Compare commits

...
Author SHA1 Message Date
skylar taylor-barrick ef24afc10c #618: move the OFFSET canvas test into the ui-egui test binary
Main now builds one integration-test binary per crate (#1989); only tests that install process-wide state stay separate. This test only drives its own session, so it joins tests/it.
2026-10-23 12:18:39 -06:00
skylar taylor-barrick 6528bb564b Merge remote-tracking branch 'origin/main' into test/backfill-regression-tests 2026-10-23 12:09:53 -06:00
aiongg 37150774c6 CI: run the gate through craft-common-actions' shared gate.yml; FreeBSD nightly and on release (#1987)
ci.yml is new: it calls the shared workflow (cached apt packages, nextest, main-only cache saves, Cargo.lock check). FreeBSD and Windows ARM64 run nightly and on pushes to release instead of on pull requests. Depends on storytold/craft-common-actions#69.
2026-10-23 13:56:16 -04:00
aiongg 9cf115e808 CI: one test binary per crate, nextest, line tables only (#1989)
* Build: line tables only in dev, no debug info for dependencies

* Tests: one integration-test binary per crate

Each crate's tests/*.rs becomes a module of tests/it/main.rs. Files that
install process-wide state (the file io hooks, user aliases, SAVETIME and
SAVEFILEPATH, user linetype and pattern files, registered fonts) stay their
own test binaries.

* xtask ci: nextest, tests without examples, one wasm check call
2026-10-23 13:34:26 -04:00
aiongg 9d8f16c825 Merge pull request #1991 from marcoarnulfo/fix/harfrust-0.14
Build main again after the #1957 dependency bump
2026-10-23 13:00:16 -04:00
Dylan M. Taylor f043540d13 packaging: use [[ ]] tests, named locals and explicit returns in shell scripts (SonarCloud S7688/S7679/S7682) (#1972)
All flagged scripts run under bash (shebang or '# shellcheck shell=bash'), so
[[ ]] is safe. Positional parameters in helper functions are copied into named
locals, and helper functions end with a bare 'return', which keeps the exit
status of the last command (no behaviour change, notably for sha256 under
pipefail). packaging/linux/postinst.sh is POSIX sh and is left alone.
2026-10-23 11:16:40 -04:00
Dylan M. Taylor 78decf80f7 web: allow browser zoom in the viewport meta (SonarCloud Web:S7926) (#1974)
user-scalable=no stops people with low vision from zooming the page. The
canvas keeps its own pinch/pan handling: eframe already prevent_default()s
touch and gesture events on the canvas, so dropping the flag only restores
browser zoom (and accessibility zoom) outside of the app's gestures.
2026-10-23 11:16:34 -04:00
Marco Arnulfo 8ad9a3ce15 dwg: require acadrust 0.6.5, the first version that inverts dimension bit 0 itself 2026-10-23 14:54:19 +02:00
Marco Arnulfo 251fddb658 fonts: harfrust comment names both versions; the advance check skips fonts at 1000 per em 2026-10-23 09:49:38 +02:00
Marco Arnulfo 51db2d52e4 dependabot: leave the egui crates at 0.36 until craft-ui moves to 0.37 (craft-libs#199) 2026-10-23 09:49:38 +02:00
Marco Arnulfo 9621dcbd94 dwg: drop the dimension bit-0 inversion that acadrust 0.6.5 now does itself 2026-10-23 09:30:10 +02:00
Marco Arnulfo 5648a06ac4 Hold the egui crates at 0.36 until craft-ui moves to 0.37 (craft-libs#199) 2026-10-23 09:14:51 +02:00
Marco Arnulfo f344cf4460 engine: pass the query envelope by value to rstar 0.13 2026-10-23 08:50:32 +02:00
Marco Arnulfo e61e328edb io: read SVG element and attribute names with quick-xml 0.42 2026-10-23 08:50:32 +02:00
Marco Arnulfo 611eb9f5a9 fonts: port complex-text shaping to harfrust 0.14 2026-10-23 08:44:31 +02:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 1977a493c7 chore(deps): bump the cargo-dependencies group across 1 directory with 17 updates (#1957)
Bumps the cargo-dependencies group with 17 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [serde_json](https://github.com/serde-rs/json) | `1.0.151` | `1.0.154` |
| [tiny-skia](https://github.com/linebender/tiny-skia) | `0.11.4` | `0.12.0` |
| [skrifa](https://github.com/googlefonts/fontations) | `0.47.0` | `0.48.0` |
| [harfrust](https://github.com/harfbuzz/harfrust) | `0.12.0` | `0.14.0` |
| [quick-xml](https://github.com/tafia/quick-xml) | `0.41.0` | `0.42.0` |
| [egui](https://github.com/emilk/egui) | `0.36.2` | `0.37.0` |
| [egui_kittest](https://github.com/emilk/egui) | `0.36.2` | `0.37.0` |
| [eframe](https://github.com/emilk/egui) | `0.36.2` | `0.37.0` |
| [egui-wgpu](https://github.com/emilk/egui) | `0.36.2` | `0.37.0` |
| [arboard](https://github.com/1Password/arboard) | `3.6.1` | `3.7.0` |
| [acadrust](https://github.com/hakanaktt/acadrust) | `0.6.4` | `0.6.5` |
| [rstar](https://github.com/georust/rstar) | `0.12.2` | `0.13.0` |
| [libc](https://github.com/rust-lang/libc) | `0.2.190` | `0.2.191` |
| [miniz_oxide](https://github.com/Frommi/miniz_oxide) | `0.8.9` | `0.9.1` |
| [getrandom](https://github.com/rust-random/getrandom) | `0.3.4` | `0.4.3` |
| [muda](https://github.com/tauri-apps/muda) | `0.21.0` | `0.21.2` |
| [winresource](https://github.com/BenjaminRi/winresource) | `0.1.31` | `0.1.34` |



Updates `serde_json` from 1.0.151 to 1.0.154
- [Release notes](https://github.com/serde-rs/json/releases)
- [Commits](https://github.com/serde-rs/json/compare/v1.0.151...v1.0.154)

Updates `tiny-skia` from 0.11.4 to 0.12.0
- [Changelog](https://github.com/linebender/tiny-skia/blob/main/CHANGELOG.md)
- [Commits](https://github.com/linebender/tiny-skia/compare/v0.11.4...v0.12.0)

Updates `skrifa` from 0.47.0 to 0.48.0
- [Release notes](https://github.com/googlefonts/fontations/releases)
- [Changelog](https://github.com/googlefonts/fontations/blob/main/docs/RELEASE.md)
- [Commits](https://github.com/googlefonts/fontations/compare/skrifa-v0.47.0...skrifa-v0.48.0)

Updates `harfrust` from 0.12.0 to 0.14.0
- [Release notes](https://github.com/harfbuzz/harfrust/releases)
- [Changelog](https://github.com/harfbuzz/harfrust/blob/main/CHANGELOG.md)
- [Commits](https://github.com/harfbuzz/harfrust/compare/0.12.0...0.14.0)

Updates `quick-xml` from 0.41.0 to 0.42.0
- [Release notes](https://github.com/tafia/quick-xml/releases)
- [Changelog](https://github.com/tafia/quick-xml/blob/master/Changelog.md)
- [Commits](https://github.com/tafia/quick-xml/compare/v0.41.0...v0.42.0)

Updates `egui` from 0.36.2 to 0.37.0
- [Release notes](https://github.com/emilk/egui/releases)
- [Changelog](https://github.com/emilk/egui/blob/main/CHANGELOG.md)
- [Commits](https://github.com/emilk/egui/compare/0.36.2...0.37.0)

Updates `egui_kittest` from 0.36.2 to 0.37.0
- [Release notes](https://github.com/emilk/egui/releases)
- [Changelog](https://github.com/emilk/egui/blob/main/CHANGELOG.md)
- [Commits](https://github.com/emilk/egui/compare/0.36.2...0.37.0)

Updates `eframe` from 0.36.2 to 0.37.0
- [Release notes](https://github.com/emilk/egui/releases)
- [Changelog](https://github.com/emilk/egui/blob/main/CHANGELOG.md)
- [Commits](https://github.com/emilk/egui/compare/0.36.2...0.37.0)

Updates `egui-wgpu` from 0.36.2 to 0.37.0
- [Release notes](https://github.com/emilk/egui/releases)
- [Changelog](https://github.com/emilk/egui/blob/main/CHANGELOG.md)
- [Commits](https://github.com/emilk/egui/compare/0.36.2...0.37.0)

Updates `arboard` from 3.6.1 to 3.7.0
- [Release notes](https://github.com/1Password/arboard/releases)
- [Commits](https://github.com/1Password/arboard/compare/v3.6.1...v3.7.0)

Updates `acadrust` from 0.6.4 to 0.6.5
- [Release notes](https://github.com/hakanaktt/acadrust/releases)
- [Changelog](https://github.com/hakanaktt/acadrust/blob/main/CHANGELOG.md)
- [Commits](https://github.com/hakanaktt/acadrust/compare/v0.6.4...v0.6.5)

Updates `rstar` from 0.12.2 to 0.13.0
- [Commits](https://github.com/georust/rstar/compare/v0.12.2...v0.13.0)

Updates `libc` from 0.2.190 to 0.2.191
- [Release notes](https://github.com/rust-lang/libc/releases)
- [Changelog](https://github.com/rust-lang/libc/blob/0.2.191/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/libc/compare/0.2.190...0.2.191)

Updates `miniz_oxide` from 0.8.9 to 0.9.1
- [Changelog](https://github.com/Frommi/miniz_oxide/blob/master/CHANGELOG.md)
- [Commits](https://github.com/Frommi/miniz_oxide/commits)

Updates `getrandom` from 0.3.4 to 0.4.3
- [Changelog](https://github.com/rust-random/getrandom/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rust-random/getrandom/compare/v0.3.4...v0.4.3)

Updates `muda` from 0.21.0 to 0.21.2
- [Release notes](https://github.com/tauri-apps/muda/releases)
- [Changelog](https://github.com/tauri-apps/muda/blob/dev/CHANGELOG.md)
- [Commits](https://github.com/tauri-apps/muda/compare/muda-v0.21...muda-v0.21.2)

Updates `winresource` from 0.1.31 to 0.1.34
- [Commits](https://github.com/BenjaminRi/winresource/commits)

---
updated-dependencies:
- dependency-name: acadrust
  dependency-version: 0.6.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: cargo-dependencies
- dependency-name: arboard
  dependency-version: 3.7.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: cargo-dependencies
- dependency-name: eframe
  dependency-version: 0.37.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: cargo-dependencies
- dependency-name: egui
  dependency-version: 0.37.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: cargo-dependencies
- dependency-name: egui-wgpu
  dependency-version: 0.37.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: cargo-dependencies
- dependency-name: egui_kittest
  dependency-version: 0.37.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: cargo-dependencies
- dependency-name: getrandom
  dependency-version: 0.4.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: cargo-dependencies
- dependency-name: harfrust
  dependency-version: 0.14.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: cargo-dependencies
- dependency-name: libc
  dependency-version: 0.2.191
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: cargo-dependencies
- dependency-name: miniz_oxide
  dependency-version: 0.9.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: cargo-dependencies
- dependency-name: muda
  dependency-version: 0.21.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: cargo-dependencies
- dependency-name: quick-xml
  dependency-version: 0.42.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: cargo-dependencies
- dependency-name: rstar
  dependency-version: 0.13.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: cargo-dependencies
- dependency-name: serde_json
  dependency-version: 1.0.154
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: cargo-dependencies
- dependency-name: skrifa
  dependency-version: 0.48.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: cargo-dependencies
- dependency-name: tiny-skia
  dependency-version: 0.12.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: cargo-dependencies
- dependency-name: winresource
  dependency-version: 0.1.34
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: cargo-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-22 23:54:32 -04:00
skylar taylor-barrick d0281360c2 #618: finish the grid-snapped OFFSET in the canvas test
The test stopped once the source line was picked, so a broken side-point step could leave the drawing unchanged and still pass. It now clicks a side point above the line and checks that the copy lands 5 units up, at y = 7. It also checks that OFFSET asks for the next object and that Enter ends it.
2026-10-22 21:16:48 -06:00
skylar taylor-barrick a08fd7c1d1 Merge pull request #1963 from storytold/docs/security-review-docstrings-20261022
docs: document security limits and regression guarantees
2026-10-22 18:36:19 -06:00
skylartaylor fa6bb0351b docs: clarify security regression guarantees 2026-10-23 02:14:11 +02:00
Brandon ThomasandClaude Opus 5.5 bad1073fdf Release 0.12.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 20:06:04 -04:00
skylartaylor 48b4165591 docs: document security limits and regression guarantees 2026-10-23 01:59:55 +02:00
skylar taylor-barrick de5aecfe8b #651: compare small text against the plot build path
The full-detail baseline used build(), which takes the on-screen path; plots take build_plot(), which keeps glyphs at full detail and records them as plot text. Comparing against build_plot() checks what the test's name says. With the screen-detail change reverted it still fails (480 triangles on screen, 480 in the plot).
2026-10-22 16:49:30 -06:00
Dylan M. Taylor 01bbdb24bc security: confine craft-fonts manifest paths and reject newline directives (CWE-22, CWE-74) (#1841)
The optional `CRAFT_FONTS_DIR` build input lets a font manifest select the file
each face is embedded from. `build.rs` canonicalised that file with no
containment check, so an absolute path, a `..` traversal, or a symlink inside
the checkout could make the generated `include_bytes!` read an arbitrary file
on the build host and expose its bytes through `CRAFT_FONTS` (CWE-22).

It also printed the resolved path straight into Cargo's newline-delimited
build-script protocol (`cargo::rerun-if-changed=…`). A path containing an
embedded newline — reachable through a crafted symlink target — could inject a
second directive such as `cargo::rustc-env=OUT_DIR=…`, redirecting the
generated-source include to attacker-supplied Rust (CWE-74).

- `resolve_font` rejects absolute and `..` manifest paths and requires the
  canonicalised result to stay inside the canonicalised checkout root.
- `rerun_if_changed` refuses any path containing CR/LF before emitting the
  directive; `one_line` collapses CR/LF in checkout-derived diagnostics.

Addresses findings rows 5 (CWE-22) and 14 (CWE-74).
2026-10-22 18:42:51 -04:00
skylar taylor-barrick 546b5a1cf3 #651: test that small filled text is simplified on screen and keeps full detail in plots
Filled TrueType text was triangulated at full outline detail at every zoom, which froze the canvas on pages of small text. The test builds four O glyphs on screen at 10 px and under 2 px per em and checks the triangle count drops (to one box per glyph at the smallest), while a plot keeps every triangle. Without the fix the on-screen build has the same 1656 triangles as the plot. It skips when no system font is installed, like the other TrueType render tests.
2026-10-22 16:34:32 -06:00
skylar taylor-barrick 2ab09850bb #1336: test that the DXF LAYER table stops at the record cap
The LAYER, LTYPE, STYLE and DIMSTYLE tables had no record cap, unlike VIEW, UCS and VPORT. The test reads a DXF with over a million LAYER records and checks the layer list stays at the cap; without the guard it holds every record (1000102 layers). The other three tables scan their lists linearly per record, so a million-record file is too slow to build in a test and they stay uncovered.
2026-10-22 16:34:32 -06:00
skylar taylor-barrick 4ab8c384b3 #618: test that OFFSET takes a clicked distance and picks its source under grid snap
OFFSET only accepted a typed distance, and with grid snap on a click meant to pick the source object was moved to the nearest grid point. The engine test clicks two points for the distance and checks the guide, the distance and the resulting offset; the canvas test does the same through grid-snapped clicks and then picks the source line. Without the fix the engine test stays at the distance prompt and the canvas test never picks the line.
2026-10-22 16:34:32 -06:00
Dylan M. Taylor 15ba3dc5cc security: submit XATTACH picked path as literal text, not a command line (CWE-74) (#1843)
The External References palette's XATTACH handler passed the file picker's
result to `app.cmdline(&path)`. `cmdline` interprets command-line syntax, so a
selected filename beginning with an apostrophe (for example `'closeall`) was run
as an engine command rather than naming the file — closing every drawing without
the UI's unsaved-change confirmation.

The picked path is attacker-controlled (the picker returns whatever the user
selected, and `relative_to_host` can reduce it to a bare basename). After
starting XATTACH, submit it through `Session::input(Input::Text(path))` so it is
delivered to the command's prompt as literal text and never interpreted as a
command, matching how other picker/dialog bridges already behave.

Addresses finding row 26.
2026-10-22 18:19:13 -04:00
Dylan M. Taylor 07bb604214 security: guard the DXF->DWG conversion input and document decoder limits (CWE-789) (#1847)
The native DWG bridge wraps the third-party `acadrust` decoder, which allocates
tables and section buffers from sizes a file declares and cannot be interrupted
once it starts. The DWG->DXF read path already rejects oversized input before
decoding, caps the DXF output while writing, and runs inside `catch_unwind`. The
DXF->DWG `convert` path had no input-size guard, so a very large DXF went
straight into the dependency's reader.

- `convert` now rejects DXF input larger than `Limits::max_dxf_bytes` before
  calling `DxfReader::read`, symmetric with the read path, and keeps the existing
  `catch_unwind` around the decoder and writer (rows 23, 3, 27, large-input
  variant).
- Documented the residual risk: a small file that declares a huge internal table
  or section can still make the dependency allocate before any size is known.
  Fully fixing that needs an upstream fix in `acadrust` (validate declared sizes
  against a decoded-memory budget) or a separate worker with an OS memory limit;
  the in-crate limits keep the blast radius bounded until then.

Addresses findings rows 3, 23, 27 (bounds the reachable input/output size;
the deeper allocation fix belongs in the unmodified dependency).
2026-10-22 18:18:43 -04:00
Dylan M. Taylorandskylartaylor dc4bbe86f6 security: bound font outline geometry and MTEXT line scanning (CWE-400/407) (#1852)
* security: bound font outline geometry and MTEXT line scanning (CWE-400/407)

Two font/text paths let crafted input monopolise the rendering thread:

- Glyph shaping copied a glyph's full contour set for every placed character
  with no cumulative budget, so a compact CFF/OTF font with a large expanded
  outline, repeated across a short string, could grow the shaped result without
  bound. `shape` now charges retained outline points against a cumulative
  per-line budget and stops placing geometry once it is exhausted (advances
  still accumulate, so the width stays sensible) (row 9).
- Default MTEXT layout recomputed `line.text.chars().count()` before appending
  each segment or space, rescanning the whole accumulated line every time —
  multiplicative on a long line. The line now keeps a running character count,
  updated only from newly appended text (row 20).

New tests cover the MTEXT layout; all font tests pass and render still builds,
clippy clean.

Addresses findings rows 9, 20.

* fix: budget glyph geometry before outline placement

* fix: skip empty outlines without text metadata

---------

Co-authored-by: skylartaylor <26588250+skylartaylor@users.noreply.github.com>
2026-10-22 17:57:39 -04:00
Dylan M. Taylorandskylartaylor 953a6b3b01 security: bound Lisp callbacks, native comparisons, and local file reads (CWE-400, CWE-407) (#1842)
* security: bound Lisp callbacks, native comparisons, and local file reads (CWE-400, CWE-407)

The command and scripting engine had several unbounded paths a supplied LISP
routine or a selected path could exploit to monopolise the thread or exhaust
memory:

- `call_sync` ran synchronous callbacks (vl-sort comparators, vl-remove-if
  predicates, …) with `u64::MAX` fuel, so a constant-memory infinite loop never
  yielded and bypassed the blocking execution cap. It now drives the callback in
  bounded slices and aborts with an error past a hard step cap (row 2).
- `wcmatch`/`glob` explored `*` placements with only a recursion-depth guard,
  and `equal` re-traversed shared cons subtrees. Both now charge every step
  against a shared work budget and stop when it is exhausted (row 33).
- `setvar` converted a LISP value to JSON unboundedly, so a shared cons DAG
  expanded into gigabytes of owned arrays. Conversion is now depth- and
  element-bounded and rejects over-budget values with a LISP error (row 39).
- OPEN/LOAD/APPLOAD (`read_text_file`), SCRIPT, and native open (`read_file`,
  `run_open`) buffered a path with `std::fs::read`, so an endless device
  exhausted memory before any size check. Reads now go through a new
  `capped_read` helper that rejects non-regular files and caps bytes
  (rows 21, 34).

New tests cover the callback loop, the oversized setvar structure, and the
capped reader. Addresses findings rows 2, 21, 33, 34, 39.

* fix: bound native Lisp scans and nonblocking file reads

* fix: enforce callback fuel before each execution slice

---------

Co-authored-by: skylartaylor <26588250+skylartaylor@users.noreply.github.com>
2026-10-22 17:57:09 -04:00
Dylan M. Taylorandskylartaylor 8085745a22 security: bound native DXF/SVG/PDF import allocation and quadratic work (CWE-400/407/789) (#1846)
* security: bound native DXF/SVG/PDF import allocation and quadratic work (CWE-400/407/789)

Several native import paths let a crafted drawing cause quadratic CPU work or
unbounded allocation:

- DXF layer decoding scanned the whole layer list for every LAYER record
  (`d.layer_mut`), so many distinct layer names made import quadratic. Keep a
  case-normalised name→index map and update/append in O(1) (row 29).
- ACAD_TABLE cells were materialised per table under a per-table cap, but many
  small tables could still add up. Add a document-wide table-cell budget in
  parse_entities; a table that would overflow it is reduced to empty (row 8).
- DXF image name allocation restarted its `_2, _3…` suffix probe for every
  definition sharing a base name (quadratic). Keep a per-base next-suffix
  counter so probing resumes where it left off (row 13).
- Image resolution cloned each definition's path/name into every referring
  image. Add an aggregate byte budget; once spent, string copies stop while
  geometry fields are still applied (row 42).
- PDF link annotation scanned the whole selected-link vector for every
  primitive. Build a HashSet of linked handles once for O(1) membership (row 15).
- SVG arc/curve flattening retained each flushed run with only a per-run cap, so
  repeated arcs amplified a compact path into unbounded geometry. Add a
  document-wide generated-vertex budget charged in `add` (row 31).

New test `svg_arc_budget` covers the SVG budget. All `cadcraft-io` tests pass;
clippy adds no new warnings.

Addresses findings rows 8, 13, 15, 29, 31, 42.

* fix: share DXF table budgets before allocation

---------

Co-authored-by: skylartaylor <26588250+skylartaylor@users.noreply.github.com>
2026-10-22 17:35:43 -04:00
Dylan M. Taylorandskylartaylor ca578a8fb7 security: authenticate the local control server with a per-session token (CWE-306) (#1844)
* security: authenticate the local control server with a per-session token (CWE-306)

The optional `--control PORT` server listens on loopback TCP and dispatched any
JSON request without authenticating the peer. Loopback TCP does not distinguish
operating-system users, so another local account could connect and drive the
desktop user's command and file authority (for example reading files through
native Lisp, or create/truncate writes).

Authenticate the peer with an unguessable per-session token:

- New `cadcraft_mcp::control_token` module: `issue(port)` generates a 256-bit
  token and writes it to `cadcraft-control-<port>.token` in the per-user runtime
  directory with owner-only permissions (0600 on Unix; per-user LOCALAPPDATA on
  Windows). `read(port)` reads it; `matches` compares in constant time.
- The control server issues the token at startup and requires every request to
  carry a matching `token` field; requests without one get an `unauthorized`
  reply and the connection is closed before dispatch.
- The MCP bridge (`Remote`) reads the token from the owner-only file — which
  needs the desktop user's own filesystem access — and sends it on every
  request, so the bridge keeps working unchanged for the legitimate user. A
  different local user cannot read the token and cannot authenticate.

Updates `docs/control-protocol.md`. New tests cover token issue/read/compare,
file permissions, and that the server refuses unauthenticated requests while
accepting authenticated ones.

Addresses finding row 48.

* fix: recover control authentication across server restarts

---------

Co-authored-by: skylartaylor <26588250+skylartaylor@users.noreply.github.com>
2026-10-22 17:35:18 -04:00
Dylan M. Taylorandskylartaylor 916be26ed3 security: bound constraint-engine work and allocation (CWE-400/407) (#1851)
* security: bound constraint-engine work and allocation (CWE-400/407)

The constraint engine let imported geometry or settings cause disproportionate
CPU/memory work on the synchronous analysis thread:

- Auto-constraint inference scanned the whole caller-supplied type list for every
  geometric candidate. Resolve the allowed type names into a set once and use
  O(1) membership per candidate (row 46).
- Rigid-point sampling stopped after collecting points but kept traversing block
  members that contribute none (Text, Ray, …), repeated for every Insert. Count
  every examined member against a bounded budget (row 47).
- Control-mode residual evaluation cloned the whole retained spline, including
  excess imported weights, for each free parameter. Drop weights that do not
  match the control-point count before retaining the template (row 45).
- The DOF analysis budget estimated only matrix-rank work, ignoring the spline
  reconstruction each tangent residual performs during numerical differentiation.
  Add a residual-evaluation budget (rows × free × per-residual cost, including a
  spline-reconstruction term) enforced before building the Jacobian (row 43).

All constraints tests pass; clippy adds no new warnings.

Addresses findings rows 43, 45, 46, 47.

* fix: budget constraint work without changing spline weights

---------

Co-authored-by: skylartaylor <26588250+skylartaylor@users.noreply.github.com>
2026-10-22 17:12:04 -04:00
Dylan M. Taylorandskylartaylor 2dbc7699a0 security: budget the rendering pipeline against DoS geometry (CWE-400/407/789) (#1849)
* security: budget the rendering pipeline against DoS geometry (CWE-400/407/789)

Several render paths let crafted drawing data spin the rendering thread or
allocate without bound:

- Hatch dash sweeping reset its per-span step counter and used emitted geometry
  as its only limit, so gap-only families (which emit nothing) could multiply
  CPU work. Skip families that cannot draw and charge every dash step against a
  shared per-hatch work budget (row 10).
- Hatch polygon deduplication compared loops with an O(n²) cyclic match and
  every loop against every kept one. Charge vertex comparisons against a shared
  budget and stop deduplicating when spent (row 12).
- Table coverage marked every merge rectangle, so overlapping merges re-marked
  covered cells quadratically. Bound marking to a small multiple of the cell
  count (row 11).
- Broken-table part layout expanded sparse rows to full width before checking
  whether detailed layout is allowed. Only expand when within the cell limit;
  otherwise leave cells sparse and let layout draw an outline (row 17).
- Dimension text substitution copied the base for every `<>` placeholder with no
  size bound. Estimate the expansion and cap it at a fixed text budget (row 32).
- Linetype range generation cycled all-gap patterns forever (nothing emitted, so
  the emit limit never tripped). Return continuous for all-gap patterns and
  bound visited pattern elements (row 38).
- Viewport contents built with a fresh expansion counter, so a layout could far
  exceed the documented block-expansion limit. Share one budget across the whole
  build, seeded with what the enclosing list already spent (row 22).

New tests for the all-gap linetype path; all render tests pass, clippy clean.

Addresses findings rows 10, 11, 12, 17, 22, 32, 38.

* fix: preserve bounded render fallbacks and text output

---------

Co-authored-by: skylartaylor <26588250+skylartaylor@users.noreply.github.com>
2026-10-22 17:08:25 -04:00
Dylan M. Taylorandskylartaylor 7c72ec45a9 security: bound drawing document-model work and allocation (CWE-400/407) (#1850)
* security: bound drawing document-model work and allocation (CWE-400/407)

Several document-model operations let crafted drawing data cause quadratic or
exponential work, or multiply retained allocations:

- Polyline grip generation rebuilt the whole segment vector inside the loop over
  every vertex, so enumerating one large polyline's grips was quadratic. Compute
  the segments once before the loop (row 35).
- Associative-array name sync searched the whole replacement vector for every
  placement. Index replacements by placement coordinate once (last entry wins),
  turning the per-item lookup O(1) (row 44).
- Dimension-extent bounds recursively expanded the referenced block without the
  shared bounds cache the Insert path uses, so a branching or self-referencing
  dimension block cost one pass per reference. Route it through the same
  `(name, depth)` cache (row 37).
- Dynamic-block parsing parsed every graph-member occurrence into its own owned
  parameter, so a graph repeating a member multiplied one record into gigabytes.
  Deduplicate member handles before resolving them (row 7).
- Stretch-action application linearly searched the vertex list and cloned the
  index vector for every selection occurrence. Index vertices by handle and skip
  handles already handled in the action's selection (row 30).

New tests for polyline grips; all doc tests pass, clippy clean.

Addresses findings rows 7, 30, 35, 37, 44.

* fix: retain first dynamic stretch vertex selection

---------

Co-authored-by: skylartaylor <26588250+skylartaylor@users.noreply.github.com>
2026-10-22 17:08:02 -04:00
Dylan M. Taylorandskylartaylor 0158712bfd security: bound geometry-kernel degree and work (CWE-407) (#1848)
* security: bound geometry-kernel degree and work (CWE-407)

Several geometry routines let a crafted spline or region cause quadratic or
worse CPU work:

- B-spline basis evaluation is O(degree²) per sample and tessellation takes
  O(degree) samples, so a HATCH/DXF spline edge declaring a very high degree
  (DXF allows up to 100 000) turned a tiny spline into billions of iterations.
  Add `MAX_DEGREE = 64` and treat higher-degree splines as invalid, so
  `eval`/`tessellate` fall back to the control polygon. The render clip boundary
  also rejects such degrees up front (rows 18, 28).
- `Spline::derivative` re-ran the O(knots) validity scan on every call, and
  arc-length integration calls it once per sample, making a valid spline's
  measurement quadratic. Split off `derivative_unchecked` and have integration
  (validated once by its public entry points) use it (row 24).
- `infer_knot_param` matched each fit parameter against the whole knot vector
  from the start. Both are ascending, so replace it with a linear merge (row 16).
- Fillet seed collection did not stop the enclosing run-pair loops when the
  per-call candidate cap was hit, and refined duplicate candidates. Bound total
  seeds across pairs and deduplicate before Newton refinement (row 36).
- Region grid intersection counted only distinct pairs against its budget, so a
  long segment replicated across many cells re-visited the same pair unbounded.
  Charge every candidate-pair visit against a budget (row 41).

New spline degree tests added; all geom and render tests pass, clippy clean.

Addresses findings rows 16, 18, 24, 28, 36, 41.

* fix: preserve spline knot and fillet search behavior

---------

Co-authored-by: skylartaylor <26588250+skylartaylor@users.noreply.github.com>
2026-10-22 17:05:16 -04:00
Dylan M. Taylorandskylartaylor 683eb5e6e7 security: do not follow directory symlinks in cargo xtask stats (CWE-400) (#1845)
* security: do not follow directory symlinks in `cargo xtask stats` (CWE-400)

The stats walker descended with `Path::is_dir()`, which follows symlinks, and
kept no record of visited directories. A small acyclic graph of directory
aliases planted in a workspace package (for example through a contribution a
developer inspects) therefore caused exponentially repeated enumeration and file
reads, consuming CPU and I/O far beyond the repository's real size.

`walk` now inspects each entry's own `file_type()` and skips symlinks before
descending or reading, so directory aliases are never re-scanned. A traversal
budget (`MAX_ENTRIES`) bounds total work as defense in depth.

Addresses finding row 4.

* fix: report statistics traversal exhaustion

---------

Co-authored-by: skylartaylor <26588250+skylartaylor@users.noreply.github.com>
2026-10-22 17:04:35 -04:00
Dylan M. Taylorandskylartaylor 345fb27a69 security: write files atomically through an exclusive temp file (CWE-59) (#1840)
* security: write files atomically through an exclusive temp file (CWE-59, CWE-377)

Save, export, plot, autosave and the audit log each wrote their output by
putting bytes in a predictable sibling temporary path (`<path>.cadcraft-tmp`,
`<recovery>.tmp`, or the companion `.adt`) and renaming it over the real file.
Opening that pre-existing temporary path for a truncating write follows a
symlink planted there by another local user, letting them redirect the write
onto any file the victim can write before the rename.

Add a shared `atomic_write` helper that creates a fresh temporary file with
`create_new` (O_CREAT|O_EXCL) in the destination directory, writes through the
retained handle, flushes/syncs, and atomically renames it into place. An
existing entry (symlink or not) makes the exclusive create fail instead of
being followed. Route autosave, save, export, plot and the audit log through it.

The macOS CI cert importer likewise wrote the decoded PKCS#12 container to a
fixed name in a possibly shared temp directory; it now creates a private
per-run directory with `mktemp -d` and creates the file with noclobber.

Addresses findings rows 1, 25, 40 (CWE-59) and 19 (CWE-377).

* fix: preserve permissions during atomic replacement

* fix: restrict temporary permissions during exclusive creation

---------

Co-authored-by: skylartaylor <26588250+skylartaylor@users.noreply.github.com>
2026-10-22 17:03:46 -04:00
Brandon ThomasandClaude Opus 5.5 8f988f630d Release 0.11.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 15:37:25 -04:00
Dylan M. Taylor acd109d97c Use chrono instead of hand-rolled UTC conversion (#1789)
* Use chrono for UTC/calendar conversion instead of hand-rolled copies

Replaces the three civil_from_days copies (logging timestamp, Drawing
Recovery time, parity checklist date) with chrono as a workspace
dependency. Output formats are unchanged and pinned by the existing tests.
AGENTS.md now says time goes through chrono.

Fixes #1785

* Never panic on a clock outside chrono's range; keep 5-digit years unprefixed

chrono's From<SystemTime> unwraps, so a clock past year 262143 panicked
inside the logger. timestamp and xtask's date now use the fallible
DateTime::from_timestamp and read such a clock as the epoch, as the old
saturating code did. xtask's date also clamps a pre-epoch clock to
1970-01-01 again. Years past 9999 are written with {:04} like before,
because %Y would add a '+'. Tests cover all three cases.

* Don't build an unrepresentable SystemTime in the far-future test

UNIX_EPOCH + Duration::from_secs(u64::MAX / 2) overflows Windows'
SystemTime and panicked before timestamp() ran, failing the Windows
test job. Build it with checked_add and only assert where it exists.

* logging/autosave/parity: keep extended-year dates, no epoch fallback

Applies the stricter no-epoch-fallback rule (from wordcraft#3427 / filmcraft#2662,
superseding the earlier epoch-or-last-valid suggestion) so all sibling PRs agree:
a timestamp the old code converted past chrono's year 262143 range must keep its real
proleptic-Gregorian date, not collapse to 1970-01-01. Each site uses the 400-year
Gregorian cycle (146_097 days) with chrono as the only calendar logic: chrono converts
the day within one cycle, whole cycles carry in the year (i128, saturated into i64).
This also removes the panic FredApps flagged — `DateTime::<Utc>::from(SystemTime)` could
panic past chrono's range; `from_num_days_from_ce_opt` within one cycle never does.
Verified the cycle result equals the old formula across a wide range and at the extremes.

- apps/cadcraft/src/logging.rs `timestamp`: cycle `civil_from_days(u64)`; pre-1970 epoch
  clamp and ms/time-of-day format kept. Pins: +10_000_000_000_000 s -> 318857-05-20T17:46:40.000Z
  (was 1970-...); checked_add u64::MAX/2 s -> 292277026596-12-04T15:30:07.000Z (was epoch).
- crates/ui-egui/src/autosave.rs `utc`: cycle converter. Pins: utc(10_000_000_000_000)
  = "318857-05-20 17:46"; utc(u64::MAX) = "584554051223-11-09 07:00" (was "1970-01-01 00:00").
- xtask/src/parity.rs `date`: cycle converter; pre-1970 epoch clamp kept. Pins:
  day(115_740_740) = "318857-05-20"; checked_add u64::MAX/2 s -> "292277026596-12-04" (was epoch).

* Don't build a year-318857 SystemTime unchecked in the logging test

Windows' SystemTime only reaches about year 30828, so
UNIX_EPOCH + Duration::from_secs(10_000_000_000_000) panicked before
timestamp() ran and failed the Windows test job. Build it with
checked_add and assert where the platform can hold it. The year-10000
case fits everywhere and stays unconditional.

* Name the 400-year range extension as the one exception to the time rule

The rule said never to hand-roll calendar math, but the range fix
(shifting by whole 400-year Gregorian cycles so dates past chrono's year
range keep their old output) is cycle arithmetic, so the rule and the code
disagreed. The rule now names that exception: the library still computes
month, day and time, and only whole cycles are carried in the year.

* Say the library computes month and day; time-of-day is plain arithmetic

Narrows the range-extension wording: the date library computes the
calendar fields, and splitting seconds into hours, minutes and seconds
is ordinary arithmetic, which is what the code does.
2026-10-22 14:09:16 -04:00
Antoine Boxho f4e6873130 Merge pull request #1568 from C00kieMonsta/readme-official-source
README: verify official releases and download checksums (fixes #1554)
2026-10-22 14:05:42 -04:00
skylar taylor-barrick e4214f7180 Merge pull request #1677 from MarekSpodBiedry/issue-1644
Write the ACAD_TABLE *T block relative to its insertion point (#1644)
2026-10-22 07:51:40 -06:00
skylar taylor-barrick 35934718ea Merge pull request #1683 from MarekSpodBiedry/issue-1667
Keep a layout's plot window corners when saving DXF (#1667)
2026-10-22 07:51:33 -06:00
skylar taylor-barrick 916d440bed Merge pull request #1718 from oracola91/fix/control-accept-backoff
Control server: wait after a failed accept instead of retrying at once
2026-10-22 07:51:26 -06:00
skylar taylor-barrick 632967aa78 Merge pull request #1336 from midasmatterhorn/fix/r4-dxf-table-record-caps
io: the four symbol tables honour the same record cap as their peers
2026-10-22 07:51:19 -06:00
skylar taylor-barrick f15be2305c Merge pull request #1680 from MarekSpodBiedry/issue-1650
DWG: write the current text style's own handle, so AutoCAD opens with it current (#1650)
2026-10-22 07:51:12 -06:00
skylar taylor-barrick f2dadec23a Merge pull request #1678 from MarekSpodBiedry/issue-1648
Multileader sizes no longer multiplied by DIMSCALE (#1648)
2026-10-22 07:51:06 -06:00
skylar taylor-barrick 00acec952e Merge pull request #1774 from philipp-tch/fix/foreign-handles-above-2-48
DXF: keep foreign objects whose handles are above 2^48 (fixes #1773)
2026-10-22 07:51:00 -06:00
skylar taylor-barrick 78254b4d3f Merge pull request #1480 from Silvertree2010/fix/h2426-polyline-segments-with-a-tiny-no
Treat polyline bulges with a sub-EPS sweep as straight segments
2026-10-22 07:50:53 -06:00
skylar taylor-barrick 8094d7746f Merge pull request #1635 from Hamza0101/entsel-pickbox
AutoLISP: an entsel prompt shows the pick box whatever its message says
2026-10-22 07:50:47 -06:00
skylar taylor-barrick 4ad4f4f598 Merge pull request #1628 from Hamza0101/lisp-eq-symbols
AutoLISP: `=` compares the symbols `type` returns
2026-10-22 07:50:41 -06:00
BFlat 477eec5026 Merge pull request #1894 from storytold/readme/app-icon
README: the app icon above the title
2026-10-22 18:01:10 +07:00
bflatastic 38750c6eb8 README: the app icon above the title 2026-10-22 17:57:20 +07:00
Dylan M. Taylor 75d9d72703 Merge pull request #1837 from storytold/sonar-fix/enforce-https
security: enforce HTTPS on curl downloads (SonarCloud S6506)
2026-10-21 20:13:55 -04:00
Dylan M. Taylor fdcd9610f2 security: enforce HTTPS on curl downloads (SonarCloud S6506)
Add --proto '=https' to curl invocations so redirects cannot downgrade to
plaintext HTTP. Resolves SonarCloud S6506 (shell/docker/githubactions).
2026-10-21 17:06:31 -04:00
Dylan M. Taylor 54d96834d5 ci: add sonar-project.properties for CI-based analysis
Marks the project ineligible for SonarCloud Automatic Analysis, resolving the
CI-vs-automatic-analysis conflict so the SonarCloud workflow succeeds.
2026-10-21 16:00:22 -04:00
Dylan M. Taylor 47a8fb02e7 ci: add SonarCloud scan via shared reusable workflow
Calls storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
with secrets: inherit. Adds SonarCloud badge to README where missing and
removes the redundant sonar-project.properties (config now passed by the
reusable workflow).
2026-10-21 15:46:46 -04:00
Dylan M. Taylor 896a390b42 ci: add SonarCloud scan workflow and config 2026-10-21 13:57:30 -04:00
Dylan M. Taylor 345eb60ef3 test 2026-10-21 13:57:10 -04:00
Dylan M. Taylor 0cf3bdea83 Merge pull request #1398 from dylanmtaylor/ci/shared-nix-workflow
ci: switch to shared nix workflow from craft-common-actions
2026-10-21 03:40:18 -04:00
Philipp ThomannandClaude Opus 5.5 82424e47c9 DXF: keep foreign objects whose handles are above 2^48
AutoCAD writes handles above 2^48 (whole drawings whose handles start at 0xDAD1...).
dxf_raw dropped every foreign object with such a handle, so these drawings lost their
dynamic blocks and all other unmodelled objects on read, DXF to DXF as well. DXF handles
are up to 16 hex digits; the whole 64-bit range is now believed (null still is not).
Modelled entities and table records already accepted such handles.

Test: the DOOR fixture with every handle moved above 2^48 keeps its dynamic block
through a save (red before the change).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 07:23:16 +02:00
Brandon ThomasandClaude Opus 5.5 7fef8cbe2f Skip CodeRabbit reviews on Dependabot PRs (#1727)
Adds .coderabbit.yaml so CodeRabbit no longer reviews or comments on PRs opened by dependabot[bot].

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 11:39:39 -04:00
Dylan M. Taylor f2b473dc51 docs: add SonarCloud and CodeRabbit badges to README (#1566)
* docs: add SonarCloud and CodeRabbit badges to README

* docs: put SonarCloud and CodeRabbit badges on their own line
2026-10-20 11:32:50 -04:00
oracola91 4d826ed91f Control server: wait after a failed accept instead of retrying at once
The accepting thread dropped failed accepts with incoming().flatten()
and asked again at once. When accepting keeps failing, for example while
the process has no file descriptors left, that loop keeps a core busy
until it recovers.

Wait 100 ms after a failed accept. The loop goes through a small helper
so the wait can be tested without running out of file descriptors.
2026-10-20 14:49:58 +03:00
Brandon ThomasandClaude Opus 5.5 e86eacf094 Release 0.10.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 22:18:26 -04:00
MarekSpodBiedry a7bb28d722 DWG: write the current text style's own handle, so AutoCAD opens with it current (#1650) 2026-10-19 22:33:02 +02:00
MarekSpodBiedry 27bff9cc30 Write the ACAD_TABLE *T block relative to its insertion point (#1644) 2026-10-19 22:29:01 +02:00
MarekSpodBiedry d600a80354 Multileader sizes no longer multiplied by DIMSCALE (#1648) 2026-10-19 22:27:34 +02:00
MarekSpodBiedry 81f0aa53ff Keep a layout's plot window corners when saving DXF (#1667) 2026-10-19 22:27:04 +02:00
Hamza 27861ad0ed Read a prompt's wording only where the engine wrote it
The pick-box heuristic read the message for a leading "Select", and
this branch had widened it past leading whitespace so that a routine's
own message would be read the same way. That makes a plain point prompt
look like a pick: (getpoint "\nSelect a point: ") showed the pick box
and lost its point modifiers, since pointmod refuses them wherever
picks_objects holds.

A routine writes all of its own message (raw), and says through
picks_one when it picks an object, so the wording needs reading only
where the engine wrote it. Covered for both spellings a routine might
use, with and without the leading newline.
2026-10-20 00:31:05 +05:00
Hamza b72680cfb7 AutoLISP: an entsel prompt shows the pick box whatever its message says
Whether the cursor shows the pick box came from `Prompt::picks_objects()`,
which read the prompt's wording: a selection-set prompt, or a point prompt whose
message starts with "Select". An `entsel`/`nentsel` prompt takes a point and is
not a selection set, so it fell to the wording -- and an AutoLISP routine writes
its own message, conventionally starting with a newline:

  (nentsel)                                       "Select object: "   pick box
  (nentsel "\nSelect second line or polyline: ")   starts with "\n"   none
  (entsel  "\nPick a line: ")                     says no such word  none

A prompt now says that it picks one object rather than being read for it, and
`GetRequest::prompt()` sets that for `GetKind::EntSel`, so all three show the
pick box. The wording test is read past leading whitespace as well, so a command
machine's prompt beginning with a newline is not caught by the same thing.

Point modifiers (`.x`, `.y`…) are offered at a point prompt that is not a pick,
so they no longer appear at an `entsel` prompt with a message -- as they never
did at one without.

Fix: #1598
2026-10-19 19:13:23 +05:00
Hamza d9adce038c AutoLISP: = compares the symbols type returns
`(= (type x) 'LIST)` is how an AutoLISP routine tests a type, because `type`
returns a symbol. `=` coerced both of its arguments to numbers, so the symbol
failed the coercion and the routine stopped at its first type test:

  _$ (= (type 1) 'INT)
  bad argument type: numberp: INT

`=` and `/=` now compare numbers and strings as before, and any other atom -- a
symbol, `nil`, an entity name, a selection set, a file handle -- by what it is,
the way `eq` does. Two consequences, both closer to AutoLISP than raising was:
a mismatch of kinds answers `nil` rather than failing, so `(= 1 "1")` and
`(= '(1) '(1))` answer (comparing list structure is `equal`'s job), and
`(= nil nil)` is `T`.

The ordering comparisons still take numbers and strings only, as AutoLISP has
them: `(< 'a 'b)` remains `bad argument type: numberp: A`.

Fix: #1597
2026-10-19 17:26:39 +05:00
Brandon ThomasandClaude Opus 5.5 0b32c2e6a1 i18n(hu): the ViewCube pill's key is now WCS (after #1558)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 03:02:53 -04:00
323f4629eb Add Greek (el) interface localization (#1307)
Co-authored-by: Camthalion77 <2944375+Camthalion77@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 03:02:49 -04:00
c02027abc8 Bound CAL unary minus/plus and ^ chain recursion (#1586)
Found while testing crates/engine/src/cmd/utility/calc.rs:124-140 (Parser::unary recursion, MAX_DEPTH only counted in expr() and '[')

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-19 02:57:52 -04:00
8c249aa722 Menu bar switches to the hovered menu once one is open (fixes #1567) (#1571)
With a menu of the in-window bar open, pointing at another title now
closes the open menu and opens that one. With no menu open, pointing
still opens nothing. Clicking the open title, clicking elsewhere, Esc
or choosing an item close the menu as before.

The bar now draws its titles first, then opens the hovered title's menu
if another bar menu is open, then draws the menus. egui 0.36's MenuBar
has no hover switching of its own.

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 02:57:40 -04:00
a0aa0863bf Turkish interface (Türkçe) (#1529)
Add a Turkish catalog (tr.tsv, 1,330 rows): the union of the existing
catalogs, four menu headings (Ölçülendirme for the Dimension menu, Ölçü for
the object) and single-form counts, since a Turkish noun stays singular after
a number. No marked UI literal falls back to English. Prompts read "… girin"
for Enter and "… belirtin" for Specify; clickable keywords (Top, Zero, Bottom,
_tan to, nX/nXP) keep the spelling the engine accepts.

Register the language (Preference::Turkish, `tr`, Türkçe) and add locale,
preference, count, menu-heading, live-switch, dialog and font tests. Docs,
README and ATTRIBUTION list it; the translation is AI-assisted, the running
app was reviewed by a native speaker, and line-by-line proofreading is still
to do.

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 02:57:28 -04:00
b4509d2368 DWG: leave out orphaned DGN line style components, so DGN-bloated drawings open (fixes #1482) (#1501)
* DWG: leave out orphaned DGN line style components, so DGN-bloated drawings open (fixes #1482)

A DWG that has been through MicroStation can carry hundreds of thousands of
DGN line style components no line style definition uses. In an 82.8 MB site
plan they were 888,483 of 889,053 and made the DXF rendition 1.2 GB, over the
1 GB limit, so the file didn't open. After the failsafe read, components no
LSDEFINITION reaches (through compound and point components) are left out with
their dictionary entries, and a read note says how many.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* contributors: add my real name for the About window credits

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-19 02:57:16 -04:00
13a3ebc0d1 AREA: an ellipse's perimeter is its exact length, not a closed-form estimate (#1471)
AREA's Object option reported a full ellipse's perimeter with
Ramanujan's closed-form estimate, which falls short as the ellipse
flattens: 46.0256 for a 10 x 4 ellipse (exactly 46.0262), 39.8741 for a
10 x 0.1 one (exactly 40.0110, 0.34% short). It now integrates the
perimeter with the geometry crate's ellipse arc length, the one LENGTHEN
measures with, so both report the same length for the same ellipse.

Ellipse::arc_length_from halves a panel where it needs it (the same
adaptive Gauss-Legendre as spline arc lengths): with fixed panels a
100:1 ellipse came out 4e-8 long, as it turns sharply at its ends.

Tests check four ellipses (round to 100:1) against the
arithmetic-geometric mean to 1e-9, and the printed AREA line.

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 02:57:04 -04:00
41e0860725 ALIASEDIT opens the Command Aliases dialog (#1381) (#1391)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 02:56:52 -04:00
0d23cc3cb3 MOCORO: move, copy, rotate and scale a selection from one base point until eXit (#1388)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 02:56:40 -04:00
81acc9b8e4 Print signed two-digit exponent for scientific units (LUNITS 1) (#1386)
Found while testing crates/engine/src/units.rs:13

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-19 02:56:28 -04:00
397b8e156a MLEDIT Merged Tee: the first multiline's inner elements run on to the second's (#1375)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 02:56:16 -04:00
12567f14f2 BURST: explode block references with their attribute values as text (#1345)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 02:56:04 -04:00
95e86caea5 TXT2MTXT: selected TEXT combined into one MTEXT top-down, or one MTEXT per TEXT, with SEttings (#1343)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 02:55:53 -04:00
7adf8d89cd BREAKLINE: a polyline with a break symbol between two points, with Block, Size and Extension (#1340)
Tools > Express Tools > Break-line Symbol draws one polyline from the first point to the second with a zigzag break symbol at a picked location (the midpoint on Enter), as AutoCAD's Express Tool does. Size scales the symbol (times DIMSCALE), Extension runs the line past both points, and Block takes a block of the drawing whose two points on the Defpoints layer mark where the line meets the symbol. The built-in symbol is our own geometry. One undo step; the options are remembered for the session.

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 02:55:41 -04:00
006f71f650 Keep Z and elevation of the pieces left by BREAK (#1270)
Found while testing crates/engine/src/cmd/modify.rs:1457 (break_entity; helpers `line()`/`arc()`/`lwpoly()` in crates/engine/src/cmd/helpers.rs:10-23)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-19 02:55:28 -04:00
b0ed57f1c7 i18n(sv): translate the 95 strings added since the Swedish catalog (#1258)
Covers the Hatch and Gradient dialog, the Block Editor, REFEDIT, multileader
editing, the Options selection settings, the MTEXT editor and recent files.
The Swedish catalog now has every key of the Ukrainian reference catalog.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-19 02:55:14 -04:00
30db7296c4 선택 객체 조회 필터와 MCP 연동 추가 (#1253)
## 변경 내용
- entities 명령에 selected 불리언 필터를 추가해 현재 공간의 선택 객체만 조회
- 기존 종류·레이어·영역 필터와 결합하고 필터 적용 후 페이지 처리
- MCP query_entities 스키마와 명령·제어 프로토콜 문서에 옵션 공개
- 빈 선택, 중복·오래된 핸들, 다른 공간 객체, 잘못된 입력 및 조회 전후 상태 보존 회귀 테스트 추가

## 검증
- cargo xtask ci 6단계 통과: fmt, clippy, 테스트 2,012개, assets, layers, wasm
- 별도 .NET 클라이언트와 실제 헤드리스 엔진의 localhost 통합 검증 통과
- 실제 데스크톱 UI 조작 및 고객 DWG 왕복 검증은 수행하지 않음

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 02:55:02 -04:00
a76775bf2b Holding Shift overrides ortho while it is held, as in AutoCAD (#1198)
At a point prompt with a base point, holding Shift turns ortho on when
ORTHOMODE is off and off when it is on; releasing it restores the setting.
ORTHOMODE itself is never changed. While the override turns ortho on, polar
tracking is off, as with ortho on.

The UI already passed the Shift state to Session::snap_cursor (for POLARMODE
bit 8's Shift to acquire), so the change is in the engine only: snap_cursor
applies the override to its copy of the settings. Window corners (ZOOM
Window) and prompts without a base point are unaffected.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-19 02:54:51 -04:00
6576cac1a3 JOIN with a single arc or elliptical arc offers cLose: the arc becomes its circle, the elliptical arc its full ellipse (fixes #1175) (#1197)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 02:54:39 -04:00
4125b351cd Add HATCHEDIT boundary add and remove (#1156)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 02:54:28 -04:00
6d15a40f43 Selection cycling: a click over overlapping objects lists them, Shift+Space steps through them (#1149)
* Selection cycling: a click over overlapping objects lists them, Shift+Space steps through them (fixes #1143, fixes #1145)

* canvas: keep viewport_content's doc comment on it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 02:54:16 -04:00
5f390e6e6a Constraints: the settings checkboxes show or hide all, dropping per-object exceptions (part of #1134) (#1144)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 02:54:03 -04:00
00f521858a Dynamic blocks: a grip drag previews the reference in its own colours (#1124)
* Dynamic blocks: a grip drag previews the reference in its own colours

The preview of a dynamic grip drag is the reference re-evaluated at the
cursor. It was drawn in the preview colour, fills left out, so on a filled
label the text was white on white. Commands can now give preview objects
with their own properties (`Interactive::preview_objects`, drawn under the
rubber band); the grip drag gives the block's objects with the properties
they draw with in the reference (layer 0 on its layer, ByBlock its
colour, linetype, lineweight and transparency) and its attributes with their
definitions', fills included in the block's order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Preview: the rubber band is grip blue over objects previewed in their own colours

Over a block previewed in its own colours the rubber band and tick marks,
in the current layer's colour, could vanish on its fill (white on white).
They are drawn in the grip blue there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Cody Vogt <18035288+codyvogt@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-19 02:53:50 -04:00
bcd0b3745e fix(render): stabilize dashed highlights of filled objects (#1046)
* fix(render): keep filled highlight dash anchors stable

* outline test: as_chunks for clippy

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 02:53:38 -04:00
ba27f36b58 Inquiry: SHAPES reports closed-shape geometry facts (#1507)
* Inquiry: SHAPES reports closed-shape geometry facts

`shapes` is a read-only command that reports, for every closed shape in the
current space (closed polylines, circles, full ellipses, closed splines),
its perimeter, area, centroid, winding, first self-intersection and
coincident vertices, exact with arcs and bulges. It filters and pages like
`entities` and counts other matches under `skipped` with the reason.

`Polyline::centroid` and `polygon_centroid` give the area centroid.

Fixes #1436.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* shapes: cap the quadratic self-intersection test at 4000 edges

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-19 02:53:10 -04:00
9f7a2422ea feat(engine): support AUDITCTL system variable and .adt log generation (gaps F4) (#1504)
* feat(engine): support AUDITCTL system variable and .adt log generation (gaps F4)

* audit: write the .adt log only beside a saved drawing

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 02:53:04 -04:00
148091aa93 Properties: a dimension's text height, arrow size, overall scale and precision (#1259) (#1473)
* Properties: a dimension's text height, arrow size, overall scale and precision (#1259)

The Properties palette showed only a dimension's text override and style,
so the only way to change its text height was a new dimension style.
It now shows Text height (DIMTXT), Arrow size (DIMASZ), Dim scale overall
(DIMSCALE) and Precision (DIMDEC) with the values the dimension draws
with. Editing one sets a per-dimension override (DIMOVERRIDE) on the
selected dimensions, as AutoCAD's Properties palette does; the style is
left alone. properties.set takes textHeight, arrowSize, dimScale and
precision for dimensions and refuses bad values before changing anything.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* props: keep DIMOVERRIDE's doc comment on dim_override

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-19 02:52:59 -04:00
c82d6228ff i18n:Add missing translations for the print dialog and page settings. (#1289)
* i18n:Add missing translations for the print dialog and page settings.

* i18n:Add missing translations for the print dialog and page settings.

* i18n(zh): drop rows that repeat existing keys and the unused 'Page setup of'

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 02:52:53 -04:00
786c8c46e4 Wrap format_angle to zero when rounding reaches a full turn (#1256)
* Wrap format_angle to zero when rounding reaches a full turn

Found while testing crates/engine/src/units.rs:63-66,87-88,102

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* units: wrap a full turn by the formatted value, so ties round as {:.*} does

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-19 02:52:48 -04:00
NetterSilverandClaude Haiku 5.5 62329310d7 Reject non-hex characters such as '+' in Rgb::parse_hex (#1601)
Found while testing crates/color/src/lib.rs:23-29

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-19 02:33:35 -04:00
Antoine Boxho 6bd09ea468 docs: customizing and automating CADCraft (for #1561) (#1569)
A short guide for people porting add-ons: what runs today (LISP routines
and startup files, .scr scripts, .pgp alias files, the JSON control
channel, MCP, cadcraft-cli), what doesn't yet (Visual LISP ActiveX, DCL,
reactors, compiled plug-ins), and where each is documented. The README's
automation section links to it.
2026-10-19 02:33:28 -04:00
FilforandClaude Opus 5.5 0440fa77ff fix(viewcube): draw the WCS pill's arrow instead of a "⌄" glyph (fixes #1541) (#1558)
The pill under the ViewCube was the text "WCS ⌄". U+2304 is missing from some
UI fonts, so it showed as a box ("WCS □"). The pill now paints the translated
label and a down chevron drawn as lines, in the pill's ink, so it no longer
depends on the font. The catalog key becomes "WCS" in every language; Korean,
which spelled the arrow out as "WCS 선택", is now "WCS" next to the chevron.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 02:33:21 -04:00
Gert Pellin - GPE 471478868d cadcraft-cli: --help / -h / help exit 0 and print the usage to stdout (#1526)
A help request is not a failure: --version already goes to stdout with
exit 0, and now --help, -h and help do the same. Bare cadcraft-cli
keeps reporting on stderr with exit 1, like git.

Fixes #1525
2026-10-19 02:33:15 -04:00
aldozarandClaude Opus 5.5 0f833358b9 ID, DIST and LIST report Z (#1509)
ID always printed Z = 0, DIST's Delta Z and angle from the XY plane were
0, and LIST printed Z = 0 for lines, circles, arcs and polyline vertices,
though the drawing kept every Z: a topographic drawing's levels could not
be read in CADCraft.

Points reach these commands in 2D, so the Z is that of what is drawn at
the point, as AutoCAD reports a snapped point's: the object a pick finds
there (a point, a 3D line or 3D polyline interpolated, a polyline's
elevation, down Z for a mirrored one, a circle, arc, text, block or the
plane of a 3D face), else the current elevation (ELEVATION). A Z given
with a JSON point wins. DIST gives the 3D distance, Delta Z and the angle
from the XY plane; LIST the Z of lines, circles, arcs and text, a line's
3D length and deltas, and a polyline's elevation.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 02:33:10 -04:00
mattlafyandClaude Opus 5.5 64e55f903c Options: an interface scale preference (#1508)
interfaceScale (0.5-3, default 1) in the OPTIONS JSON form and the Display
tab scales every menu, palette, dialog and the command line through egui's
zoom factor, and is kept in the profile.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 02:33:04 -04:00
NetterSilverandClaude Haiku 5.5 94aad92f1d Scale SVG export precision and point size to the drawing's extents (#1495)
Found while testing crates/io/src/svg.rs:50-60 (export_list: viewBox, stroke-width, circle r and coordinates all formatted with {:.6})

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-19 02:32:58 -04:00
NetterSilverandClaude Haiku 5.5 1738d3805e Offset elliptical arcs towards the picked side (#1475)
Found while testing crates/engine/src/cmd/modify.rs:584-597 (offset_kind, EntityKind::Ellipse branch)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-19 02:32:52 -04:00
Rusu Alex bf6d9fd46a Make Properties layer menu scrollable (#1467) 2026-10-19 02:32:47 -04:00
Rusu Alex f17be3d7ad Fix Properties palette color swatch (#1466) 2026-10-19 02:32:39 -04:00
NetterSilverandClaude Haiku 5.5 582d9535b6 Strip the UTF-8 BOM when reading a data link CSV file (#1457)
Found while testing crates/engine/src/cmd/table/breaks.rs:167 (parse_csv), fed by read_link at :236 (String::from_utf8_lossy, no BOM removal)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-19 02:32:33 -04:00
Marco Arnulfo 2a3b0c2626 A wide polyline thinner than a pixel keeps every side on screen (fixes #1434) (#1453) 2026-10-19 02:32:27 -04:00
NetterSilverandClaude Haiku 5.5 abce3a718b Reject lone '-' or '"' in parse_distance instead of reading 0 (#1444)
Found while testing crates/engine/src/units.rs:107-132

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-19 02:32:21 -04:00
Kostykandkostyk348 acb28313c6 Layer Off: stay active until Enter or Esc (#1418) (#1426)
LAYOFF ended after a single pick, so turning off five layers took five command
invocations. AutoCAD's Layer Off keeps prompting: you pick object after object
and the command ends on Enter or Esc only.

`SelectRun` grows a `repeat` mode next to the existing single-pick and plain
select modes: `SelectRun::repeat(..)` acts on every pick, seeds the next prompt
straight away, and finishes on Enter (or Esc through the usual cancel path).
The non-repeating paths are untouched — a repeating run must not let one pick
end the command, so `sel.single` is disabled for it and Enter is handled before
the selection machine's own keywords.

The whole run stays a single undo step, because the command body is still
called once per pick but the running command records one step, as before.

Tests: `crates/engine/tests/layoff_repeat.rs`
  cargo test -p cadcraft-engine --test layoff_repeat   -> 4 passed
  - keeps asking until Enter, two layers off, one undo takes both back
  - Esc ends it and keeps what was already turned off
  - an empty pick ("0 found") keeps it running
  - pickfirst and JSON calls are unchanged

Co-authored-by: kostyk348 <kostyk348@users.noreply.github.com>
2026-10-19 02:32:14 -04:00
william-wwh 2be9d00796 fix(arc): hold Ctrl to switch the arc direction (#1422)
ARC advertised "hold Ctrl to switch direction" but the engine never saw modifier
keys: the UI dropped `mods` when building `Input::Point`. PR #407 recorded this as
a known limit.

The UI now writes `Session::ctrl_down` every frame (next to the cursor update, not
only while a command prompts, so previews see it too) and the two ARC paths read it:

- Continue: negating the start tangent makes `arc_sed` pick the mirror arc through
  the same two endpoints.
- Center,Start,End: swapping the start/end angles walks the other way around the
  same circle; its end-point prompt now carries the same Ctrl hint.

Tests: crates/engine/tests/arc_ctrl_direction.rs.
2026-10-19 02:32:08 -04:00
NetterSilverandClaude Haiku 5.5 286d3de0aa Drop minus sign from dimension text that rounds to zero (#1403)
Found while testing crates/render/src/units.rs:139-160 (format_linear, unit 3 and 4 arms) and feet_inches at :101

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-19 02:32:01 -04:00
NetterSilverandClaude Haiku 5.5 cd3e8478d6 Fix AutoLISP real printing of subnormals and mantissa round-up (#1334)
Found while testing crates/engine/src/lisp/value.rs:401-410 (real_text)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-19 02:31:56 -04:00
NetterSilverandClaude Haiku 5.5 12d0235b2c Fix table formula range shifting for one-cell and reversed ranges (#1328)
Found while testing crates/engine/src/cmd/table/formula.rs:660

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-19 02:31:50 -04:00
NetterSilverandClaude Haiku 5.5 1ac2ebaad2 Keep line and arc elevation when lengthening with LENGTHEN (#1313)
Found while testing crates/engine/src/cmd/modify2.rs:764

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-19 02:31:44 -04:00
wstockandClaude Fable 5.1 a3b3e1281f render: the even-odd fill cuts a slab at the crossings inside it (#927) (#1308)
Slabs ran between vertex rows and paired the active edges by their
order at the slab's middle, so two edges crossing inside a slab (a
bowtie, two loops crossing) were paired across the crossing and the
trapezoids covered the wrong side of it. A slab with such a crossing is
now cut again at that y and each part paired on its own, under a cut
budget so hostile input stays bounded. Tests check coverage against an
even-odd parity oracle for a bowtie, an off-centre bowtie, two crossing
loops and the plain cases; the three crossing cases fail on main.


Claude-Session: https://claude.ai/code/session_011K1peyrh5MdZ6ttHkVq8Gh

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-19 02:31:39 -04:00
Galeno ca5a34be12 UI: let the Chinese and Arabic language gates accept an embedded craft-fonts face (#1294)
can_show only asked the installed-font lookups for those two languages, and
both lookups hard-code None on wasm32, where there are no system fonts. The
Korean gate already falls back to a craft-fonts face (korean_font), but the
web build embeds a Jpan CJK face and an Arabic face precisely as the web's
only fonts, so a Chinese or Arabic interface could never be selected on it,
however complete the catalog; the embedded Arabic face was also never added
to the UI font families (the embedded filter only kept Jpan/Han faces).

Give both gates the embedded-face path the Korean one has:
- can_show("zh"/"ar") answers true when this build embeds a Regular face of
  Hans/Hant or Arab, in addition to an installed font.
- install_fonts picks embedded faces via embedded_ui_faces: every CJK face
  still loads in every language for CJK drawing names (#513), the face of a
  zh interface leads (a Japanese face lacks the simplified-only 图 园 测),
  and an Arabic interface appends its Arabic face.

Nothing changes for a build without CRAFT_FONTS_DIR (CRAFT_FONTS empty):
Chinese still rides on an installed CJK font and Arabic on an installed one.
2026-10-19 02:31:34 -04:00
c400d6d48c Properties: a rotation shows and is typed as the drawing measures directions (#1275)
AutoCAD's Properties shows a block reference's or text's rotation as it shows
any direction: from ANGBASE, clockwise when ANGDIR is 1 (a block turned 2.23°
shows as 87.77 in a drawing measured clockwise from north). CADCraft showed
the raw angle from +X. Properties and Quick Properties now show it that way
and read a typed rotation back the same way (`AngleSettings::shown`,
`unshown`); properties.set still takes the angle from +X counterclockwise.

Co-authored-by: Cody Vogt <18035288+codyvogt@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 02:31:29 -04:00
NetterSilverandClaude Haiku 5.5 e635248b1e Carry engineering distance rounding into the next foot (#1255)
Found while testing crates/engine/src/units.rs:14-20

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-19 02:31:24 -04:00
Marco Arnulfo 739e083f4b Drafting Settings: polar increment angle no longer shows 15 decimals (fixes #1237) (#1239) 2026-10-19 02:31:18 -04:00
3fa7275e4f DXF: $ANGBASE is in degrees (#1234)
AutoCAD writes $ANGBASE in degrees (a drawing whose angles run clockwise from
north has 90.0); CADCraft read and wrote it as radians, so such a drawing's
angle base came in as 90 radians (about 116.6 degrees), turning typed angles,
polar tracking and angles shown in Properties. The reader now converts to
radians (the variable's unit), the writer back, and the DWG reader's hand-off
of the value in DXF form gives degrees.

Co-authored-by: Cody Vogt <18035288+codyvogt@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 02:31:12 -04:00
Marco Arnulfo aba903dbb3 CLI: convert to an image warns about the entities it can't draw (#1212)
* CLI: convert to an image warns about the entities it can't draw (fixes #1208)

* CLI: the undrawn-entities warning lists its counts like the skipped-records one
2026-10-19 02:31:06 -04:00
Marco Arnulfo 493addc2dd DXF files that declare no units stay unitless when saved again (fixes #1209) (#1210) 2026-10-19 02:31:01 -04:00
Marco Arnulfo a2bee8b49e LTSCALE typed at the command line asks for the scale factor (fixes #1194) (#1199) 2026-10-19 02:30:56 -04:00
Marco Arnulfo c68420221c New metric drawings get metric Standard multileader and table styles (fixes #1171) (#1195) 2026-10-19 02:30:51 -04:00
Marco Arnulfo a5fa5ca064 Select objects accepts keywords with a leading underscore (_L, _ALL, _W…) (fixes #1172) (#1193) 2026-10-19 02:30:46 -04:00
Marco Arnulfo ef599e58e0 BREAK opens a closed polyline instead of refusing it (fixes #1173) (#1191) 2026-10-19 02:30:41 -04:00
Marco Arnulfo 27fd074f54 AutoLISP entity lists hold angles in radians, as entget does in AutoCAD (fixes #1165) (#1190) 2026-10-19 02:30:36 -04:00
Marco Arnulfo 2db6ab069a DIMRADIUS/DIMDIAMETER: a location outside the circle puts the text at that point (fixes #1170) (#1189) 2026-10-19 02:30:31 -04:00
Marco Arnulfo d33a6e634a AREA, Object: an open polyline's perimeter is its length, without the closing side (fixes #1168) (#1188) 2026-10-19 02:30:26 -04:00
Antoine Boxho 0bfe609567 Add Antoine Boxho to contributors list with GitHub sync (#1186) 2026-10-19 02:30:21 -04:00
Antoine BoxhoandClaude Fable 5.1 7e830044ab BLOCK: a keep value other than convert, retain or delete is an error (fixes #935) (#1160)
make_block erased the selected objects for every `keep` value except
"retain" and only converted them for "convert", so a misspelt value such
as "retian" removed the objects from the drawing without a reference to
the new block.

`keep` is now parsed into an enum before anything is touched; a value
that is not documented is an error naming the three accepted values. The
documented values are accepted in any case. BLOCK and -BLOCK share the
check.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-19 02:30:15 -04:00
Marco Arnulfo 6546f0eecd Parameters Manager: double-click a name to rename the parameter and every expression that uses it (part of #1134) (#1142) 2026-10-19 02:30:10 -04:00
Marco Arnulfo 15b5395c06 Angular constraint between two lines holds the angle it draws, whichever way the lines run (part of #1134) (#1140) 2026-10-19 02:30:05 -04:00
Marco Arnulfo cdc61f32b5 A fixed point stays put when its grip is dragged: Fix locks the position it had before the edit (part of #1134) (#1139) 2026-10-19 02:29:59 -04:00
Marco Arnulfo d4bacce885 Tool Sets: the Parametric category offers Fix, Symmetric, the dimensional constraints and Delete Constraints (part of #1134) (#1138) 2026-10-19 02:29:54 -04:00
Marco Arnulfo 9aea9b16c4 Dynamic Input: the CIRCLE radius prompt shows one distance field, not X/Y (fixes #1135) (#1137) 2026-10-19 02:29:48 -04:00
Marco Arnulfo 9e3f584a68 Status bar toggles don't cancel the running command, as F7-F12 don't (fixes #1108) (#1111) 2026-10-19 02:29:43 -04:00
Marco Arnulfo 3bd7510f2b ARC continuation starts from the last picked end of a clockwise arc (fixes #1107) (#1110) 2026-10-19 02:29:37 -04:00
Marco Arnulfo b464a947ec MEASUREGEOM typed at the command line asks for the option, then the points (fixes #1103) (#1104) 2026-10-19 02:29:31 -04:00
Marco Arnulfo f76ca751cc Collapsed Tool Sets leave a strip on the left edge that expands them again (fixes #1095) (#1101) 2026-10-19 02:29:26 -04:00
Marco Arnulfo e0ad0ef33e DWG dimensions: text at its default position unless it was moved, and bit 32 kept (fixes #1094) (#1096) 2026-10-19 02:29:19 -04:00
56d73ceb21 Dynamic blocks: show a distance's grip only at an end an action follows, and the grips of parameters a visibility state lists (#1090)
* Dynamic blocks: a distance's grip shows only at an end an action follows

A linear parameter's grip at an end no action is attached to moves nothing,
and AutoCAD doesn't show it on the reference: a stretch that follows only the
end's change (EndXDelta, EndYDelta) leaves the base without a grip, and the
other way round. A connection to the distance itself follows both ends, as
does a distance measured from its midpoint; a parameter no action is attached
to keeps the grips the block gives it. (A block saved with one grip has no
base grip at all, which was already honoured.)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Dynamic blocks: a visibility state that lists a parameter shows its grips

AutoCAD shows a parameter's grips in a visibility state that lists the
parameter, though the state doesn't list the grips themselves (a label block's
Label state lists Position1 and Distance2 but not their grips, and AutoCAD
shows the square and the arrow). A grip now shows when the state lists it or
its parameter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Cody Vogt <18035288+codyvogt@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 02:29:13 -04:00
aldozarandClaude Opus 5.5 691bdbe0b8 Layouts in millimetres plot in millimetres whatever MEASUREMENT says (#1058) (#1085)
Paper-space units followed the drawing's MEASUREMENT only, so a layout
whose page setup is in millimetres (plot paper units 1), common in
drawings from AutoCAD with MEASUREMENT 0, was taken as inches: its PDF
drew everything 25.4 times too large, off the page.

PageSetup gains `paper_mm`, read from LAYOUT group 72 = 1; 72 = 0 still
leaves the units to MEASUREMENT, since earlier CADCraft versions wrote 0
for every layout. The layout's sheet, its PDF/raster plots, PREVIEW and
the pens of what its viewports show use the layout's units, and the
writer stores 72 = 1 for layouts in millimetres (it wrote 0 always).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 02:29:03 -04:00
aldozarandClaude Opus 5.5 162d5c6820 DWG: entities of a second layout stay on it in AutoCAD (#1057) (#1080)
acadrust writes every paper-space entity with entity mode 1 ("paper
space"), including those of *Paper_Space0, *Paper_Space1… and relies on
each block's entity list; AutoCAD reads mode 1 as the *Paper_Space
block's, so what was drawn on a second layout (and its viewports, in
drawings from AutoCAD with several layouts) landed on the first one, and
the second stayed empty. The DWG bridge now writes those entities owned
by their block (entity mode 0 with the owner handle), as the public DWG
specification describes; the first layout's entities stay mode 1.

The io tests read CADCraft's DWG back with acadrust (a dev-dependency,
the same version as cadcraft-dwg's) to check the modes as written.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 02:28:58 -04:00
aldozarandClaude Opus 5.5 632c2d4f1f DIMASSOC: reference polyline vertices by segment, as AutoCAD does (#1049) (#1064)
* DIMASSOC: no standard reference to a polyline's vertex or segment (#1049)

A dimension on a polyline vertex was written with an AcDbOsnapPointRef
naming the polyline but no subentity (groups 73 and 91 both 0); AutoCAD
then resolved it to the polyline's first segment when it regenerated
the dimension, and the value changed without warning (210 became 5).
Such points (LWPOLYLINE, 3D polyline, MLINE, also as the second object
of an intersection) are now left out of the DIMASSOC object, as the
report suggests: AutoCAD sees them as non-associative and the values
stay right. CADCraft keeps every link through its own xdata, and
references to single-curve objects (lines, arcs, circles, points) are
written as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* DIMASSOC: reference polyline vertices by segment, as AutoCAD does

Following the black-box observation of AutoCAD 2026 in the PR review: a
polyline vertex is referenced as an end of one of its segments,
subentity type 73 = 2 (edge) and the 1-based segment in 91 (segment k
runs from vertex k-1 to vertex k; a closed polyline's closing segment is
the last), with osnap 1 / parameter 1.0 for a segment's end and 13 / 0.0
for its start. Vertex 0 is written as the start of segment 1, vertex k
as the end of segment k, falling back to the other neighbour when that
one is an arc; a vertex between two arcs (where AutoCAD writes an angle
parameter, not yet tested) still gets no reference, nor do 3D polylines
and multilines.

Reading uses the segment and osnap for polylines: AutoCAD leaves the
reference point at 0,0, so the nearest-vertex fallback attached
AutoCAD's associative dimensions on polylines to the vertex nearest the
origin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 02:28:52 -04:00
aldozarandClaude Opus 5.5 f0e4fef003 AutoCAD interop: justified text, DIMPOST, current dimension style, *D block colours (#1062)
* AutoCAD interop: justified text, DIMPOST, current dimension style, *D block colours

What AutoCAD takes from CADCraft's DWG/DXF as it is:

- Justified TEXT/ATTRIB/ATTDEF (#1050): group 10 is now where the
  baseline starts, from the alignment point and the text's width, not
  the alignment point itself; AutoCAD drew centred and right-aligned
  text from DWG starting at its alignment point. A stored point close to
  CADCraft's own (AutoCAD's, exact for its fonts) is kept.
- DIMPOST (#1053) is written even when empty: the DWG bridge defaulted a
  missing one to "<>", and AutoCAD then dropped the "R" of radius
  dimensions when it regenerated them.
- The current dimension style (#1051): the DXF header now carries its
  DIM* variables (AutoCAD draws new and updated dimensions from them);
  the DWG bridge points $DIMSTYLE at the style by name (its handle was
  renumbered, so the writer fell back to Standard), and reading a DWG
  gives the current dimension and text styles their names back.
- *D blocks (#1052): lines, arrows, extension lines and text take the
  style's DIMCLRD/DIMCLRE/DIMCLRT instead of ByBlock.

#1054 (group 42 of a rotated linear dimension) was already fixed on
main by #855; acad_interop.rs keeps it covered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Test: angular dimensions store their angle in radians (group 42)

Three-point (60°, 174°) and two-line (120°) angular dimensions, read back
from the DWG as DXF. Already right on main since #855; this guards it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Justified text made here: Left Middle and Left Bottom write their baseline start too

text_insert kept a stored insertion point that was within 0.1 x an
estimated width + 0.5 x height of CADCraft's baseline start, taking it for
AutoCAD's own. The width was estimated from the alignment point's offset,
which for Left Middle is only h/2 (vertical), so a text made here, whose
stored point is the alignment point, passed and kept it: AutoCAD drew it
half a height high (Left Bottom: a third). Reported with a test by
@lee-cyrille-maskell.

A stored point equal to the alignment point is now always stale. Otherwise
the offset is split along and across the baseline: along it the same slack
as before (a tenth of the text's width plus half its height, as fonts and
their substitutes differ most in width), across it a fifth of the height.
Multiline attribute values keep the distance-only check, since their lines
are placed by the embedded MTEXT.

On 12 drawings made in AutoCAD (1,055 justified texts) the insertion points
written are the same as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 02:28:47 -04:00
leonardopbatista 7164b6b5b1 feat(options): save a two-click selection window preference (#1032) 2026-10-19 02:28:41 -04:00
Bihacsy László 38fdfa4ac4 i18n: add Hungarian (hu) interface translation (#1027)
A complete 1,326-entry Hungarian catalog: every key of the Ukrainian reference catalog and the
four menu headings (Méretezés, Rajzolás, Módosítás, Nézet). Written from the meaning of
CADCraft's English strings in general technical drawing vocabulary; no other product's
localization and no other catalog was used. The interface addresses the user informally;
command-line keywords, system variables and osnap tokens stay English.

- Preference::Hungarian (hu); hu-HU and hu_HU.UTF-8 select it. Counts take one form, as a
  Hungarian numeral is followed by the singular (plural_ko, plural_forms: 1).
- hu in REPORTED; tests for locales, saved preference and plurals, menu headings, a live
  switch, dialogs and the Hungarian letters (ő, ű) in the bundled fonts. No gaps are reported
  for Hungarian.
- ATTRIBUTION, README, docs/localization.md, docs/localization-parity.md,
  docs/control-protocol.md and the options doc comment.
2026-10-19 02:28:35 -04:00
Gert Pellin - GPE baa9c00fb7 SVG import: name what was left out instead of dropping it silently (#627) (#1016)
Text, raster images, <use> copies, <style> sheets and rounded rectangle
corners aren't read yet. They were dropped without a word; the reader now
counts them and adds one read note (the channel DWG reading already uses),
which OPEN echoes and returns as a warning. Content that isn't drawn (in
<defs> or hidden) isn't counted.
2026-10-19 02:28:29 -04:00
Dylan M. Taylor 0cc66ad306 ci: configure Dependabot for Cargo and GitHub Actions
Co-authored-by: Dylan M. Taylor <dylan@dylanmtaylor.com>
2026-10-18 12:30:54 -06:00
Dylan M. Taylor 3370a91fe9 docs: drop references to competing commercial software (#1545) 2026-10-18 10:39:39 -04:00
Brandon ThomasandClaude Opus 5.5 b23d362f6a Release 0.9.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:43:25 -04:00
Brandon ThomasandClaude Opus 5.5 714111d528 Release 0.8.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 17:20:02 -04:00
skylar taylor-barrick ff6cc8b3fc Merge pull request #1518 from skylartaylor/control-input
Bound control-port input, and follow stored paths only on this computer or the drawing's share
2026-10-17 12:14:01 -06:00
skylar taylor-barrick 98d22f0c68 Build: take craft-docpath from the merged craft-libs rev
craft-libs#169 is merged, so the fork pin is no longer needed. Pinned to
storytold/craft-libs 82a24b006eefa036b43c09a67fb9a3fbd17ffe63 (main after the merge) rather than the older 12281d07,
which predates resolve_absolute and the Windows colon refusal.
2026-10-17 12:04:10 -06:00
NetterSilverandClaude Haiku 5.5 4c75b0c458 Treat polyline bulges with a sub-EPS sweep as straight segments
Found while testing crates/geom/src/curve.rs:435 (bulge_to_arc) together with crates/geom/src/lib.rs:45-48 (ccw_sweep)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 04:43:01 +00:00
aiongg ba02572b31 Build: use craft-docpath from craft-libs 2026-10-17 02:20:57 +00:00
aiongg 8fe7c421ea Xrefs and data links: follow stored paths only on this computer or the drawing's share
Xref paths saved in a drawing (on open, on Reload, in the External
References list, XOPEN, nested xrefs) and table data-link paths re-read by
DATALINKUPDATE go through doc_path::resolve (craft-docpath): relative
paths resolve beside the drawing that stores them, and paths to other
computers, devices and URLs are not opened. Such an xref is Unresolved
with the reason, unless a file of its name is beside the host drawing.
Paths given to XATTACH, -XREF Path and DATALINK are used as given.

One load of xrefs reads at most 1 GB from referenced files in total.
2026-10-16 17:18:34 +00:00
aiongg 972ae7888c Close control connections on the first line that is not a request; bound idle time, writes and queued requests
A line that is not a JSON object with a string method, including an HTTP
request line, gets one error reply and the connection closes before
anything after it is read. Connections time out after 10 minutes idle or
30 seconds blocked on a write, and at most 64 requests wait for the app;
more are answered busy.
2026-10-16 16:07:25 +00:00
Dylan M. Taylor ea6d7beca2 ci: switch to shared nix workflow from craft-common-actions 2026-10-15 23:58:01 -04:00
skylar taylor-barrick 9b2308b138 Merge pull request #1382 from dylanmtaylor/ci/add-platform-checks
ci: add shared FreeBSD, Windows ARM64, and nix checks
2026-10-15 21:26:48 -06:00
Dylan M. Taylor 69fc442ea6 ci: add shared FreeBSD, Windows ARM64, and nix checks 2026-10-15 20:47:50 -04:00
Marco Arnulfo be3ce73895 fix(engine): preserve rectangle area with ortho enabled (#1117) 2026-10-15 18:34:36 -06:00
midasmatterhorn 8a2b8f4bdf io: the four symbol tables honour the same record cap as their peers
LAYER, LTYPE, STYLE and DIMSTYLE records were appended with no cap while
the VIEW, UCS and VPORT arms of the same loop guard at MAX_OBJECTS — so
a hostile DXF could grow the layer table (each record found by a linear
case-insensitive name scan) without bound. The same guard now applies to
all seven table kinds.

Behaviour is unchanged for every legal drawing: no drawing approaches a
million table records, so no regression test can reach the cap at unit
scale (the same reason the peer guards shipped without one). The
quadratic cost of the name lookups themselves is a separate, larger
change (resolving table names through a map while reading) deliberately
left to the maintainers.
2026-10-15 17:43:04 +03:00
488 changed files with 17777 additions and 1161 deletions
+6
View File
@@ -0,0 +1,6 @@
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
reviews:
auto_review:
# Dependabot bumps are checked by CI; skip CodeRabbit reviews on them.
ignore_usernames:
- "dependabot[bot]"
+41
View File
@@ -0,0 +1,41 @@
version: 2
updates:
# Maintain Rust / Cargo dependencies
- package-ecosystem: "cargo"
directory: "/"
schedule:
interval: "daily"
time: "06:00"
timezone: "America/New_York"
# Consolidate updates into a single PR rather than one PR per crate
groups:
cargo-dependencies:
patterns:
- "*"
commit-message:
prefix: "chore(deps)"
# craft-ui is still on egui 0.36, and two eguis in the graph don't build (#1990). Remove this
# once craft-ui moves to egui 0.37 (storytold/craft-libs#199).
ignore:
- dependency-name: "egui"
versions: [">=0.37"]
- dependency-name: "eframe"
versions: [">=0.37"]
- dependency-name: "egui-wgpu"
versions: [">=0.37"]
- dependency-name: "egui_kittest"
versions: [">=0.37"]
# Maintain GitHub Actions workflows
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "daily"
time: "06:00"
timezone: "America/New_York"
groups:
actions:
patterns:
- "*"
commit-message:
prefix: "ci(deps)"
+30
View File
@@ -0,0 +1,30 @@
name: CI
# Quality gates for every push to main and every pull request (`cargo xtask ci`), through the
# suite's shared gate workflow in craft-common-actions.
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
permissions:
contents: read
# A newer push to a PR branch cancels its older run; every push to main runs to the end.
concurrency:
group: ci-${{ github.event_name == 'pull_request' && github.ref || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
gate:
# Tracks @main so suite-wide CI fixes reach every app without a PR in each repository.
uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
with:
# Same craft-fonts commit as release.yml.
craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
timeout-minutes: 90
packages: >-
pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcb1-dev libxcursor-dev
libxrandr-dev libxi-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers
libgl1-mesa-dev
+32
View File
@@ -0,0 +1,32 @@
# Builds and tests the workspace on FreeBSD, inside a FreeBSD VM on a Linux runner
# (GitHub has no FreeBSD runners). GPU tests skip themselves when the VM has no adapter.
name: FreeBSD
# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every pull
# request: a drifted dependency still shows up within a day, and always before a release ships.
# PRs that change the FreeBSD workflow or packaging itself still run it.
on:
push:
branches: [release]
pull_request:
paths:
- .github/workflows/freebsd.yml
- packaging/freebsd/**
- packaging/env.sh
schedule:
- cron: "27 3 * * *"
workflow_dispatch:
concurrency:
group: freebsd-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
freebsd:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
with:
craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
+23
View File
@@ -0,0 +1,23 @@
# Evaluates the Nix flake (no build, about a minute): `nix build` / `nix develop` stop at evaluation
# when Cargo.lock gains something nix/package.nix can't vendor, and nothing else would notice.
name: Nix
on:
push:
branches: [main]
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
pull_request:
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: nix-${{ github.ref }}
cancel-in-progress: true
jobs:
nix:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/nix.yml@main
+20
View File
@@ -0,0 +1,20 @@
name: SonarCloud
on:
push:
branches: [main]
pull_request:
types: [opened, synchronize, reopened]
permissions:
contents: read
concurrency:
group: sonarcloud-${{ github.ref }}
cancel-in-progress: true
jobs:
sonarcloud:
# Tracked at @main so org-wide CI improvements propagate without PR churn.
uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
secrets: inherit
+32
View File
@@ -0,0 +1,32 @@
# Windows on ARM: package the ARM64 release the way release.yml does (cross-compiled on an x64
# runner, unsigned here), then install that MSI on a Windows 11 ARM64 runner, check the installed
# binaries are ARM64, run the CLI natively, and uninstall.
name: Windows ARM64
# Nightly on main and on pushes to `release` (dependency and runner-image drift shows up within a
# day, and always before a release ships), and on PRs that change the Windows packaging itself.
on:
schedule:
- cron: "3 5 * * *"
push:
branches: [release]
pull_request:
paths:
- .github/workflows/windows-arm64.yml
- packaging/windows/**
workflow_dispatch:
concurrency:
group: windows-arm64-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
windows-arm64:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main
with:
app-name: cadcraft
app-dir: CADCraft
+1
View File
@@ -30,6 +30,7 @@ People trust CADCraft with their drawings; a crash loses their work. **This outr
- **Programmatic calls never open dialogs.** `engine.execute` runs the JSON form with defaults; only menu/toolbar invocation starts the interactive prompt sequence.
- **Layering** is enforced by `cargo xtask layers`: L0 `geom`, `dxf` → L1 `color`, `doc` → L2 `fonts`, `render`, `constraints` → L3 `io` → L4 `engine` → L5 `ui-egui`, `mcp` → apps. Nothing below L5 depends on egui/eframe/winit/wgpu/rfd. **The UI crate is swappable.**
- **The UI is thin**: panels read engine state and act through `app.run(id, params)` / `app.start(id)` / `app.cmdline(text)`. Colours come from `theme::Tokens`.
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, assets, layers, wasm). Commit after every feature arc that builds, and push to `main`.
+3
View File
@@ -68,3 +68,6 @@ Generated-in-code assets are original and have no file to list:
| `crates/ui-egui/src/i18n/it.tsv` | Emanuele Bottelli (AI-assisted) | Original Italian translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
| `crates/ui-egui/src/i18n/ko.tsv` | @huk082 (AI-assisted) | Original Korean translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied; no font assets added |
| `crates/ui-egui/src/i18n/ar.tsv` | @hasan-sdaif | Original Arabic translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
| `crates/ui-egui/src/i18n/hu.tsv` | @Skaywalker | Original Hungarian translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
| `crates/ui-egui/src/i18n/tr.tsv` | @ogzhnklvz (AI-assisted) | Original Turkish translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied; written from the English strings, no other catalog used as a source; the running app reviewed by a native speaker |
| `crates/ui-egui/src/i18n/el.tsv` | @Camthalion77 with ChatGPT (AI-assisted) | Original Greek translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied; independent native proofreading pending |
Generated
+106 -149
View File
@@ -4,9 +4,9 @@ version = 4
[[package]]
name = "acadrust"
version = "0.6.4"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94862189cab6e282ed0433a801a216cd1a712953813284a48fa81c67223e38f6"
checksum = "5eaa72520f70eb6f7f09b06f44e1ed0d8fad5fa743364f2c3715c30df962ec7c"
dependencies = [
"anyhow",
"bitflags 2.13.2",
@@ -209,9 +209,9 @@ checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "arboard"
version = "3.6.1"
version = "3.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf"
checksum = "aeb9553e62312c8756a5979254fc6d7da8bbeb5986a1e17037fab932f7037917"
dependencies = [
"clipboard-win",
"image",
@@ -223,7 +223,7 @@ dependencies = [
"objc2-foundation 0.3.2",
"parking_lot",
"percent-encoding",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
"x11rb",
]
@@ -547,11 +547,13 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "cadcraft"
version = "0.7.0"
version = "0.12.0"
dependencies = [
"cadcraft-engine",
"cadcraft-io",
"cadcraft-mcp",
"cadcraft-ui-egui",
"chrono",
"eframe",
"egui",
"hidapi",
@@ -569,7 +571,7 @@ dependencies = [
[[package]]
name = "cadcraft-cli"
version = "0.7.0"
version = "0.12.0"
dependencies = [
"cadcraft-engine",
"cadcraft-io",
@@ -580,14 +582,14 @@ dependencies = [
[[package]]
name = "cadcraft-color"
version = "0.7.0"
version = "0.12.0"
dependencies = [
"serde",
]
[[package]]
name = "cadcraft-constraints"
version = "0.7.0"
version = "0.12.0"
dependencies = [
"cadcraft-doc",
"cadcraft-geom",
@@ -597,7 +599,7 @@ dependencies = [
[[package]]
name = "cadcraft-doc"
version = "0.7.0"
version = "0.12.0"
dependencies = [
"cadcraft-color",
"cadcraft-geom",
@@ -608,14 +610,14 @@ dependencies = [
[[package]]
name = "cadcraft-dwg"
version = "0.7.0"
version = "0.12.0"
dependencies = [
"acadrust",
]
[[package]]
name = "cadcraft-dxf"
version = "0.7.0"
version = "0.12.0"
dependencies = [
"encoding_rs",
"log",
@@ -624,7 +626,7 @@ dependencies = [
[[package]]
name = "cadcraft-engine"
version = "0.7.0"
version = "0.12.0"
dependencies = [
"cadcraft-color",
"cadcraft-constraints",
@@ -634,6 +636,8 @@ dependencies = [
"cadcraft-geom",
"cadcraft-io",
"cadcraft-render",
"craft-docpath",
"libc",
"log",
"rstar",
"serde",
@@ -643,25 +647,26 @@ dependencies = [
[[package]]
name = "cadcraft-fonts"
version = "0.7.0"
version = "0.12.0"
dependencies = [
"cadcraft-geom",
"harfrust",
"skrifa 0.47.0",
"harfrust 0.14.0",
"skrifa 0.48.0",
"unicode-bidi",
]
[[package]]
name = "cadcraft-geom"
version = "0.7.0"
version = "0.12.0"
dependencies = [
"serde",
]
[[package]]
name = "cadcraft-io"
version = "0.7.0"
version = "0.12.0"
dependencies = [
"acadrust",
"cadcraft-color",
"cadcraft-doc",
"cadcraft-dwg",
@@ -669,8 +674,8 @@ dependencies = [
"cadcraft-fonts",
"cadcraft-geom",
"cadcraft-render",
"miniz_oxide 0.8.9",
"quick-xml",
"miniz_oxide 0.9.1",
"quick-xml 0.42.0",
"serde",
"serde_json",
"thiserror 2.0.21",
@@ -679,18 +684,19 @@ dependencies = [
[[package]]
name = "cadcraft-mcp"
version = "0.7.0"
version = "0.12.0"
dependencies = [
"cadcraft-engine",
"cadcraft-io",
"cadcraft-render",
"getrandom 0.4.3",
"log",
"serde_json",
]
[[package]]
name = "cadcraft-render"
version = "0.7.0"
version = "0.12.0"
dependencies = [
"cadcraft-color",
"cadcraft-doc",
@@ -704,7 +710,7 @@ dependencies = [
[[package]]
name = "cadcraft-ui-egui"
version = "0.7.0"
version = "0.12.0"
dependencies = [
"cadcraft-color",
"cadcraft-doc",
@@ -712,6 +718,7 @@ dependencies = [
"cadcraft-fonts",
"cadcraft-geom",
"cadcraft-render",
"chrono",
"craft-ui",
"egui",
"egui-wgpu",
@@ -726,7 +733,7 @@ dependencies = [
[[package]]
name = "cadcraft-web"
version = "0.7.0"
version = "0.12.0"
dependencies = [
"cadcraft-engine",
"cadcraft-io",
@@ -823,6 +830,15 @@ dependencies = [
"libc",
]
[[package]]
name = "chrono"
version = "0.4.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
dependencies = [
"num-traits",
]
[[package]]
name = "clipboard-win"
version = "5.4.1"
@@ -927,6 +943,11 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48"
[[package]]
name = "craft-docpath"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63"
[[package]]
name = "craft-ui"
version = "0.1.0"
@@ -1291,7 +1312,7 @@ dependencies = [
"emath",
"epaint_default_fonts",
"font-types",
"harfrust",
"harfrust 0.12.0",
"log",
"nohash-hasher",
"parking_lot",
@@ -1420,9 +1441,9 @@ checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "font-types"
version = "0.12.6"
version = "0.12.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c92d9081c7ab3bd83ef1eecdfad090f3d966e3ce74106779664114cfe1a6274"
checksum = "835ae9d781464a79026cde387f9cbc650c9a4909d7cf7e658b127e3c35d2f35d"
dependencies = [
"bytemuck",
"serde",
@@ -1713,6 +1734,18 @@ dependencies = [
"smallvec",
]
[[package]]
name = "harfrust"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86aa776d6d32b072119e1f1650f4db843ff4382a4aaa6a2c9c4e35703cd377cb"
dependencies = [
"bitflags 2.13.2",
"bytemuck",
"read-fonts 0.45.1",
"smallvec",
]
[[package]]
name = "hash32"
version = "0.3.1"
@@ -1893,7 +1926,7 @@ dependencies = [
"byteorder-lite",
"moxcms",
"num-traits",
"png 0.18.1",
"png",
"tiff",
"zune-core",
"zune-jpeg",
@@ -2079,9 +2112,9 @@ checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb"
[[package]]
name = "libc"
version = "0.2.190"
version = "0.2.191"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
[[package]]
name = "libloading"
@@ -2250,9 +2283,9 @@ dependencies = [
[[package]]
name = "muda"
version = "0.21.0"
version = "0.21.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6744ab645ebe79f9c428455c7cb9dfa4275fa3b50a25408e08f927da8dc618c"
checksum = "4340e89c178916e62879c5c36b7f699019369be413e496178a6983ce91b8bc03"
dependencies = [
"crossbeam-channel",
"dpi",
@@ -2261,8 +2294,7 @@ dependencies = [
"objc2-app-kit 0.3.2",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
"once_cell",
"png 0.18.1",
"png",
"thiserror 2.0.21",
"windows-sys 0.61.2",
]
@@ -2924,19 +2956,6 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
[[package]]
name = "png"
version = "0.17.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526"
dependencies = [
"bitflags 1.3.2",
"crc32fast",
"fdeflate",
"flate2",
"miniz_oxide 0.8.9",
]
[[package]]
name = "png"
version = "0.18.1"
@@ -3060,6 +3079,15 @@ dependencies = [
"memchr",
]
[[package]]
name = "quick-xml"
version = "0.42.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b"
dependencies = [
"memchr",
]
[[package]]
name = "quote"
version = "1.0.47"
@@ -3138,13 +3166,15 @@ dependencies = [
[[package]]
name = "read-fonts"
version = "0.44.0"
version = "0.45.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d5a0cf4bb6cbce4c29756270d189a5e400ac05e91192161d83a0bcb6e68492e8"
checksum = "4edc8db0d972de88e53a085b1f01fb3fbdae7bb0cb7a0ce2b2f8285a412fe3ef"
dependencies = [
"bytemuck",
"foldhash",
"font-types",
"once_cell",
"yoke",
]
[[package]]
@@ -3223,9 +3253,9 @@ dependencies = [
[[package]]
name = "rstar"
version = "0.12.2"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "421400d13ccfd26dfa5858199c30a5d76f9c54e0dba7575273025b43c5175dbb"
checksum = "5912b862fa5ffb462607bfd1e35036c458c537921f508c8235a83d5f3987edfe"
dependencies = [
"heapless",
"num-traits",
@@ -3356,9 +3386,9 @@ dependencies = [
[[package]]
name = "serde_json"
version = "1.0.151"
version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
@@ -3443,12 +3473,12 @@ dependencies = [
[[package]]
name = "skrifa"
version = "0.47.0"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "526e654b303ff4ad73a5a40b25de2b3ce3d039c5e3b6da5f89ae6e57004cb40f"
checksum = "a5213b4825bac3587046330052d61bfac7bd3bd0d203c758d7c2ef726fcea034"
dependencies = [
"bytemuck",
"read-fonts 0.44.0",
"read-fonts 0.45.1",
]
[[package]]
@@ -3694,24 +3724,24 @@ dependencies = [
[[package]]
name = "tiny-skia"
version = "0.11.4"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab"
checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea"
dependencies = [
"arrayref",
"arrayvec",
"bytemuck",
"cfg-if",
"log",
"png 0.17.16",
"png",
"tiny-skia-path",
]
[[package]]
name = "tiny-skia-path"
version = "0.11.4"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93"
checksum = "edca365c3faccca67d06593c5980fa6c57687de727a03131735bb85f01fdeeb9"
dependencies = [
"arrayref",
"bytemuck",
@@ -4132,7 +4162,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0"
dependencies = [
"proc-macro2",
"quick-xml",
"quick-xml 0.41.0",
"quote",
]
@@ -4489,7 +4519,7 @@ version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets 0.52.6",
"windows-targets",
]
[[package]]
@@ -4498,16 +4528,7 @@ version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
dependencies = [
"windows-targets 0.53.5",
"windows-targets",
]
[[package]]
@@ -4525,31 +4546,14 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc 0.52.6",
"windows_i686_gnu 0.52.6",
"windows_i686_gnullvm 0.52.6",
"windows_i686_msvc 0.52.6",
"windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm 0.52.6",
"windows_x86_64_msvc 0.52.6",
]
[[package]]
name = "windows-targets"
version = "0.53.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
dependencies = [
"windows-link",
"windows_aarch64_gnullvm 0.53.1",
"windows_aarch64_msvc 0.53.1",
"windows_i686_gnu 0.53.1",
"windows_i686_gnullvm 0.53.1",
"windows_i686_msvc 0.53.1",
"windows_x86_64_gnu 0.53.1",
"windows_x86_64_gnullvm 0.53.1",
"windows_x86_64_msvc 0.53.1",
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
@@ -4567,96 +4571,48 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_aarch64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_i686_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "windows_x86_64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "winit"
version = "0.30.13"
@@ -4719,9 +4675,9 @@ dependencies = [
[[package]]
name = "winresource"
version = "0.1.31"
version = "0.1.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0986a8b1d586b7d3e4fe3d9ea39fb451ae22869dcea4aa109d287a374d866087"
checksum = "a2bc6d36480b829272a5bdabf08e6dca8004761c1da7e35efef800540c66221b"
dependencies = [
"toml",
"version_check",
@@ -4804,8 +4760,9 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.7.0"
version = "0.12.0"
dependencies = [
"chrono",
"serde_json",
]
+13 -7
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
# default-members
[workspace.package]
version = "0.7.0"
version = "0.12.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
@@ -24,26 +24,30 @@ cadcraft-io = { path = "crates/io" }
cadcraft-engine = { path = "crates/engine" }
cadcraft-ui-egui = { path = "crates/ui-egui" }
cadcraft-mcp = { path = "crates/mcp" }
# Which paths stored in a drawing (xrefs, data links) may be opened.
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
# foundation
thiserror = "2"
serde = { version = "1", features = ["derive", "rc"] }
serde_json = { version = "1", features = ["float_roundtrip"] }
log = "0.4"
# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md).
chrono = { version = "0.4.45", default-features = false, features = ["std"] }
rayon = "1"
# rendering / formats
tiny-skia = { version = "0.11", default-features = false, features = ["std", "simd", "png-format"] }
tiny-skia = { version = "0.12", default-features = false, features = ["std", "simd", "png-format"] }
lyon_tessellation = "1.0.22"
skrifa = "0.47"
# Text shaping (OpenType GSUB/GPOS, HarfBuzz port on read-fonts; the version egui's epaint uses)
# and the Unicode Bidirectional Algorithm for right-to-left drawing text.
harfrust = "0.12"
skrifa = "0.48"
# Text shaping (OpenType GSUB/GPOS, HarfBuzz port on read-fonts; egui's epaint is still on 0.12,
# so the graph has both) and the Unicode Bidirectional Algorithm for right-to-left drawing text.
harfrust = "0.14"
unicode-bidi = "0.3.18"
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
encoding_rs = "0.8.35"
# XML for the SVG reader (streaming parser, no entity expansion beyond the five predefined ones)
quick-xml = "0.41"
quick-xml = "0.42"
# ui
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
@@ -65,9 +69,11 @@ type_complexity = "allow"
[profile.dev.package."*"]
opt-level = 2
debug = false
[profile.dev]
opt-level = 1
debug = "line-tables-only"
[profile.release]
opt-level = 3
+21 -5
View File
@@ -7,14 +7,18 @@
</a>
</p>
<p align="center">
<img src="assets/app-icon/hicolor/256x256/apps/ai.storyteller.cadcraft.png" width="128" height="128" alt="CADCraft icon: a drafting set square and compass arc on a blue grid tile">
</p>
<h1 align="center">CADCraft</h1>
<p align="center">
<b>Computer-aided design and drafting; an open-source, clean-room reimplementation of Autodesk AutoCAD, rebuilt in pure Rust.</b>
<b>Computer-aided design and drafting; an open-source, modern 2D and 3D CAD application, built in pure Rust.</b>
</p>
<p align="center">
A fast, open-source take on the AutoCAD workflow: the command line, object snaps, layers,
A fast, open-source CAD workflow: the command line, object snaps, layers,
dimensions, hatches, blocks and DXF drawings you already know. It runs natively on macOS,
Windows, Linux and FreeBSD, and in the browser via WebAssembly.<br>
<i>By the ArtCraft team.</i>
@@ -26,6 +30,9 @@
<img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20%2F%20Apache--2.0-0b6f88?style=flat-square">
<img alt="Agent-drivable over MCP" src="https://img.shields.io/badge/agents-MCP-14a3c7?style=flat-square">
<img alt="Status: early development" src="https://img.shields.io/badge/status-early%20development-f07a3a?style=flat-square">
<br>
<a href="https://sonarcloud.io/project/overview?id=storytold_cadcraft"><img alt="Scanned by SonarCloud" src="https://img.shields.io/badge/Scanned_by-SonarCloud-4b9fd5?style=flat-square&logo=sonarqubecloud&logoColor=white"></a>
<a href="https://coderabbit.ai"><img alt="Reviewed by CodeRabbit" src="https://img.shields.io/coderabbit/prs/github/storytold/cadcraft?style=flat-square&label=Reviewed%20by%20CodeRabbit&logo=coderabbit"></a>
</p>
<p align="center">
@@ -111,7 +118,7 @@ numbers):
| Layouts & plotting | Paper-space layouts, viewports (scale, lock, per-viewport layer freeze), MSPACE/PSPACE through viewports, page setups, PLOT and EXPORTPDF |
| Parametric | Geometric and dimensional constraints, AUTOCONSTRAIN, PARAMETERS with expressions, conflict detection; constraints re-solve after every edit |
| Grips | Hot grips with Space to cycle Stretch, Move, Rotate, Scale and Mirror |
| Interface languages | English, Ukrainian (Українська), Brazilian Portuguese (Português (Brasil)), German (Deutsch), Spanish (Español), Czech (Čeština), Russian (Русский), French (Français), Simplified Chinese (简体中文, with an installed CJK font), Italian (Italiano), Korean (한국어, with an installed Hangul font), Swedish (Svenska) and Arabic (العربية, with an installed Arabic font), live switching and a remembered Auto/system-language choice; [details](docs/localization.md) |
| Interface languages | English, Ukrainian (Українська), Brazilian Portuguese (Português (Brasil)), German (Deutsch), Spanish (Español), Czech (Čeština), Russian (Русский), French (Français), Simplified Chinese (简体中文, with an installed CJK font), Italian (Italiano), Korean (한국어, with an installed Hangul font), Swedish (Svenska), Arabic (العربية, with an installed Arabic font), Hungarian (Magyar) and Turkish (Türkçe), live switching and a remembered Auto/system-language choice; [details](docs/localization.md) |
| Automation | MCP server, JSON control channel, `cadcraft-cli` (info, convert, run, commands, mcp) |
## Quick start
@@ -225,6 +232,9 @@ MCP tools include `command_line` (type at the prompt), `execute` (any command wi
`screenshot` and `ui_click`. The desktop app's JSON control channel is documented in
[docs/control-protocol.md](docs/control-protocol.md).
To run LISP routines, scripts and alias files, or to drive CADCraft from your own programs, see
[docs/customization.md](docs/customization.md).
## Architecture
```text
@@ -279,6 +289,14 @@ See [ROADMAP.md](ROADMAP.md) for the stage, measured parity and the estimate of
**Download CADCraft** from GitHub: the [latest release](https://github.com/storytold/cadcraft/releases/latest) has every build listed below, and [all releases](https://github.com/storytold/cadcraft/releases) has earlier versions and their notes. `<ver>` in the file names is the version number, and `SHA256SUMS.txt` lists a checksum for every file.
> [!WARNING]
> Download official CADCraft builds from the [storytold/cadcraft releases](https://github.com/storytold/cadcraft/releases). Verify that the repository owner is `storytold`. Before you run a download, check that its checksum matches the line for that file in the release's `SHA256SUMS.txt`:
>
> ```sh
> shasum -a 256 cadcraft-<ver>-macos-universal.dmg # macOS (on Linux: sha256sum FILE)
> Get-FileHash .\cadcraft-<ver>-windows-x64.msi # Windows PowerShell
> ```
### Windows
| Build | Installer | Portable |
@@ -394,8 +412,6 @@ ArtCraft Team and are not covered by this license. They may be used only unmodif
part of this repository and CADCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
Forks and modified versions must remove them.
<sub>Autodesk, AutoCAD and DWG are trademarks or registered trademarks of Autodesk, Inc. in the United States and/or other countries. CADCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Autodesk, Inc.; these names are used only to describe the workflows and file formats it is compatible with.</sub>
<p align="center">
<a href="https://getartcraft.com/"><img alt="ArtCraft" src="docs/brand/artcraft-mark.svg" width="28"></a><br>
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
+4 -1
View File
@@ -2,7 +2,7 @@
**Stage: pre-alpha** · next: alpha, ~2 points (38% → 40% ready for real work) and ~125–190 Opus 5.5 hours away (the four failing core workflows of the [alpha gate](docs/roadmap.md#alpha-gate))
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (hours to ~95% for each readiness number) · **Target:** Autodesk AutoCAD 2027
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (HATCHEDIT boundary add/remove progress) · **Target:** Autodesk AutoCAD 2027
CADCraft targets full parity with AutoCAD (2D drafting first, then annotation, layouts and
plotting, DWG, parametrics and 3D), plus what AutoCAD doesn't have: agent control over MCP, a
@@ -94,6 +94,7 @@ Ukrainian and German. Detail: [localization-parity.md](docs/localization-parity.
| Portuguese | none | 0% |
| Indonesian | none | 0% |
| Japanese | none | 0% |
| Greek | complete current catalog | 1,330 current source keys; independent proofreading pending |
| German | partial | ≈ 100% of the catalog (989 rows) |
| Korean | none | 0% |
| Vietnamese | none | 0% |
@@ -118,6 +119,7 @@ Ranked; detail and the full beta checklist in [docs/roadmap.md](docs/roadmap.md)
| Date | Entry |
|---|---|
| 2026-10-14 | HATCHEDIT adds boundaries from internal points or closed objects and removes selected loops in the dialog, command line and JSON command; associative links and undo are covered by tests. |
| 2026-10-12 | TRIM: a pick after a closed polyline's last cut keeps only the complementary run instead of an extra circuit of the outline (#726) |
| 2026-10-12 | HATCHEDIT refuses an explicit bad `scale` or `angle` before it edits anything, so a rejected request leaves the drawing, undo and redo as they were (#724) |
| 2026-10-10 | Merged main: Ukrainian interface (#36, first string catalog), Dynamic Input pointer boxes with Tab and relative entry (#60), press-drag selection windows (#63), WIPEOUT and hatch gradients kept through DXF/DWG save (#147, #162), SETVAR DIM* as style overrides (#129), UNITS dialog (#38), close-window save prompt (#47), DIMANGULAR on circles (#20), cargo fmt fixed on main. UI/UX 40% → 43% (dynamic input 15% → 45%), localization 3% → 5%; ready for real work ≈ 37% → 38% (weighted 37.2% → 37.7%), mainstream 27% → 28%, essentials 45% → 46%. Beta is now ~37 points away; hours unchanged within rounding. |
@@ -133,6 +135,7 @@ Ranked; detail and the full beta checklist in [docs/roadmap.md](docs/roadmap.md)
| Date | Change | Summary |
|---|---|---|
| 2026-10-14 | minor | Progress log: HATCHEDIT boundary add/remove |
| 2026-10-12 | trivial | Progress log: closed-polyline TRIM (#726) |
| 2026-10-12 | trivial | Progress log: HATCHEDIT numeric validation (#724) |
| 2026-10-10 | minor | Merged main's changes (Ukrainian catalog, dynamic input pointer boxes, DXF fixes); UI/UX and localization numbers updated |
+28
View File
@@ -275,10 +275,34 @@ fn convert(args: &[String]) -> Result<(), String> {
if let Some(m) = skipped_message(s.doc().map_err(|e| e.to_string())?, i) {
eprintln!("{m}");
}
if cadcraft_io::is_image(o)
&& let Some(m) = undrawn_message(s.doc().map_err(|e| e.to_string())?, i)
{
eprintln!("{m}");
}
let window = export_window(&s, framing, None)?;
export_framed(&s, o, window)
}
/// The warning for the model-space entities of `path` an image can't show, by type (those kept
/// as unknown objects, `Proxy` in `info`), or `None` when there are none.
fn undrawn_message(d: &cadcraft_engine::doc::Drawing, path: &str) -> Option<String> {
let mut types: std::collections::BTreeMap<&str, usize> = std::collections::BTreeMap::new();
for e in d.model.iter() {
if let cadcraft_engine::doc::EntityKind::Unknown(u) = &e.kind {
let n = types.entry(u.dxf_type.as_str()).or_insert(0);
*n = n.saturating_add(1);
}
}
let n = types.values().fold(0usize, |a, n| a.saturating_add(*n));
if n == 0 {
return None;
}
let list: Vec<String> = types.iter().map(|(t, n)| format!("{t}: {n}")).collect();
let entities = if n == 1 { "entity" } else { "entities" };
Some(format!("{path}: {n} {entities} not drawn ({})", list.join(", ")))
}
/// The warning for the records of `path` the reader could not place, or `None` when there are none.
fn skipped_message(d: &cadcraft_engine::doc::Drawing, path: &str) -> Option<String> {
let n = d.skipped_records.values().fold(0usize, |a, n| a.saturating_add(*n));
@@ -666,6 +690,10 @@ fn cli() -> ExitCode {
outln!("cadcraft-cli {}", env!("CARGO_PKG_VERSION"));
Ok(())
}
Some("--help" | "-h" | "help") => {
outln!("{USAGE}");
Ok(())
}
_ => Err(USAGE.into()),
};
match r {
+23
View File
@@ -0,0 +1,23 @@
//! `--help` is a request, not a failure (#1525): it goes to stdout with exit 0, like `--version`.
//! Bare `cadcraft-cli` keeps reporting on stderr with exit 1.
use std::process::Command;
#[test]
fn help_flags_print_usage_to_stdout_and_exit_zero() {
for args in [&["--help"][..], &["-h"], &["help"]] {
let out = Command::new(env!("CARGO_BIN_EXE_cadcraft-cli")).args(args).output().expect("run cadcraft-cli");
assert!(out.status.success(), "{args:?}: {:?}", out.status);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.starts_with("usage:"), "{args:?}: {stdout:?}");
assert!(String::from_utf8_lossy(&out.stderr).is_empty(), "{args:?}: stderr should be empty");
}
}
#[test]
fn bare_invocation_still_reports_on_stderr() {
let out = Command::new(env!("CARGO_BIN_EXE_cadcraft-cli")).output().expect("run cadcraft-cli");
assert_eq!(out.status.code(), Some(1));
assert!(out.stdout.is_empty(), "bare invocation keeps its usage on stderr");
assert!(String::from_utf8_lossy(&out.stderr).starts_with("usage:"));
}
+6
View File
@@ -0,0 +1,6 @@
mod cli;
mod drafting_spacing;
mod help;
mod mcp_connect;
mod skipped_records;
mod undrawn_entities;
@@ -0,0 +1,53 @@
//! `convert` to an image says how many model-space entities it can't draw, by type, instead of
//! leaving them out silently (#1208). The exit code stays 0.
use std::process::{Command, Output};
fn cli(args: &[&str]) -> Output {
Command::new(env!("CARGO_BIN_EXE_cadcraft-cli")).args(args).output().unwrap()
}
/// A line, two foreign REGIONs and a PDF underlay: the last three are kept as unknown entities.
const PROXIES: &str = "0\nSECTION\n2\nENTITIES\n\
0\nLINE\n8\n0\n10\n0\n20\n0\n11\n5\n21\n5\n\
0\nREGION\n8\n0\n70\n1\n1\nabc\n\
0\nREGION\n8\n0\n70\n1\n1\ndef\n\
0\nPDFUNDERLAY\n8\n0\n10\n0\n20\n0\n41\n1\n42\n1\n43\n1\n\
0\nENDSEC\n0\nEOF\n";
#[test]
fn convert_to_an_image_warns_about_entities_it_cannot_draw() {
let dir = std::env::temp_dir().join(format!("cc-cli-undrawn-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let input = dir.join("proxies.dxf");
std::fs::write(&input, PROXIES).unwrap();
let input = input.to_str().unwrap();
let out = cli(&["info", input]);
let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
assert_eq!(v["counts"]["Proxy"], 3, "{v}");
for ext in ["svg", "png", "pdf"] {
let image = dir.join(format!("out.{ext}"));
let out = cli(&["convert", input, image.to_str().unwrap()]);
assert!(out.status.success(), "{:?}: {}", out.status, String::from_utf8_lossy(&out.stderr));
let err = String::from_utf8_lossy(&out.stderr);
let warnings: Vec<&str> = err.lines().filter(|l| l.contains("not drawn")).collect();
assert_eq!(warnings.len(), 1, "{ext}: {err}");
assert_eq!(warnings[0], format!("{input}: 3 entities not drawn (PDFUNDERLAY: 1, REGION: 2)"), "{ext}");
}
// A DXF copy keeps them, so there is nothing to warn about.
let copy = dir.join("copy.dxf");
let out = cli(&["convert", input, copy.to_str().unwrap()]);
assert!(out.status.success());
assert!(!String::from_utf8_lossy(&out.stderr).contains("not drawn"), "{}", String::from_utf8_lossy(&out.stderr));
// A drawing without them: no warning.
let sample = dir.join("sample.dxf");
assert!(cli(&["sample", "floorplan", sample.to_str().unwrap()]).status.success());
let out = cli(&["convert", sample.to_str().unwrap(), dir.join("sample.svg").to_str().unwrap()]);
assert!(out.status.success());
assert!(!String::from_utf8_lossy(&out.stderr).contains("not drawn"), "{}", String::from_utf8_lossy(&out.stderr));
let _ = std::fs::remove_dir_all(&dir);
}
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>CADCraft</title>
<link data-trunk rel="rust" data-bin="cadcraft-web" data-wasm-opt="z" data-weak-refs />
<style>
+3
View File
@@ -14,10 +14,13 @@ workspace = true
cadcraft-engine = { workspace = true }
cadcraft-ui-egui = { workspace = true }
cadcraft-io = { workspace = true }
# For the shared per-session control-token helper (control_server.rs authentication).
cadcraft-mcp = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "links", "persistence"] }
egui = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
chrono = { workspace = true }
[build-dependencies]
winresource = "0.1"
+335 -63
View File
@@ -1,41 +1,100 @@
//! Loopback JSON-lines control server: one request per line, one reply per line.
//! This is the transport the MCP server (`cadcraft-cli mcp --connect`) wraps.
//!
//! Lines are bounded ([`MAX_LINE`]): a longer one is refused and the connection closed, without
//! buffering the rest. So is the number of connections served at once ([`MAX_CONNECTIONS`]).
//! The server only reads requests: a line that is not a JSON object with a string `method` (an
//! HTTP request line, a stray `nc`, binary data) gets one error reply and the connection is
//! closed, so nothing sent after it runs. Every resource a client can hold is bounded
//! ([`Limits`]): simultaneous connections, request line length, queued requests, and how long a
//! socket may sit idle or blocked on a write.
use std::io::{BufRead, BufReader, Read, Write};
use std::io::{BufRead, BufReader, ErrorKind, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::mpsc::{Receiver, Sender, channel};
use std::sync::mpsc::{Receiver, SyncSender, TrySendError, sync_channel};
use std::time::Duration;
use cadcraft_ui_egui::ControlRequest;
use serde_json::{Value, json};
/// Longest accepted request line (bytes, without the newline). Generous, so requests that carry
/// whole drawings or images as base64 still fit.
const MAX_LINE: usize = 256 * 1024 * 1024;
/// Connections served at once; further ones get an error line and are closed.
const MAX_CONNECTIONS: usize = 16;
/// Resource bounds for the control server.
#[derive(Clone, Copy, Debug)]
struct Limits {
/// Connections served at once; more get an error line and are closed.
max_clients: usize,
/// Longest request line in bytes (newline excluded); a longer one closes the connection.
max_line: usize,
/// Requests waiting for the app at once; more are answered `busy`.
queue: usize,
/// How long a connection may go without sending a byte before it is closed.
idle_timeout: Duration,
/// How long a reply write may block before the connection is closed.
write_timeout: Duration,
/// How long a request waits for the app's answer.
reply_timeout: Duration,
}
impl Default for Limits {
fn default() -> Self {
Self {
max_clients: 16,
// Generous, so requests that carry whole drawings or images as base64 still fit.
max_line: 256 * 1024 * 1024,
queue: 64,
idle_timeout: Duration::from_secs(600),
write_timeout: Duration::from_secs(30),
reply_timeout: Duration::from_secs(60),
}
}
}
/// Start the loopback server with a fresh session token and a bounded request queue.
/// Binding or token-issuance failure returns a disconnected receiver.
pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
let (tx, rx) = channel::<ControlRequest>();
let listener = match TcpListener::bind(("127.0.0.1", port)) {
Ok(l) => l,
match TcpListener::bind(("127.0.0.1", port)) {
Ok(l) => {
// Loopback TCP cannot tell local users apart, so issue an unguessable per-session
// token in an owner-only file and require it on every request (CWE-306). A client
// (the MCP bridge) reads it from the file, which needs the desktop user's filesystem
// access; another local account cannot.
let token = match cadcraft_mcp::control_token::issue(port) {
Ok(t) => Arc::new(t),
Err(e) => {
log::error!("control server not started: could not write the control token: {e}");
return sync_channel::<ControlRequest>(0).1;
}
};
if let Ok(path) = cadcraft_mcp::control_token::token_path(port) {
log::info!("control server listening on 127.0.0.1:{port} (token in {})", path.display());
}
serve_listener(l, Limits::default(), ctx, token)
}
Err(e) => {
log::error!("control server failed to bind 127.0.0.1:{port}: {e}");
return rx;
sync_channel::<ControlRequest>(0).1
}
};
log::info!("control server listening on 127.0.0.1:{port}");
std::thread::spawn(move || accept_loop(listener, tx, ctx));
}
}
/// How long the accepting thread waits after a failed accept. Accepting can keep failing, for
/// example while the process has no file descriptors left, and retrying at once would keep a
/// core busy until it recovers.
const ACCEPT_RETRY: Duration = Duration::from_millis(100);
/// The connections `incoming` accepts, calling `wait` after each failed accept.
fn accepted<T>(incoming: impl Iterator<Item = std::io::Result<T>>, mut wait: impl FnMut()) -> impl Iterator<Item = T> {
incoming.filter_map(move |stream| stream.map_err(|_| wait()).ok())
}
/// Accept connections on `listener` under `limits`; requests arrive on the returned receiver.
fn serve_listener(listener: TcpListener, limits: Limits, ctx: egui::Context, token: Arc<String>) -> Receiver<ControlRequest> {
let (tx, rx) = sync_channel::<ControlRequest>(limits.queue.max(1));
std::thread::spawn(move || accept_loop(listener, tx, ctx, limits, token));
rx
}
/// A connection's place among [`MAX_CONNECTIONS`], given back when the connection ends (also when
/// its thread can't be started, or panics).
/// A connection's place among [`Limits::max_clients`], given back when the connection ends (also
/// when its thread can't be started, or panics).
struct Slot(Arc<AtomicUsize>);
impl Drop for Slot {
@@ -44,10 +103,12 @@ impl Drop for Slot {
}
}
fn accept_loop(listener: TcpListener, tx: Sender<ControlRequest>, ctx: egui::Context) {
/// Accept clients within the connection limit and give each worker the same session token.
fn accept_loop(listener: TcpListener, tx: SyncSender<ControlRequest>, ctx: egui::Context, limits: Limits, token: Arc<String>) {
let open = Arc::new(AtomicUsize::new(0));
for mut stream in listener.incoming().flatten() {
if open.fetch_add(1, Ordering::SeqCst) >= MAX_CONNECTIONS {
for mut stream in accepted(listener.incoming(), || std::thread::sleep(ACCEPT_RETRY)) {
let _ = stream.set_write_timeout(Some(limits.write_timeout));
if open.fetch_add(1, Ordering::SeqCst) >= limits.max_clients {
open.fetch_sub(1, Ordering::SeqCst);
let _ = writeln!(stream, "{}", json!({"ok": false, "error": "too many control connections"}));
continue;
@@ -55,9 +116,10 @@ fn accept_loop(listener: TcpListener, tx: Sender<ControlRequest>, ctx: egui::Con
let slot = Slot(Arc::clone(&open));
let tx = tx.clone();
let ctx = ctx.clone();
let token = Arc::clone(&token);
let _ = std::thread::Builder::new().spawn(move || {
let _slot = slot;
serve(stream, tx, ctx);
serve(stream, &tx, &ctx, limits, &token);
});
}
}
@@ -68,7 +130,11 @@ fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec<u8>, max: usize) -
buf.clear();
// `take` bounds the bytes buffered for one line: a peer can't make us allocate more than `max`.
let limit = u64::try_from(max).unwrap_or(u64::MAX).saturating_add(1);
let n = reader.take(limit).read_until(b'\n', buf).map_err(|e| e.to_string())?;
let n = reader.take(limit).read_until(b'\n', buf).map_err(|e| match e.kind() {
// The idle timeout ran out (Windows reports it as TimedOut, Unix as WouldBlock).
ErrorKind::TimedOut | ErrorKind::WouldBlock => "request line not completed in time; closing the connection".to_string(),
_ => e.to_string(),
})?;
if n == 0 {
return Ok(false);
}
@@ -86,48 +152,112 @@ fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec<u8>, max: usize) -
Ok(true)
}
fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context) {
/// One request line, parsed.
enum Line {
Request {
id: Value,
method: String,
params: Value,
token: Option<String>,
},
Blank,
/// Not a request: reply with this error and close the connection.
Reject(String),
}
/// Parse a UTF-8 JSON request and its optional token, distinguishing blank lines from rejected input.
fn parse_line(bytes: &[u8]) -> Line {
let Ok(text) = std::str::from_utf8(bytes) else {
return Line::Reject("not a control request (invalid UTF-8); closing the connection".into());
};
let text = text.trim();
if text.is_empty() {
return Line::Blank;
}
if is_http_request_line(text) {
return Line::Reject("HTTP is not served here: this port takes JSON lines; closing the connection".into());
}
let msg = match serde_json::from_str::<Value>(text) {
Ok(v) => v,
Err(e) => return Line::Reject(format!("bad JSON ({e}); expected one JSON request object per line; closing the connection")),
};
let Some(obj) = msg.as_object() else {
return Line::Reject("not a control request (expected a JSON object); closing the connection".into());
};
let Some(method) = obj.get("method").and_then(Value::as_str) else {
return Line::Reject("not a control request (missing string `method`); closing the connection".into());
};
Line::Request {
id: obj.get("id").cloned().unwrap_or(Value::Null),
method: method.to_string(),
params: obj.get("params").cloned().unwrap_or(json!({})),
token: obj.get("token").and_then(Value::as_str).map(str::to_string),
}
}
/// An HTTP/1 request line (`POST / HTTP/1.1`) or the HTTP/2 preface (`PRI * HTTP/2.0`).
fn is_http_request_line(text: &str) -> bool {
const METHODS: [&str; 10] = ["GET", "HEAD", "POST", "PUT", "DELETE", "CONNECT", "OPTIONS", "TRACE", "PATCH", "PRI"];
text.split_once(' ').is_some_and(|(method, _)| METHODS.contains(&method))
|| text.rsplit_once(' ').is_some_and(|(_, version)| version.starts_with("HTTP/"))
}
/// Serve bounded JSON lines, authenticating each request before enqueueing it for the UI.
/// Invalid credentials close the connection; queue and reply limits report whether a request ran.
fn serve(stream: TcpStream, tx: &SyncSender<ControlRequest>, ctx: &egui::Context, limits: Limits, token: &str) {
if stream.set_read_timeout(Some(limits.idle_timeout)).is_err() || stream.set_write_timeout(Some(limits.write_timeout)).is_err() {
return;
}
let Ok(read) = stream.try_clone() else { return };
let mut out = stream;
let mut reader = BufReader::new(read);
let mut buf = Vec::new();
loop {
match read_bounded_line(&mut reader, &mut buf, MAX_LINE) {
Ok(true) => {}
let reject = match read_bounded_line(&mut reader, &mut buf, limits.max_line) {
Ok(false) => break,
Err(e) => {
let _ = writeln!(out, "{}", json!({"ok": false, "error": e}));
break;
}
}
// Bytes that aren't UTF-8 end the connection, as `BufRead::lines` did.
let Ok(line) = std::str::from_utf8(&buf) else { break };
if line.trim().is_empty() {
continue;
}
let reply = match serde_json::from_str::<Value>(line) {
Ok(msg) => {
let id = msg.get("id").cloned().unwrap_or(Value::Null);
let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string();
let params = msg.get("params").cloned().unwrap_or(json!({}));
let (req, pending) = ControlRequest::new(method, params);
if tx.send(req).is_err() {
break;
// Nothing of a line arrived (idle timeout, or the peer is gone): close without a word.
// A line nobody asked for would be read as the reply to the client's next request.
Err(_) if buf.is_empty() => break,
Err(e) => e,
Ok(true) => match parse_line(&buf) {
Line::Blank => continue,
Line::Reject(e) => e,
Line::Request { id, method, params, token: given } => {
// Authenticate the peer before dispatching: loopback TCP does not identify the
// OS user, so only a client holding the owner-only session token may drive the
// desktop user's commands (CWE-306).
if !given.map(|g| cadcraft_mcp::control_token::matches(token, &g)).unwrap_or(false) {
let mut r = json!({"ok": false, "error": "unauthorized: missing or invalid control token", "state": "not-run"});
if let Some(o) = r.as_object_mut() {
o.insert("id".into(), id);
}
let _ = writeln!(out, "{r}");
break;
}
let (req, pending) = ControlRequest::new(method, params);
let mut r = match tx.try_send(req) {
Ok(()) => {
ctx.request_repaint();
// On timeout the request is cancelled if the app hasn't started it, and
// the reply's `state` says whether it can still have run.
pending.wait(limits.reply_timeout)
}
Err(TrySendError::Full(_)) => json!({"ok": false, "error": "busy: too many queued requests", "state": "not-run"}),
Err(TrySendError::Disconnected(_)) => break,
};
if let Some(o) = r.as_object_mut() {
o.insert("id".into(), id);
}
if writeln!(out, "{r}").is_err() {
break;
}
continue;
}
ctx.request_repaint();
// On timeout the request is cancelled if the app hasn't started it, and the reply's
// `state` says whether it can still have run.
let mut r = pending.wait(Duration::from_secs(60));
if let Some(o) = r.as_object_mut() {
o.insert("id".into(), id);
}
r
}
Err(e) => json!({"ok": false, "error": format!("bad JSON: {e}")}),
},
};
if writeln!(out, "{reply}").is_err() {
break;
}
// The connection closes either way; a peer that can't read the reason loses nothing.
let _ = writeln!(out, "{}", json!({"ok": false, "error": reject}));
break;
}
}
@@ -135,6 +265,18 @@ fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context) {
mod tests {
use super::*;
/// A failed accept is followed by a wait before the next one, so a listener that keeps
/// failing does not spin, and the connections around the failures still come through.
#[test]
fn a_failed_accept_waits_before_the_next() {
let failed = || Err(std::io::Error::other("no file descriptors left"));
let incoming = vec![failed(), Ok(1), failed(), failed(), Ok(2)];
let mut waits = 0;
let connections: Vec<i32> = super::accepted(incoming.into_iter(), || waits += 1).collect();
assert_eq!(connections, [1, 2]);
assert_eq!(waits, 3, "one wait per failed accept");
}
#[test]
fn lines_are_read_up_to_the_limit() {
let mut buf = Vec::new();
@@ -158,12 +300,29 @@ mod tests {
assert_eq!(input.len(), 100 - 11, "the rest of the line is left unread");
}
/// The control server on a loopback port, with a stand-in UI that echoes each method.
fn serve_echo() -> std::net::SocketAddr {
fn short() -> Limits {
Limits {
queue: 1,
idle_timeout: Duration::from_millis(300),
write_timeout: Duration::from_millis(300),
reply_timeout: Duration::from_millis(300),
..Limits::default()
}
}
/// A fixed token for tests; real tokens are random (see `cadcraft_mcp::control_token`).
const TEST_TOKEN: &str = "test-control-token";
/// The control server on a loopback port under `limits`; requests arrive on the receiver.
fn serve_with(limits: Limits) -> (std::net::SocketAddr, Receiver<ControlRequest>) {
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
let addr = listener.local_addr().unwrap();
let (tx, rx) = channel::<ControlRequest>();
std::thread::spawn(move || accept_loop(listener, tx, egui::Context::default()));
(addr, serve_listener(listener, limits, egui::Context::default(), Arc::new(TEST_TOKEN.to_string())))
}
/// The control server on a loopback port, with a stand-in UI that echoes each method.
fn serve_echo() -> std::net::SocketAddr {
let (addr, rx) = serve_with(Limits::default());
std::thread::spawn(move || {
for req in rx {
let _ = req.reply.send(json!({"ok": true, "method": req.method}));
@@ -172,12 +331,97 @@ mod tests {
addr
}
/// A request line carrying the test token, so it authenticates.
fn req_line(id: i64, method: &str) -> String {
json!({"id": id, "method": method, "token": TEST_TOKEN}).to_string() + "\n"
}
/// Send `input` on a new connection and read reply lines until the server closes it.
fn exchange(addr: std::net::SocketAddr, input: &[u8]) -> Vec<Value> {
let mut c = TcpStream::connect(addr).unwrap();
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
c.write_all(input).unwrap();
let mut replies = Vec::new();
for line in BufReader::new(c).lines() {
// A read timeout here means the server kept the connection open.
let line = line.unwrap();
replies.push(serde_json::from_str(&line).unwrap());
}
replies
}
/// A browser can POST to the port: its request line closes the connection before the JSON
/// line in its body is read, so nothing is dispatched.
#[test]
fn an_http_request_closes_the_connection_before_its_body_is_dispatched() {
let (addr, rx) = serve_with(short());
let http = b"POST / HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: text/plain\r\n\r\n\
{\"id\":1,\"method\":\"cmdline.input\",\"params\":{\"text\":\"circle 0,0 5\"}}\n";
let replies = exchange(addr, http);
assert_eq!(replies.len(), 1, "{replies:?}");
assert_eq!(replies[0]["ok"], false);
assert!(replies[0]["error"].as_str().unwrap().contains("closing the connection"), "{replies:?}");
assert!(rx.try_recv().is_err(), "the body was dispatched");
}
#[test]
fn a_line_that_is_not_a_request_closes_the_connection() {
for junk in [&b"hello\n"[..], b"[1,2]\n", b"{\"id\":1}\n", b"{\"method\":5}\n", b"\xff\xfe\n", b"GET /x HTTP/1.1\n"] {
let addr = serve_echo();
let mut input = junk.to_vec();
input.extend_from_slice(b"{\"id\":2,\"method\":\"ping\"}\n");
let replies = exchange(addr, &input);
assert_eq!(replies.len(), 1, "{replies:?}");
assert_eq!(replies[0]["ok"], false);
}
assert!(is_http_request_line("OPTIONS * HTTP/1.1"));
assert!(is_http_request_line("PRI * HTTP/2.0"));
assert!(!is_http_request_line("{\"method\":\"GET /\"}"));
}
#[test]
fn an_idle_connection_is_closed() {
let (addr, _rx) = serve_with(short());
let mut c = TcpStream::connect(addr).unwrap();
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
let t = std::time::Instant::now();
let mut got = Vec::new();
c.read_to_end(&mut got).unwrap();
assert!(got.is_empty(), "{}", String::from_utf8_lossy(&got));
assert!(t.elapsed() < Duration::from_secs(4));
}
/// Authenticated requests rejected by a full queue retain their IDs and report that they did not run.
#[test]
fn a_full_queue_answers_busy() {
let (addr, rx) = serve_with(short());
// No UI drains the one-place queue: the first request fills it, the second finds it full.
let first = std::thread::spawn(move || exchange(addr, req_line(1, "m").as_bytes()));
let deadline = std::time::Instant::now() + Duration::from_secs(4);
let busy = loop {
let mut c = TcpStream::connect(addr).unwrap();
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
c.write_all(req_line(2, "m").as_bytes()).unwrap();
let mut line = String::new();
BufReader::new(c).read_line(&mut line).unwrap();
let v: Value = serde_json::from_str(&line).unwrap();
if v["error"].as_str().is_some_and(|e| e.starts_with("busy")) || std::time::Instant::now() > deadline {
break v;
}
};
assert_eq!((busy["id"].as_i64(), busy["state"].as_str()), (Some(2), Some("not-run")), "{busy}");
assert_eq!(first.join().unwrap()[0]["state"], "not-run");
drop(rx);
}
/// Replies on one authenticated connection preserve the ID of each request.
#[test]
fn requests_are_answered_with_their_ids() {
let addr = serve_echo();
let mut c = TcpStream::connect(addr).unwrap();
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
c.write_all(b"{\"id\":3,\"method\":\"ping\"}\r\n\n{\"id\":4,\"method\":\"pong\"}\n").unwrap();
let input = format!("{}\n{}", req_line(3, "ping").trim_end(), req_line(4, "pong"));
c.write_all(input.as_bytes()).unwrap();
let mut r = BufReader::new(c);
for (id, method) in [(3, "ping"), (4, "pong")] {
let mut line = String::new();
@@ -187,11 +431,39 @@ mod tests {
}
}
/// Missing and incorrect session tokens are rejected before dispatch.
#[test]
fn a_request_without_the_token_is_unauthorized() {
// A local peer that does not hold the session token cannot drive the app (CWE-306).
let addr = serve_echo();
let replies = exchange(addr, b"{\"id\":9,\"method\":\"ping\"}\n");
assert_eq!(replies.len(), 1, "{replies:?}");
assert_eq!(replies[0]["ok"], false);
assert!(replies[0]["error"].as_str().unwrap().contains("unauthorized"), "{replies:?}");
// A wrong token is also refused.
let wrong = exchange(addr, b"{\"id\":9,\"method\":\"ping\",\"token\":\"nope\"}\n");
assert_eq!(wrong[0]["ok"], false);
assert!(wrong[0]["error"].as_str().unwrap().contains("unauthorized"), "{wrong:?}");
}
/// A request carrying the session token reaches the control dispatcher.
#[test]
fn a_request_with_the_token_is_dispatched() {
let addr = serve_echo();
let mut c = TcpStream::connect(addr).unwrap();
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
c.write_all(req_line(10, "ping").as_bytes()).unwrap();
let mut line = String::new();
BufReader::new(c).read_line(&mut line).unwrap();
let v: Value = serde_json::from_str(&line).unwrap();
assert_eq!((v["ok"].as_bool(), v["method"].as_str()), (Some(true), Some("ping")), "{line}");
}
#[test]
fn extra_connections_are_refused() {
let addr = serve_echo();
// Idle connections are accepted in order and held open.
let held: Vec<TcpStream> = (0..MAX_CONNECTIONS).map(|_| TcpStream::connect(addr).unwrap()).collect();
let held: Vec<TcpStream> = (0..Limits::default().max_clients).map(|_| TcpStream::connect(addr).unwrap()).collect();
let mut extra = TcpStream::connect(addr).unwrap();
extra.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
let mut line = String::new();
+36 -16
View File
@@ -93,7 +93,10 @@ fn matches(name: &str, target: &str) -> bool {
}
}
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch.
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch (the
/// `duration_since` clamp); past the last date chrono can represent (year 262143) the proleptic
/// Gregorian date is kept via the 400-year cycle rather than falling back to the epoch, so the
/// output never changes for a value the old formatter accepted and logging never panics.
pub fn timestamp(t: SystemTime) -> String {
let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
let secs = since.as_secs();
@@ -102,20 +105,25 @@ pub fn timestamp(t: SystemTime) -> String {
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis())
}
/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's
/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch; saturating so an absurd
/// clock can't overflow).
fn civil_from_days(days: u64) -> (u64, u64, u64) {
let z = days.saturating_add(719_468);
let era = z / 146_097;
let doe = z % 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = era.saturating_mul(400).saturating_add(yoe).saturating_add(u64::from(m <= 2));
(y, m, d)
/// Days since 1970-01-01 → proleptic Gregorian (year, month, day).
///
/// `timestamp` clamps a pre-1970 clock to the epoch, so `days` is an unsigned count that can run
/// past `NaiveDate`'s ±262143-year range. The Gregorian calendar repeats every 400 years
/// (146_097 days), so chrono converts the day within one cycle and whole cycles go back into the
/// year (an `i128`, then saturated into `i64`), keeping chrono as the only calendar logic and
/// reproducing the old saturating `civil_from_days` result exactly without hand-rolled day math.
/// 1970-01-01 is `num_days_from_ce` 719_163.
fn civil_from_days(days: u64) -> (i64, u32, u32) {
use chrono::Datelike;
let (cycles, rest) = (days / 146_097, days % 146_097);
// `rest + 719_163` is 719_163..865_259, always a valid CE day.
match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) {
Some(d) => {
let y = i128::from(d.year()) + i128::from(cycles) * 400;
(i64::try_from(y).unwrap_or(i64::MAX), d.month(), d.day())
}
None => (1970, 1, 1), // unreachable
}
}
fn numbered(dir: &Path, n: usize) -> PathBuf {
@@ -385,8 +393,20 @@ mod tests {
// 2000 is a leap year (divisible by 400), 2100 is not.
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(951_782_400)), "2000-02-29T00:00:00.000Z");
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z");
// Before the epoch (a clock set wrong) clamps instead of panicking.
// Before the epoch (a clock set wrong) clamps to the epoch instead of panicking.
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
// Past chrono's range (a clock set absurdly far ahead) keeps the proleptic Gregorian date
// the old formatter gave, rather than falling back to the epoch.
// Windows' SystemTime holds only up to about year 30828, so this is checked where it fits.
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) {
assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z");
}
// Windows' SystemTime can't hold u64::MAX / 2 seconds, so it is only checked where it exists.
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) {
assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z");
}
// Years past 9999 keep the plain zero-padded year (no `+`).
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z");
}
#[test]
+12
View File
@@ -61,3 +61,15 @@ sync_github_name = false
real_name = "Chester Fung"
display_name = "Chester Fung"
sync_github_name = true
[people.C00kieMonsta]
real_name = "Antoine Boxho"
display_name = "Antoine Boxho"
sync_github_name = true
[people.Silvertree2010]
display_name = "Netter"
sync_github_name = false
[people.gurparvez]
real_name = "Gurparvez"
+9 -1
View File
@@ -22,7 +22,7 @@ impl Rgb {
}
pub fn parse_hex(s: &str) -> Option<Rgb> {
let s = s.trim().trim_start_matches('#');
if s.len() != 6 || !s.is_ascii() {
if s.len() != 6 || !s.chars().all(|c| c.is_ascii_hexdigit()) {
return None;
}
u32::from_str_radix(s, 16).ok().map(Rgb::from_u32)
@@ -225,6 +225,14 @@ pub fn display_rgb(c: Rgb, aci7: bool, background: Rgb) -> Rgb {
mod tests {
use super::*;
#[test]
fn parse_hex_rejects_non_hex_digits() {
assert_eq!(Rgb::parse_hex("+12345"), None);
assert_eq!(Rgb::parse_hex("-12345"), None);
assert_eq!(Color::parse("#+12345"), None);
assert_eq!(Rgb::parse_hex("#012345"), Some(Rgb(1, 35, 69)));
}
#[test]
fn palette_known_entries() {
assert_eq!(aci_rgb(1), Rgb(255, 0, 0));
+5 -1
View File
@@ -102,7 +102,11 @@ fn ang_between(d1: Vec2, d2: Vec2) -> f64 {
pub fn infer(d: &Drawing, handles: &[Handle], o: &InferOptions) -> Vec<Constraint> {
let tol = if o.distance_tolerance.is_finite() { o.distance_tolerance.clamp(0.0, 1e9) } else { 0.05 };
let atol = if o.angle_tolerance.is_finite() { o.angle_tolerance.clamp(0.0, 45.0).to_radians() } else { 1f64.to_radians() };
let allow = |k: ConstraintKind| o.types.is_empty() || o.types.iter().any(|t| t.eq_ignore_ascii_case(k.name()));
// Resolve the allowed type names into a set once, so membership is O(1) per candidate instead
// of re-scanning the whole (attacker-controllable) `o.types` list for every geometric match
// (CWE-407). Empty `o.types` means "all types".
let allowed: std::collections::HashSet<String> = o.types.iter().map(|t| t.to_ascii_lowercase()).collect();
let allow = |k: ConstraintKind| allowed.is_empty() || allowed.contains(&k.name().to_ascii_lowercase());
let g = collect(d, handles);
let mut out: Vec<Constraint> = Vec::new();
let mut push = |kind: ConstraintKind, refs: Vec<GeomRef>| {
+49 -3
View File
@@ -12,12 +12,15 @@ use cadcraft_doc::{Constraint, Drawing, Handle};
use serde::Serialize;
use crate::model::Model;
use crate::{Comp, MAX_CONSTRAINTS, SolveError, SolveOptions, build, lm};
use crate::{Comp, MAX_CONSTRAINTS, SolveError, SolveOptions, build_budgeted, lm};
/// Most free parameters for which per-object freedom is analysed (null-space basis size).
const MAX_NULL_COLS: usize = 600;
/// Largest Jacobian (rows × columns × min) whose rank is computed.
const MAX_RANK_WORK: f64 = 4e8;
/// Largest residual-evaluation work (rows × free × per-residual cost, including spline
/// reconstruction) allowed before building the Jacobian during numerical differentiation.
const MAX_EVAL_WORK: f64 = 2e9;
/// Most objects listed in one report.
const MAX_OBJECTS: usize = 10_000;
@@ -86,6 +89,8 @@ struct CompDof {
objects: Vec<Option<usize>>,
}
/// Compute component and object freedom only when rank and residual-evaluation work fit their budgets.
/// Return unknown freedom before building the Jacobian when either work limit is exceeded.
fn analyse(comp: &Comp) -> CompDof {
let free = comp.free(None);
let rows = comp.rows(None);
@@ -94,6 +99,14 @@ fn analyse(comp: &Comp) -> CompDof {
if rows > lm::MAX_ROWS || (rows as f64) * (nf as f64) * (rows.min(nf) as f64) > MAX_RANK_WORK {
return unknown;
}
// Numerical differentiation evaluates every residual about `rows × free` times, and each
// spline-end residual reconstructs its spline through a dense solve. Account for that
// reconstruction cost before building the Jacobian so a component with few equations but large
// splines cannot pass the rank-work check yet still require billions of operations (row 43).
let eval_work = (rows as f64) * (nf as f64) * (1.0 + comp.built.iter().map(|b| b.spline_reconstruct_cost(&comp.model)).sum::<f64>());
if eval_work > MAX_EVAL_WORK {
return unknown;
}
let f = |x: &[f64], out: &mut Vec<f64>| comp.eval(x, out, None);
let jac = lm::jacobian(&f, &comp.model.x0, &free, rows);
if nf > MAX_NULL_COLS {
@@ -126,7 +139,8 @@ fn plural(n: usize, one: &str, many: &str) -> String {
/// (constrained or not) and the constraint components they belong to.
pub fn dof(d: &Drawing, handles: &[Handle]) -> Result<DofReport, SolveError> {
let cons: Vec<&Constraint> = d.constraints.iter().take(MAX_CONSTRAINTS).collect();
let (comps, _) = build(d, &cons, &SolveOptions::default())?;
let mut members_left = crate::model::MAX_RIGID_WORK;
let (comps, _) = build_budgeted(d, &cons, &SolveOptions::default(), &mut members_left)?;
let want = |h: Handle| handles.is_empty() || handles.contains(&h);
let mut objects = Vec::new();
let mut seen = HashSet::new();
@@ -165,7 +179,7 @@ pub fn dof(d: &Drawing, handles: &[Handle]) -> Result<DofReport, SolveError> {
if seen.contains(&h) {
continue;
}
let m = Model::build(d, &[h]);
let m = Model::build_budgeted(d, &[h], &mut members_left);
let Some(s) = m.slots.first() else { continue };
seen.insert(h);
free_objects = true;
@@ -193,3 +207,35 @@ pub fn dof(d: &Drawing, handles: &[Handle]) -> Result<DofReport, SolveError> {
};
Ok(DofReport { dof: total, status, redundant, constraints, objects, message })
}
#[cfg(test)]
mod budget_tests {
use super::*;
use crate::residual::{Built, Form};
use cadcraft_doc::{Common, ConstraintKind, EntityKind, GeomRef, Space};
use cadcraft_geom::{Spline, Vec2};
/// An expensive fit-spline endpoint residual produces unknown freedom before numerical differentiation.
#[test]
fn endpoint_analysis_rejects_cubic_fit_work_before_differentiation() {
let mut d = Drawing::default();
let pts: Vec<_> = (0..500).map(|i| Vec2::new(i as f64, (i % 3) as f64)).collect();
let mut spline = Spline::from_control(pts.clone(), 3);
spline.fit = pts;
let h = d.add(&Space::Model, Common::default(), EntityKind::Spline(spline)).unwrap();
let model = Model::build(&d, &[h]);
let endpoint = Built {
id: 1,
kind: ConstraintKind::Tangent,
refs: vec![GeomRef::whole(h)],
form: Form::EndOn { spline: 0, at_end: false },
target: 0.0,
sign: 1.0,
rows: 2,
aux_init: Vec::new(),
aux: 0,
};
let comp = Comp { model, built: vec![endpoint], weights: vec![1.0; 2], tol: 1e-9 };
assert!(analyse(&comp).dof.is_none());
}
}
+44
View File
@@ -185,6 +185,43 @@ pub fn names(e: &Expr, out: &mut Vec<String>) {
}
}
/// `text` with every whole-name use of `old` replaced by `new`; numbers (`1e3`), other names
/// (`d10` for `d1`) and spacing are left as written.
pub fn rename(text: &str, old: &str, new: &str) -> String {
let b = text.as_bytes();
let mut out = String::with_capacity(text.len());
let mut i = 0;
while let Some(&c) = b.get(i) {
let start = i;
if c.is_ascii_digit() || c == b'.' {
while b.get(i).is_some_and(|c| c.is_ascii_digit() || *c == b'.') {
i += 1;
}
if b.get(i).is_some_and(|c| *c == b'e' || *c == b'E') {
let mut j = i + 1;
if b.get(j).is_some_and(|c| *c == b'+' || *c == b'-') {
j += 1;
}
if b.get(j).is_some_and(u8::is_ascii_digit) {
i = j;
while b.get(i).is_some_and(u8::is_ascii_digit) {
i += 1;
}
}
}
} else if c.is_ascii_alphabetic() || c == b'_' {
while b.get(i).is_some_and(|c| c.is_ascii_alphanumeric() || *c == b'_') {
i += 1;
}
} else {
i += text.get(i..).and_then(|r| r.chars().next()).map_or(1, char::len_utf8);
}
let token = text.get(start..i).unwrap_or_default();
out.push_str(if token == old { new } else { token });
}
out
}
/// Evaluate with a variable lookup.
pub fn eval_with(e: &Expr, lookup: &mut dyn FnMut(&str) -> Result<f64, ExprError>) -> Result<f64, ExprError> {
let v = match e {
@@ -314,6 +351,13 @@ mod tests {
assert!(chain.value("p0").is_err());
}
#[test]
fn rename_replaces_whole_names_only() {
assert_eq!(rename("d1+d10*d1 / (d1_x+2e1)", "d1", "w"), "w+d10*w / (d1_x+2e1)");
assert_eq!(rename("1e2+e", "e", "x"), "1e2+x");
assert_eq!(rename("é+a", "a", "b"), "é+b");
}
#[test]
fn names_valid() {
assert!(valid_name("d1") && valid_name("_w") && !valid_name("1d") && !valid_name("a-b") && !valid_name("") && !valid_name("pi"));
+80 -1
View File
@@ -324,12 +324,24 @@ fn components<'a>(cons: &[&'a Constraint]) -> Vec<Vec<&'a Constraint>> {
/// Build the component systems. Constraints that cannot be interpreted are returned as skipped.
pub(crate) fn build(d: &Drawing, cons: &[&Constraint], opts: &SolveOptions) -> Result<(Vec<Comp>, Vec<(u32, String)>), SolveError> {
let mut members_left = model::MAX_RIGID_WORK;
build_budgeted(d, cons, opts, &mut members_left)
}
/// Build constraint components with one caller-owned rigid-member sampling budget shared by every model.
/// Return constraints that cannot be interpreted as skipped entries.
pub(crate) fn build_budgeted(
d: &Drawing,
cons: &[&Constraint],
opts: &SolveOptions,
members_left: &mut usize,
) -> Result<(Vec<Comp>, Vec<(u32, String)>), SolveError> {
let mut e = env(d);
let mut skipped = Vec::new();
let mut comps = Vec::new();
for group in components(cons) {
let handles: Vec<Handle> = group.iter().flat_map(|c| c.refs.iter().map(|r| r.handle)).collect();
let mut model = Model::build(d, &handles);
let mut model = Model::build_budgeted(d, &handles, members_left);
let mut built = Vec::new();
for c in group {
let target = if c.kind.is_dimensional() { constraint_value(&mut e, c)? } else { 0.0 };
@@ -428,6 +440,46 @@ pub fn residuals(d: &Drawing) -> Vec<(u32, Option<f64>)> {
.collect()
}
/// Put back what each Fix locks where `before` had it, and report whether anything moved.
///
/// A Fix keeps no position of its own: the solver locks the parameters at their current values.
/// Without this, an edit that moves a fixed point (a grip drag) would carry the lock along with
/// it. Only Fixes that already existed in `before`, on objects of the same shape, are restored.
pub fn restore_fixed(d: &mut Drawing, before: &Drawing) -> bool {
let refs: Vec<GeomRef> = d
.constraints
.iter()
.filter(|c| c.kind == ConstraintKind::Fix && before.constraints.contains(c))
.flat_map(|c| c.refs.iter().copied())
.collect();
if refs.is_empty() {
return false;
}
let handles: Vec<Handle> = refs.iter().map(|r| r.handle).collect();
let (now, old) = (Model::build(d, &handles), Model::build(before, &handles));
let mut x = now.x0.clone();
let mut changed = false;
for r in &refs {
let same_shape = matches!((now.slot(r.handle), old.slot(r.handle)), (Some(a), Some(b)) if a.shape == b.shape);
let (Some(i), Some(j)) = (now.lock_indices(r), old.lock_indices(r)) else { continue };
if !same_shape {
continue;
}
for (i, j) in i.into_iter().zip(j) {
if let (Some(slot), Some(&v)) = (x.get_mut(i), old.x0.get(j))
&& *slot != v
{
*slot = v;
changed = true;
}
}
}
if changed {
now.write_back(d, &x);
}
changed
}
/// True when every interpretable constraint holds at the current geometry.
pub fn all_satisfied(d: &Drawing) -> bool {
let handles: Vec<Handle> = d.constraints.iter().flat_map(|c| c.refs.iter().map(|r| r.handle)).collect();
@@ -659,6 +711,33 @@ pub fn set_parameter(d: &mut Drawing, name: &str, expr_text: &str, opts: &SolveO
Ok(report)
}
/// Rename a parameter (dimensional constraint name or user parameter) and every expression that
/// uses it. Geometry doesn't change. On error the drawing is unchanged.
pub fn rename_parameter(d: &mut Drawing, old: &str, new: &str) -> Result<(), SolveError> {
if !name_in_use(d, old) {
return Err(SolveError::Other(format!("no parameter `{old}`")));
}
if !expr::valid_name(new) {
return Err(SolveError::Other(format!("invalid parameter name `{new}`")));
}
if new != old && name_in_use(d, new) {
return Err(SolveError::Other(format!("`{new}` is already in use")));
}
for c in d.constraints.iter_mut().filter(|c| c.kind.is_dimensional()) {
if c.name == old {
c.name = new.to_string();
}
c.expr = expr::rename(&c.expr, old, new);
}
for p in &mut d.parametric.parameters {
if p.name == old {
p.name = new.to_string();
}
p.expr = expr::rename(&p.expr, old, new);
}
Ok(())
}
/// Where to draw a constraint's glyphs (one anchor per reference).
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
+102 -3
View File
@@ -27,6 +27,11 @@ pub const MAX_PARAMS: usize = 4000;
pub const MAX_SPLINE_POINTS: usize = 500;
/// Most characteristic points of a block reference used as rigid constraint points.
pub const MAX_RIGID_POINTS: usize = 256;
/// Block members examined when sampling rigid points, counting those that contribute none, so a
/// block padded with point-less entities cannot make sampling (repeated per Insert) unbounded.
const MAX_RIGID_MEMBERS: usize = 100_000;
/// Shared across every model built for one analysis request.
pub(crate) const MAX_RIGID_WORK: usize = 1_000_000;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Shape {
@@ -74,6 +79,21 @@ pub(crate) struct Model {
rigid: HashMap<Handle, Vec<Vec2>>,
}
impl Model {
/// Work for a spline that an endpoint residual actually reconstructs. Fit interpolation
/// uses dense Gaussian elimination (cubic); control splines clone and evaluate their template.
pub(crate) fn spline_reconstruct_cost(&self, handle: Handle) -> f64 {
let Some(s) = self.splines.get(&handle) else { return 0.0 };
let n = s.fit.len() as f64;
if fit_mode(s) {
n * n * n
} else {
let n = s.control.len() as f64;
n * n
}
}
}
/// Can this entity take part in constraints?
pub fn supported(kind: &EntityKind) -> bool {
matches!(
@@ -93,13 +113,26 @@ pub fn supported(kind: &EntityKind) -> bool {
/// centres, vertices, nested insertion points) of the block's own objects, at most
/// [`MAX_RIGID_POINTS`]. Nested blocks are not entered.
pub fn rigid_points(d: &Drawing, ins: &Insert) -> Vec<Vec2> {
let mut members_left = MAX_RIGID_MEMBERS;
rigid_points_budgeted(d, ins, &mut members_left)
}
/// Sample finite block points within per-insert and caller-shared member limits.
/// Members that contribute no points still consume traversal work; nested blocks are not entered.
fn rigid_points_budgeted(d: &Drawing, ins: &Insert, members_left: &mut usize) -> Vec<Vec2> {
let Some(block) = d.block(&ins.block) else { return Vec::new() };
let m = ins.transform(block.base.xy());
let mut out = Vec::new();
// Bound the members examined, not only the points collected: entities that contribute no
// points (Text, Ray, …) still cost traversal, so a block full of them — scanned again for
// every Insert — would otherwise be unbounded (CWE-407).
let mut examined = 0usize;
for e in block.entities.iter() {
if out.len() >= MAX_RIGID_POINTS {
examined += 1;
if out.len() >= MAX_RIGID_POINTS || examined > MAX_RIGID_MEMBERS || *members_left == 0 {
break;
}
*members_left -= 1;
let pts: Vec<Vec2> = match &e.kind {
EntityKind::Line(l) => vec![l.a.xy(), l.b.xy()],
EntityKind::Arc(a) => {
@@ -152,6 +185,12 @@ fn fit_mode(s: &GSpline) -> bool {
impl Model {
/// Build a model for the given entities. Unsupported or missing entities are skipped.
pub fn build(d: &Drawing, handles: &[Handle]) -> Model {
let mut members_left = MAX_RIGID_WORK;
Self::build_budgeted(d, handles, &mut members_left)
}
/// Build solver parameters for supported entities while sharing rigid-member sampling work with other models.
pub(crate) fn build_budgeted(d: &Drawing, handles: &[Handle], members_left: &mut usize) -> Model {
let mut m = Model::default();
for &h in handles {
if m.index.contains_key(&h) {
@@ -208,12 +247,15 @@ impl Model {
// fit point; a control polygon's ends are its first and last points when the
// knots are clamped.
let ends = !sp.closed && (fit || (clamped(sp, false) && clamped(sp, true)));
m.splines.insert(h, sp.clone());
// Missing weights default to 1 in eval; only excess weights are unused.
let mut kept = sp.clone();
kept.weights.truncate(kept.control.len());
m.splines.insert(h, kept);
Shape::Spline { n, fit, ends }
}
EntityKind::Insert(ins) => {
let base = Vec2::new(ins.insert.x, ins.insert.y);
let pts: Vec<Vec2> = rigid_points(d, ins).into_iter().map(|p| (p - base).rotate(-ins.rotation)).collect();
let pts: Vec<Vec2> = rigid_points_budgeted(d, ins, members_left).into_iter().map(|p| (p - base).rotate(-ins.rotation)).collect();
m.x0.extend([ins.insert.x, ins.insert.y, ins.rotation]);
let n = pts.len();
m.rigid.insert(h, pts);
@@ -573,3 +615,60 @@ impl Model {
moved
}
}
#[cfg(test)]
mod budget_tests {
use super::*;
use cadcraft_doc::{Block, Common, Entity, Line, Space};
use cadcraft_geom::Vec3;
use std::sync::Arc;
/// Writing an unchanged model back leaves partial rational weights and the original spline geometry intact.
#[test]
fn write_back_keeps_partial_rational_weights() {
let mut drawing = Drawing::default();
let mut spline = GSpline::from_control(vec![Vec2::ZERO, Vec2::new(1.0, 2.0), Vec2::new(3.0, 0.0)], 2);
spline.weights = vec![2.0, 0.5];
let h = drawing.add(&Space::Model, Common::default(), EntityKind::Spline(spline.clone())).unwrap();
let model = Model::build(&drawing, &[h]);
model.write_back(&mut drawing, &model.x0);
let EntityKind::Spline(after) = &drawing.entity(h).unwrap().kind else { panic!("spline") };
assert_eq!(after.weights, spline.weights);
for i in 0..=10 {
assert!(after.eval(i as f64 / 10.0).near(spline.eval(i as f64 / 10.0), 1e-12));
}
}
/// A second model receives no sampled block points after the first model spends the shared member budget.
#[test]
fn rigid_sampling_budget_is_shared_between_models() {
let mut d = Drawing::default();
let mut block = Block::new("BAR");
for _ in 0..3 {
block.entities.push(Entity {
handle: d.new_handle(),
common: Common::default(),
kind: EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(1.0, 0.0, 0.0) }),
});
}
d.blocks.insert("BAR".into(), Arc::new(block));
let ins = Insert {
block: "BAR".into(),
insert: Vec3::ZERO,
scale: Vec3::new(1.0, 1.0, 1.0),
rotation: 0.0,
attribs: Vec::new(),
cols: 1,
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
};
let h = d.add(&Space::Model, Common::default(), EntityKind::Insert(ins)).unwrap();
let mut budget = 2;
let first = Model::build_budgeted(&d, &[h], &mut budget);
assert_eq!(first.rigid[&h].len(), 4);
assert_eq!(budget, 0);
let next = Model::build_budgeted(&d, &[h], &mut budget);
assert!(next.rigid[&h].is_empty());
}
}
+31 -7
View File
@@ -40,6 +40,11 @@ pub(crate) enum Form {
C1,
/// Arc sweep (angular on an arc).
ArcSweep,
/// Two lines measured along the rays from their intersection to each line's far end, the
/// angle the dimension draws; `rev` marks a line whose far end is its start point.
Rays {
rev: [bool; 2],
},
/// Fix of a derived point (residual to an anchor).
Anchor(Vec2),
/// Fix handled by locking parameters (no rows).
@@ -92,6 +97,10 @@ fn dir_n(a: Vec2, b: Vec2) -> Vec2 {
if l > 1e-12 { d * (1.0 / l) } else { d }
}
fn ray(a: Vec2, b: Vec2, rev: bool) -> Vec2 {
if rev { dir_n(b, a) } else { dir_n(a, b) }
}
fn sgn(v: f64) -> f64 {
if v < 0.0 { -1.0 } else { 1.0 }
}
@@ -107,6 +116,16 @@ fn r_at(c: &Constraint, i: usize) -> Option<&GeomRef> {
}
impl Built {
/// Estimate spline reconstruction work for endpoint and join residuals; other residual forms add no such cost.
pub(crate) fn spline_reconstruct_cost(&self, m: &Model) -> f64 {
let cost = |i: usize| self.refs.get(i).map_or(0.0, |r| m.spline_reconstruct_cost(r.handle));
match self.form {
Form::EndOn { spline, .. } => cost(spline),
Form::Join { .. } => cost(0) + cost(1),
_ => 0.0,
}
}
/// Interpret a constraint against the model at `x`. `target` is the evaluated value for
/// dimensional constraints (degrees for angular). Errors explain what references are needed.
pub fn new(c: &Constraint, m: &Model, x: &[f64], target: f64) -> Result<Built, String> {
@@ -284,10 +303,15 @@ impl Built {
}
K::Angular => {
b.rows = 1;
if n == 2 && ln(0).is_some() && ln(1).is_some() {
b.form = Form::LL;
let (Some((a1, b1)), Some((a2, b2))) = (ln(0), ln(1)) else { return need("two lines") };
let (d1, d2) = (dir_n(a1, b1), dir_n(a2, b2));
if let (2, Some((a1, b1)), Some((a2, b2))) = (n, ln(0), ln(1)) {
// Start-to-end directions would measure the supplement when a line is drawn
// towards the vertex, so use the rays the dimension draws.
let rev = match cadcraft_geom::line_line_infinite(a1, b1, a2, b2) {
Some((_, t, u)) => [t > 0.5, u > 0.5],
None => [false, false],
};
b.form = Form::Rays { rev };
let (d1, d2) = (ray(a1, b1, rev[0]), ray(a2, b2, rev[1]));
b.sign = sgn(d1.cross(d2).atan2(d1.dot(d2)));
} else if n == 1 && m.arc_sweep(x, r_at(c, 0).ok_or_else(|| "Angular needs references".to_string())?).is_some() {
b.form = Form::ArcSweep;
@@ -297,7 +321,7 @@ impl Built {
b.target = target.to_radians();
// Two lines measure a signed angle in [-180, 180] and the sign is re-read from the geometry, so a larger
// target would flip the sign once solved and never be satisfied; only an arc sweep can reach a full turn.
let max = if b.form == Form::LL { std::f64::consts::PI } else { std::f64::consts::TAU };
let max = if matches!(b.form, Form::Rays { .. }) { std::f64::consts::PI } else { std::f64::consts::TAU };
if !b.target.is_finite() || b.target.abs() > max {
return Err("angle out of range".into());
}
@@ -461,9 +485,9 @@ impl Built {
(K::Angular, Form::ArcSweep) => {
out.push(m.arc_sweep(x, r(0)?)? - self.target);
}
(K::Angular, _) => {
(K::Angular, Form::Rays { rev }) => {
let ((a1, b1), (a2, b2)) = (ln(0)?, ln(1)?);
let (d1, d2) = (dir_n(a1, b1), dir_n(a2, b2));
let (d1, d2) = (ray(a1, b1, rev[0]), ray(a2, b2, rev[1]));
out.push(wrap(d1.cross(d2).atan2(d1.dot(d2)) - self.sign * self.target));
}
(K::Radius, _) => out.push(ci(0)?.1 - self.target),
+19
View File
@@ -363,3 +363,22 @@ fn hostile_new_geometry_never_panics() {
let _ = dof(&d, &hs);
let _ = dof(&d, &[Handle(0), Handle(u64::MAX)]);
}
/// Fit-point vertex constraints retain a known freedom count because they do not reconstruct the spline.
#[test]
fn vertex_constraints_on_large_fit_spline_do_not_charge_reconstruction() {
let mut d = Drawing::default();
let pts: Vec<_> = (0..500).map(|i| Vec2::new(i as f64, (i % 3) as f64)).collect();
let mut sp = GSpline::from_control(pts.clone(), 3);
sp.fit = pts;
let h = put(&mut d, EntityKind::Spline(sp));
for i in 0..5 {
let p = point(&mut d, (i as f64, (i % 3) as f64));
let mut c = con(K::Coincident, &[s(h, Sub::Vertex(i)), w(p)]);
c.id = i + 1;
d.constraints.push(c);
}
let report = dof(&d, &[]).unwrap();
assert_eq!(report.status, DofStatus::Under);
assert_eq!(report.dof, Some(1000));
}
+8 -1
View File
@@ -248,10 +248,17 @@ impl ArrayParams {
return false;
}
let Some(items) = self.items() else { return false };
// Index the replacement entries by placement so each item is matched in O(1) instead of
// scanning the whole `replaced` vector per item, which is quadratic when both the items
// and the replacements are large (CWE-407). Last entry wins, as `.rev().find` did.
let mut last_replaced: std::collections::HashMap<(u32, u32, u32), usize> = std::collections::HashMap::with_capacity(self.replaced.len());
for (idx, r) in self.replaced.iter().enumerate() {
last_replaced.insert((r.i, r.j, r.level), idx);
}
let mut source: Option<&str> = None;
for (it, e) in items.iter().zip(&ents) {
let EntityKind::Insert(ins) = &e.kind else { return false };
match self.replaced.iter_mut().rev().find(|r| (r.i, r.j, r.level) == (it.i, it.j, it.level)) {
match last_replaced.get(&(it.i, it.j, it.level)).and_then(|&idx| self.replaced.get_mut(idx)) {
Some(r) => r.block.clone_from(&ins.block),
None => match source {
None => source = Some(&ins.block),
+5 -1
View File
@@ -334,7 +334,11 @@ fn graph_members<'a>(objects: &HashMap<Handle, &'a ForeignObject>, xdict: Handle
}
let graph = objects.get(&graph?).filter(|o| o.kind.eq_ignore_ascii_case("ACAD_EVALUATION_GRAPH"))?;
let members = section(&sections(&graph.tags), "AcDbEvalGraph").iter().filter(|t| t.code == 360).filter_map(|t| Handle::parse_hex(&t.value));
Some(members.take(MAX_NODES).filter_map(|h| objects.get(&h).copied()).collect())
// Deduplicate member handles before resolving them: a graph can list the same member many
// times, and parsing each occurrence into its own owned parameter multiplies a single large
// record into gigabytes of retained allocations (CWE-400).
let mut seen = std::collections::HashSet::new();
Some(members.take(MAX_NODES).filter(|h| seen.insert(*h)).filter_map(|h| objects.get(&h).copied()).collect())
}
/// The dynamic block data of block `name` (any case; `*Model_Space` for a drawing that is a
+9 -1
View File
@@ -464,7 +464,15 @@ fn apply(
if !(v.x.is_finite() && v.y.is_finite()) || (v.x == 0.0 && v.y == 0.0) {
continue;
}
// Index this action's vertex lists by handle once, and skip handles already handled in
// this action's selection, so a selection that repeats a handle does not re-scan the
// vertex list and re-clone its index vector per occurrence (CWE-400).
let vtx_by_handle: HashMap<Handle, &Vec<i32>> = vertices.iter().rev().map(|(vh, i)| (*vh, i)).collect();
let mut handled: HashSet<Handle> = HashSet::new();
for h in &a.selection {
if !handled.insert(*h) {
continue;
}
if let Some(node) = param_of.get(h) {
// A parameter in the selection is carried along: its points in the frame. Which
// points are in it is decided where the dynamic block has them (AutoCAD evaluates
@@ -489,7 +497,7 @@ fn apply(
continue;
}
let Some(c) = copy_of(h) else { continue };
let idx = vertices.iter().find(|(vh, _)| vh == h).map(|(_, i)| i.clone());
let idx = vtx_by_handle.get(h).map(|i| (*i).clone());
moves.entry(c).or_default().push((idx, v));
}
}
+40 -4
View File
@@ -10,7 +10,7 @@ use std::collections::{HashMap, HashSet};
use cadcraft_geom::{Vec2, Vec3};
use serde::Serialize;
use super::{ParameterKind, Value, custom_properties, dynamic_block, reference};
use super::{DynamicBlock, ParameterKind, Value, custom_properties, dynamic_block, reference};
use crate::{Drawing, EntityKind, Handle};
/// Most grips listed for one reference.
@@ -79,6 +79,29 @@ pub struct DynGrip {
pub choices: Vec<String>,
}
/// Which ends of two-point parameter `node` the block's actions follow, as (base, end): a
/// connection to one point's change (BaseXDelta, EndYDelta, UpdatedEnd…) follows that end, one to
/// the value itself (a distance, an angle) follows both. None when no action is attached to the
/// parameter: its grips are all kept, as the block has them.
fn ends_used(m: &DynamicBlock, node: i32) -> Option<(bool, bool)> {
let mut used: Option<(bool, bool)> = None;
for (n, name) in m.actions.iter().flat_map(|a| a.connections.iter()) {
if *n != node {
continue;
}
let c = name.to_ascii_lowercase();
let (base, end) = used.unwrap_or((false, false));
used = Some(if c.starts_with("base") || c.starts_with("updatedbase") {
(true, end)
} else if c.starts_with("end") || c.starts_with("updatedend") {
(base, true)
} else {
(true, true)
});
}
used
}
fn point<S: serde::Serializer>(p: &Vec2, s: S) -> Result<S::Ok, S::Error> {
[p.x, p.y].serialize(s)
}
@@ -112,8 +135,15 @@ pub fn grips(d: &Drawing, insert: Handle) -> Vec<DynGrip> {
break;
}
}
// A grip shows in the current visibility state when the state lists it, or lists its
// parameter (AutoCAD shows a listed parameter's grips though the grips aren't listed).
let param_handle: HashMap<i32, Handle> = m.parameters.iter().map(|p| (p.node, p.handle)).collect();
let shown = |node: i32, id: i32| {
id != 0 && (Some(node) == visibility_node || shown_in_state.as_ref().is_none_or(|a| objects.get(&id).is_some_and(|h| a.contains(h))))
id != 0
&& (Some(node) == visibility_node
|| shown_in_state
.as_ref()
.is_none_or(|a| objects.get(&id).is_some_and(|h| a.contains(h)) || param_handle.get(&node).is_some_and(|h| a.contains(h))))
};
let choices: HashMap<String, Vec<String>> =
custom_properties(d, insert).map(|(_, ps)| ps.into_iter().map(|p| (p.name.to_ascii_uppercase(), p.choices)).collect()).unwrap_or_default();
@@ -121,15 +151,21 @@ pub fn grips(d: &Drawing, insert: Handle) -> Vec<DynGrip> {
for p in &m.parameters {
let grip = |i: usize| p.grips.get(i).copied().unwrap_or(0);
match &p.kind {
ParameterKind::Linear { base, end, label, .. } => {
ParameterKind::Linear { base, end, label, from_midpoint, .. } => {
let (b, e) = match r.values.get(&p.node) {
Some(Value::Points { base, end }) => (*base, *end),
_ => (*base, *end),
};
let u = e - b;
let u = if u.len() > 1e-12 { u * (1.0 / u.len()) } else { Vec3::new(1.0, 0.0, 0.0) };
// An end no action follows has no grip (dragging it would change nothing); a
// distance from its midpoint moves both ends.
let used = match ends_used(&m, p.node) {
Some((base, end)) if !*from_midpoint => [base, end],
_ => [true, true],
};
for (i, part, at, way) in [(0, GripPart::Base, b, u * -1.0), (1, GripPart::End, e, u)] {
if shown(p.node, grip(i)) {
if used[i] && shown(p.node, grip(i)) {
let (at, dir) = (world(at), toward(at, way));
out.push(DynGrip { node: p.node, part, shape: GripShape::Linear, at, dir, property: label.clone(), choices: Vec::new() });
}
+22 -1
View File
@@ -1408,9 +1408,12 @@ impl EntityKind {
EntityKind::LwPolyline(p) => {
let mut g: Vec<Vec2> = Vec::new();
let pl = Polyline { vertices: p.vertices.clone(), closed: p.closed };
// Compute the segments once, not once per vertex: rebuilding the whole segment
// vector inside the loop is quadratic in the vertex count (CWE-407).
let segs = pl.segments();
for (i, v) in p.vertices.iter().enumerate() {
g.push(v.p);
if let Some(s) = pl.segments().get(i) {
if let Some(s) = segs.get(i) {
g.push(s.mid());
}
}
@@ -1468,3 +1471,21 @@ fn transform_text(t: &mut Text, m: &Mat3, s: f64, rot: f64, mirror: bool) {
// MIRRTEXT = 0 (the default): mirrored text keeps reading left to right.
t.rotation = cadcraft_geom::norm_angle(t.rotation + if mirror { 0.0 } else { rot });
}
#[cfg(test)]
mod grip_tests {
use super::*;
use cadcraft_geom::PolyVertex;
#[test]
fn polyline_grips_are_vertices_and_midpoints() {
// Grips are each vertex plus the midpoint of each segment; the segment vector is computed
// once rather than rebuilt per vertex (CWE-407, row 35).
let verts: Vec<PolyVertex> = [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0)].iter().map(|&(x, y)| PolyVertex::new(Vec2::new(x, y))).collect();
let e = EntityKind::LwPolyline(LwPolyline { vertices: verts, closed: false, const_width: 0.0, elevation: 0.0, plinegen: false });
let g = e.grips();
// 3 vertices + 2 segment midpoints.
assert_eq!(g.len(), 5, "{g:?}");
assert!(g.contains(&Vec2::new(0.0, 0.0)) && g.contains(&Vec2::new(5.0, 0.0)));
}
}
+15 -3
View File
@@ -165,9 +165,21 @@ fn bounds_in<'a>(d: &'a Drawing, e: &Entity, depth: usize, blocks: &mut HashMap<
if let Some(blk) = dm.block.as_ref().and_then(|n| d.block(n))
&& depth < MAX_BLOCK_DEPTH
{
for be in blk.entities.iter().filter(|be| in_block_visible(d, be)) {
b = b.union(&bounds_in(d, be, depth + 1, blocks));
}
// Reuse the shared block-bounds cache (as the Insert branch does) so a dimension
// block with branching or self-referencing children costs one pass per definition
// per depth, not one per reference (which grows exponentially) (CWE-400).
let inner = match blocks.get(&(blk.name.as_str(), depth)) {
Some(cached) => *cached,
None => {
let mut inner = Bounds2::EMPTY;
for be in blk.entities.iter().filter(|be| in_block_visible(d, be)) {
inner = inner.union(&bounds_in(d, be, depth + 1, blocks));
}
blocks.insert((blk.name.as_str(), depth), inner);
inner
}
};
b = b.union(&inner);
}
b
}
+3 -1
View File
@@ -142,12 +142,14 @@ impl Drawing {
d.header = Header::imperial();
d
}
/// An empty metric drawing (millimetres, ISO-25 dimensions).
/// An empty metric drawing (millimetres, ISO-25 dimensions, multileaders and tables to match).
pub fn new_metric() -> Self {
let mut d = Drawing::bare();
d.header = Header::metric();
d.scales = metric_scales();
d.dim_styles.push(DimStyle::iso25());
d.mleader_styles = vec![MLeaderStyle::metric()];
d.table_styles = vec![TableStyle::metric()];
d.header.set_str("DIMSTYLE", "ISO-25");
d
}
+21
View File
@@ -616,6 +616,13 @@ impl Default for MLeaderStyle {
}
}
impl MLeaderStyle {
/// The Standard style for metric drawings: text and arrows as high as ISO-25's.
pub fn metric() -> Self {
MLeaderStyle { arrow_size: 2.5, text_height: 2.5, landing_gap: 0.625, dogleg: 5.0, ..Default::default() }
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct TableStyle {
@@ -636,6 +643,13 @@ impl Default for TableStyle {
}
}
impl TableStyle {
/// The Standard style for metric drawings: text as high as ISO-25's.
pub fn metric() -> Self {
TableStyle { text_height: 2.5, margin: 1.0, ..Default::default() }
}
}
mod cellstyle;
mod tablebreak;
pub use cellstyle::*;
@@ -738,6 +752,8 @@ pub struct PageSetup {
pub landscape: bool,
/// "layout" | "extents" | "display" | "window" | "limits".
pub plot_area: String,
/// Plot window corners `[x1, y1, x2, y2]` (DXF plot layout groups 48, 49, 140, 141); used when `plot_area` is "window".
pub plot_window: [f64; 4],
pub scale_to_fit: bool,
/// Paper units per drawing unit.
pub scale: f64,
@@ -750,6 +766,9 @@ pub struct PageSetup {
/// Plot objects with their transparency ("Plot transparency"; PLOTTRANSPARENCYOVERRIDE can
/// override it). The DXF plot layout flags have no bit for it, so it is not saved in DXF.
pub plot_transparency: bool,
/// The layout's paper units are millimetres (plot paper units 1 in DXF) whatever the
/// drawing's MEASUREMENT says; otherwise paper units follow MEASUREMENT (#1058).
pub paper_mm: bool,
}
impl Default for PageSetup {
@@ -762,6 +781,7 @@ impl Default for PageSetup {
margins_mm: [6.35, 6.35, 6.35, 6.35],
landscape: true,
plot_area: "layout".into(),
plot_window: [0.0; 4],
scale_to_fit: false,
scale: 1.0,
plot_style_table: String::new(),
@@ -769,6 +789,7 @@ impl Default for PageSetup {
lineweights: true,
show_plot_styles: false,
plot_transparency: false,
paper_mm: false,
}
}
}
+17
View File
@@ -16,6 +16,23 @@ fn new_drawing_has_standard_tables() {
assert!(m.dim_style("ISO-25").is_some());
}
#[test]
fn new_metric_drawing_has_metric_multileader_and_table_styles() {
let m = Drawing::new_metric();
let iso = m.dim_style("ISO-25").unwrap();
let ml = m.mleader_styles.iter().find(|s| s.name == "Standard").unwrap();
assert_eq!((ml.text_height, ml.arrow_size), (iso.text_height, iso.arrow_size));
assert_eq!(ml.dogleg, 5.0);
let ts = m.table_styles.iter().find(|s| s.name == "Standard").unwrap();
assert_eq!(ts.text_height, iso.text_height);
assert_eq!(iso.text_height, 2.5);
let i = Drawing::new_imperial();
assert_eq!(i.mleader_styles, vec![MLeaderStyle::default()]);
assert_eq!(i.table_styles, vec![TableStyle::default()]);
assert_eq!((i.mleader_styles[0].text_height, i.table_styles[0].text_height), (0.18, 0.18));
}
#[test]
fn add_and_extents() {
let mut d = Drawing::new_imperial();
+2
View File
@@ -0,0 +1,2 @@
mod lin_pat_parse;
mod sub_segment_alias;
+1 -1
View File
@@ -10,4 +10,4 @@ workspace = true
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
# MPL-2.0: used as an unmodified dependency only (see plan/adr/0001 and NOTICE).
acadrust = "0.6.4"
acadrust = "0.6.5"
+392 -2
View File
@@ -4,6 +4,25 @@
//! converted to DXF bytes, which CADCraft's own DXF reader maps to its document model; saving
//! goes the other way. Keeping the dependency behind this byte-level API isolates it: nothing
//! else in CADCraft depends on its types. Native targets only (it memory-maps files).
//!
//! # Guardrails around the third-party decoder
//!
//! The decoder allocates tables and section buffers from sizes a (possibly damaged or hostile)
//! file declares, and once it starts it cannot be interrupted (an allocation failure aborts the
//! process, which `catch_unwind` cannot recover). Because the dependency is used unmodified, this
//! crate guards it from the outside:
//!
//! - Both directions reject oversized input *before* the decoder runs ([`Limits::max_dwg_bytes`]
//! for DWG→DXF, [`Limits::max_dxf_bytes`] for DXF→DWG), and the DWG→DXF output is capped while
//! it is written (`CappedWriter`).
//! - The reader and writer run inside `catch_unwind`, so a decode failure is reported instead of
//! crashing.
//!
//! These bound the input- and output-size variants. A small file that declares a huge internal
//! table or section can still make the dependency allocate before any size is known; fully fixing
//! that requires an upstream fix in `acadrust` (validate declared sizes against a decoded-memory
//! budget) or running the decode in a separate worker with an OS memory limit. The limits here
//! keep the blast radius bounded in the meantime.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
@@ -83,10 +102,16 @@ mod native {
let why = outcome.stats.diagnostics.first().map(|d| d.message.trim_start_matches("Failsafe: ").to_string());
return Err(format!("DWG: {}", why.unwrap_or_else(|| "the file's objects couldn't be read".to_string())));
}
let notes = read_notes(&outcome.stats);
let mut notes = read_notes(&outcome.stats);
let mut doc = outcome.document;
current_style_names(&mut doc);
table_merges(&mut doc);
table_style_rows(&mut doc, false);
let orphans = drop_orphaned_dgn_components(&mut doc);
if orphans > 0 {
let (s, were) = if orphans == 1 { ("", "was") } else { ("s", "were") };
notes.push(format!("DWG: {orphans} DGN line style component{s} that no line style definition uses {were} left out"));
}
let mut out = CappedWriter { buf: Vec::new(), limit: limits.max_dxf_bytes, exceeded: false };
let written = std::panic::catch_unwind(AssertUnwindSafe(|| acadrust::DxfWriter::new(&doc).write_to_writer(&mut out)))
.map_err(|_| "the DWG→DXF conversion failed on this file".to_string())?;
@@ -265,6 +290,73 @@ mod native {
}
}
/// The DWG dimension flags byte. Its bit 1 is DXF group 70's bit 32 (ODA DWG specification,
/// common dimension data), which acadrust keeps no copy of from DXF: it is set here as
/// CADCraft's DXF writer always sets it (#1094). Bit 0, the opposite of group 70's bit 128,
/// acadrust 0.6.5 reads and writes itself.
fn dimension_flags(doc: &mut acadrust::CadDocument) {
for e in doc.entities_mut() {
let acadrust::entities::EntityType::Dimension(d) = e else { continue };
d.base_mut().dwg_flags_byte |= 0x02;
}
}
/// Leave out DGN line style components (`LS*COMPONENT`) that no line style definition uses
/// (#1482). A drawing that once came from MicroStation can carry hundreds of thousands of
/// them, orphaned copies piled up by earlier DGN imports and copy-pastes (AutoCAD's PURGE
/// removes them as orphaned data): in a real 83 MB site plan they were 888,483 of 889,053
/// components and made its DXF rendition 1.2 GB, over [`Limits::DEFAULT`], so the file
/// didn't open at all. No definition that was read refers to them (a definition the reader
/// skipped can't use them either) and CADCraft doesn't draw DGN line styles, so nothing
/// visible is lost.
/// Every component a definition reaches (directly, or through compound and point
/// components) is kept, as are the definitions; dictionary entries that named a dropped
/// component go with it. Returns how many were left out.
fn drop_orphaned_dgn_components(doc: &mut acadrust::CadDocument) -> usize {
use acadrust::objects::{DgnLineStyleData, ObjectType};
use std::collections::{HashMap, HashSet};
// Each component's references, and the definitions' root components to start from.
let mut refs: HashMap<acadrust::Handle, Vec<acadrust::Handle>> = HashMap::new();
let mut stack: Vec<acadrust::Handle> = Vec::new();
for (h, o) in &doc.objects {
let ObjectType::DgnLineStyle(ls) = o else { continue };
match &ls.data {
DgnLineStyleData::Definition { root_component, .. } => stack.push(*root_component),
DgnLineStyleData::Component { component, .. } => {
refs.insert(*h, component.references());
}
DgnLineStyleData::Registered { .. } => {}
}
}
if refs.is_empty() {
return 0;
}
// Each component is visited once, so cycles and shared subtrees end.
let mut used: HashSet<acadrust::Handle> = HashSet::new();
while let Some(h) = stack.pop() {
if let Some(r) = refs.get(&h)
&& used.insert(h)
{
stack.extend(r.iter().copied());
}
}
let orphans: HashSet<acadrust::Handle> = refs.into_keys().filter(|h| !used.contains(h)).collect();
if orphans.is_empty() {
return 0;
}
doc.objects.retain(|h, _| !orphans.contains(h));
doc.dgn_ls_components.retain(|h, _| !orphans.contains(h));
for o in doc.objects.values_mut() {
let ObjectType::Dictionary(d) = o else { continue };
if d.entries.iter().any(|(_, e)| orphans.contains(e)) {
d.entries.retain(|(_, e)| !orphans.contains(e));
let keys: HashSet<&str> = d.entries.iter().map(|(k, _)| k.as_str()).collect();
d.hard_owner_entries.retain(|k| keys.contains(k.as_str()));
}
}
orphans.len()
}
/// Leave out what acadrust can't write as this DWG version: other programs' custom entities
/// and objects without raw DWG data of that version. acadrust 0.6.4 refuses the whole file
/// for one of them (0.6.3 left them out); CADCraft already tells the user which entity types
@@ -307,7 +399,8 @@ mod native {
/// the HEADER section: (name, group code, value).
fn unwritten_header_vars(h: &acadrust::document::HeaderVariables) -> [(&'static str, i32, String); 7] {
[
("$ANGBASE", 50, h.angle_base.to_string()),
// DWG holds radians; `$ANGBASE` is in degrees.
("$ANGBASE", 50, h.angle_base.to_degrees().to_string()),
("$ANGDIR", 70, h.angle_direction.to_string()),
("$FILLETRAD", 40, h.fillet_radius.to_string()),
("$CHAMFERA", 40, h.chamfer_distance_a.to_string()),
@@ -385,7 +478,52 @@ mod native {
convert(dxf, Some(v))
}
/// The current dimension and text styles' names from their handles: a DWG stores the
/// handles, and the reader leaves the names at "Standard", so a drawing reopened from DWG
/// lost its current dimension style (#1051).
fn current_style_names(doc: &mut acadrust::CadDocument) {
let h = doc.header.current_dimstyle_handle;
if let Some(n) = doc.dim_styles.iter().find(|ds| ds.handle == h && !h.is_null()).map(|ds| ds.name.clone()) {
doc.header.current_dimstyle_name = n;
}
let h = doc.header.current_text_style_handle;
if let Some(n) = doc.text_styles.iter().find(|ts| ts.handle == h && !h.is_null()).map(|ts| ts.name.clone()) {
doc.header.current_text_style_name = n;
}
}
/// The current dimension style (`$DIMSTYLE`) by its name: the DXF gives the name, and the
/// handle read with it needn't be the style's in the document, in which case the DWG writer
/// fell back to Standard and AutoCAD opened the drawing with Standard current (#1051).
fn current_dimstyle(doc: &mut acadrust::CadDocument) {
let name = doc.header.current_dimstyle_name.clone();
if let Some(h) = doc.dim_styles.iter().find(|ds| ds.name.eq_ignore_ascii_case(&name)).map(|ds| ds.handle) {
doc.header.current_dimstyle_handle = h;
}
}
/// The current text style (`$TEXTSTYLE`) by its name, the same way as [`current_dimstyle`]:
/// the handle read with the DXF needn't be the style's in the document, in which case the DWG
/// writer fell back to Standard and AutoCAD opened the drawing with Standard current (#1650).
fn current_text_style(doc: &mut acadrust::CadDocument) {
let name = doc.header.current_text_style_name.clone();
if let Some(h) = doc.text_styles.iter().find(|ts| ts.name.eq_ignore_ascii_case(&name)).map(|ts| ts.handle) {
doc.header.current_text_style_handle = h;
}
}
fn convert(dxf: &[u8], version: Option<acadrust::DxfVersion>) -> Result<Vec<u8>, String> {
// Reject a pathologically large DXF before handing it to the third-party decoder, which
// allocates tables and section buffers from the input's declared sizes and cannot be
// interrupted once it starts (CWE-789). This bounds the large-input variant; the decoder
// runs inside `catch_unwind` so a decode failure is reported rather than crashing.
if dxf.len() > Limits::DEFAULT.max_dxf_bytes {
return Err(format!(
"DXF: the input is {}, larger than the {} CADCraft converts to DWG",
mb(dxf.len() as u64),
mb(Limits::DEFAULT.max_dxf_bytes as u64)
));
}
let data = dxf.to_vec();
let mut doc = std::panic::catch_unwind(move || acadrust::DxfReader::from_reader(Cursor::new(data)).and_then(|r| r.read()))
.map_err(|_| "the DXF→DWG conversion failed".to_string())?
@@ -398,12 +536,41 @@ mod native {
None if doc.version < MIN_DWG_VERSION => doc.version = MIN_DWG_VERSION,
None => {}
}
other_layout_owners(&mut doc);
table_style_rows(&mut doc, true);
dimension_flags(&mut doc);
drop_unwritable(&mut doc);
current_dimstyle(&mut doc);
current_text_style(&mut doc);
std::panic::catch_unwind(AssertUnwindSafe(|| acadrust::DwgWriter::write_to_vec(&doc)))
.map_err(|_| "the DWG writer failed on this drawing".to_string())?
.map_err(|e| format!("DWG write: {e}"))
}
/// Entities of a layout other than the one in `*Paper_Space` are written owned by their block
/// (entity mode 0, with its handle). acadrust writes every paper-space entity with mode 1,
/// "paper space", which AutoCAD reads as the `*Paper_Space` block's: everything drawn on a
/// second layout landed on the first one, and the second stayed empty (#1057).
fn other_layout_owners(doc: &mut acadrust::CadDocument) {
let others: std::collections::HashSet<acadrust::Handle> = doc
.block_records
.iter()
.filter(|br| {
let n = br.name.to_ascii_uppercase();
n.len() > 12 && n.starts_with("*PAPER_SPACE") && n[12..].bytes().all(|b| b.is_ascii_digit())
})
.map(|br| br.handle)
.collect();
if others.is_empty() {
return;
}
for e in doc.entities_mut() {
let c = e.common_mut();
if others.contains(&c.owner_handle) {
c.entity_mode = Some(0);
}
}
}
}
#[cfg(not(target_arch = "wasm32"))]
@@ -448,9 +615,232 @@ mod tests {
assert_eq!(version(b"AC1018xxxx").as_deref(), Some("AC1018"));
}
/// A DXF with ROMANS as `$TEXTSTYLE` gives a DWG whose current text style is ROMANS, not
/// Standard: the DXF names the style, and the handle read with it was Standard's (#1650).
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn current_text_style_reaches_the_dwg() {
let header = " 0\nSECTION\n 2\nHEADER\n 9\n$ACADVER\n 1\nAC1032\n 9\n$TEXTSTYLE\n 7\nROMANS\n 0\nENDSEC\n";
let style = " 0\nSTYLE\n 5\n1F0\n100\nAcDbSymbolTableRecord\n100\nAcDbTextStyleTableRecord\n 2\nROMANS\n 70\n0\n";
let style = format!("{style} 40\n0\n 41\n1\n 50\n0\n 71\n0\n 42\n2.5\n 3\nromans.shx\n 4\n\n");
let dxf = format!("{header} 0\nSECTION\n 2\nTABLES\n 0\nTABLE\n 2\nSTYLE\n 70\n1\n{style} 0\nENDTAB\n 0\nENDSEC\n 0\nEOF\n");
let dwg = dxf_to_dwg(dxf.as_bytes()).unwrap();
let doc = acadrust::DwgReader::from_stream(std::io::Cursor::new(&dwg)).read().unwrap();
let romans = doc.text_styles.iter().find(|ts| ts.name == "ROMANS").map(|ts| ts.handle);
assert!(romans.is_some(), "ROMANS is missing from the DWG");
assert_eq!(Some(doc.header.current_text_style_handle), romans);
}
#[test]
fn garbage_is_an_error_not_a_crash() {
assert!(dwg_to_dxf(b"AC1032 this is not a dwg file at all").is_err());
assert!(dwg_to_dxf(b"").is_err());
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn dxf_to_dwg_rejects_garbage_without_crashing() {
// The DXF→DWG path runs the third-party reader inside catch_unwind and reports failures
// rather than crashing (and rejects oversized input before decoding).
assert!(dxf_to_dwg(b"not a dxf file").is_err());
assert!(dxf_to_dwg(b"").is_err());
}
/// A DXF with one DIMENSION per (text, group 70, subclass, points) row.
#[cfg(not(target_arch = "wasm32"))]
fn dims_dxf(dims: &[(&str, i64, &str, &[(i32, [f64; 2])])]) -> String {
let mut s = String::from(" 0\nSECTION\n 2\nHEADER\n 9\n$ACADVER\n 1\nAC1032\n 0\nENDSEC\n 0\nSECTION\n 2\nENTITIES\n");
for (text, flags, sub, pts) in dims {
s += " 0\nDIMENSION\n 8\n0\n100\nAcDbEntity\n100\nAcDbDimension\n 10\n0\n 20\n0\n 30\n0\n";
s += &format!(" 11\n50\n 21\n-20\n 31\n0\n 70\n{flags}\n 1\n{text}\n 3\nStandard\n100\n{sub}\n");
for (code, [x, y]) in pts.iter() {
s += &format!("{code:>3}\n{x}\n{:>3}\n{y}\n{:>3}\n0\n", code + 10, code + 20);
}
}
s + " 0\nENDSEC\n 0\nEOF\n"
}
/// DWG stores DXF group 70's bit 128 inverted, in bit 0 of the dimension flags byte (set:
/// the text is at its default position), and bit 32 in bit 1 (ODA DWG specification, common
/// dimension data). The bytes are read back raw, so a reader that inverts bit 0 the same way
/// as the writer can't hide a wrong byte.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn dimension_text_position_flags_reach_the_dwg_as_autocad_reads_them() {
let linear: &[(i32, [f64; 2])] = &[(13, [0.0, 0.0]), (14, [100.0, 0.0])];
let radius: &[(i32, [f64; 2])] = &[(15, [10.0, 0.0])];
let angular: &[(i32, [f64; 2])] = &[(13, [0.0, 0.0]), (14, [10.0, 0.0]), (15, [0.0, 0.0]), (16, [0.0, 10.0])];
let kinds = [
(0, "AcDbAlignedDimension", linear),
(1, "AcDbAlignedDimension", linear),
(4, "AcDbRadialDimension", radius),
(2, "AcDb2LineAngularDimension", angular),
];
let mut dims = Vec::new();
for (ty, sub, pts) in kinds {
dims.push(("HOME", ty | 32, sub, pts));
dims.push(("MOVED", ty | 32 | 128, sub, pts));
}
let dwg = dxf_to_dwg(dims_dxf(&dims).as_bytes()).unwrap();
let doc = acadrust::DwgReader::from_stream(std::io::Cursor::new(&dwg)).read().unwrap();
let mut seen = Vec::new();
for e in doc.entities() {
let acadrust::entities::EntityType::Dimension(d) = e else { continue };
let (text, byte) = (d.base().text_override().unwrap_or_default(), d.base().dwg_flags_byte);
seen.push((text.to_string(), byte & 0x03));
assert_eq!(byte & 0x02, 0x02, "{text} {:?}: bit 32 of group 70 is lost (flags byte {byte:#04x})", d.base().dimension_type);
let default_pos = text == "HOME";
assert_eq!(byte & 0x01 != 0, default_pos, "{text} {:?}: flags byte {byte:#04x}", d.base().dimension_type);
}
for text in ["HOME", "MOVED"] {
assert_eq!(seen.iter().filter(|(t, _)| t == text).count(), 4, "{seen:?}");
}
// And CADCraft reads its own DWG back as it wrote it: 128 only on the moved texts.
let dxf = String::from_utf8(dwg_to_dxf(&dwg).unwrap()).unwrap();
let lines: Vec<&str> = dxf.lines().map(str::trim).collect();
let mut flags = Vec::new();
for rec in lines.chunks(2).collect::<Vec<_>>().split(|p| p.first() == Some(&"0")) {
let value = |code: &str| rec.iter().find(|p| p.first() == Some(&code)).and_then(|p| p.get(1).copied());
if let (Some(text @ ("HOME" | "MOVED")), Some(v)) = (value("1"), value("70")) {
flags.push((text, v.parse::<i64>().unwrap_or(-1) & 128));
}
}
assert_eq!(flags.len(), 8, "{flags:?}");
assert!(flags.iter().all(|(l, f)| (*f == 128) == (*l == "MOVED")), "{flags:?}");
}
/// The handles a DXF text refers to or gives its records (groups 5, 105 and 330–369).
#[cfg(not(target_arch = "wasm32"))]
fn dxf_handles(dxf: &str) -> std::collections::HashSet<u64> {
let lines: Vec<&str> = dxf.lines().collect();
lines
.chunks(2)
.filter_map(|p| match p {
[c, v] if matches!(c.trim().parse::<i32>(), Ok(5 | 105 | 330..=369)) => u64::from_str_radix(v.trim(), 16).ok(),
_ => None,
})
.collect()
}
/// Issue #1482: a DWG that once came from MicroStation can carry hundreds of thousands of DGN
/// line style components no definition uses (a real 83 MB site plan: 888,483 of 889,053, a
/// 1.2 GB DXF rendition over the limit, so it didn't open). They're left out with a note, and
/// their dictionary entries with them; the definition and every component it reaches —
/// through compound and point components, around a cycle — are kept.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn orphaned_dgn_line_style_components_are_left_out() {
use acadrust::objects::*;
let mut doc = acadrust::CadDocument::new();
let container = doc.allocate_handle();
let nod = doc.header.named_objects_dict_handle;
let Some(ObjectType::Dictionary(root)) = doc.objects.get_mut(&nod) else { panic!("no named object dictionary") };
root.entries.push(("ACAD_DGNLINESTYLECOMP".into(), container));
let pattern = || DgnLsStrokePattern {
has_iteration_limit: false,
is_single_segment: false,
iteration_limit: 0,
auto_phase: 0.0,
phase: 0.0,
phase_mode: DgnLsPhaseMode::Distance,
strokes: vec![DgnLsStroke {
is_dash: true,
bypass_corner: false,
can_be_scaled: true,
invert_at_origin: false,
invert_at_end: false,
length: 2.5,
start_width: 0.0,
end_width: 0.0,
width_mode: 0,
cap_mode: 0,
}],
};
let kind = |c: &DgnLsComponentData| match c {
DgnLsComponentData::Symbol(_) => DgnLsComponentType::Symbol,
DgnLsComponentData::Compound(_) => DgnLsComponentType::Compound,
DgnLsComponentData::Stroke(_) => DgnLsComponentType::Stroke,
DgnLsComponentData::Point(_) => DgnLsComponentType::Point,
DgnLsComponentData::Internal(_) => DgnLsComponentType::Internal,
};
let compound = |of: &[acadrust::Handle]| {
DgnLsComponentData::Compound(DgnLsCompoundComponent {
entries: of.iter().map(|&component| DgnLsCompoundEntry { component, offset: 0.0 }).collect(),
})
};
let mut entries = Vec::new();
let mut put = |doc: &mut acadrust::CadDocument, h: acadrust::Handle, data: DgnLineStyleData| {
entries.push((format!("{:X}", h.value()), h));
let reactors = vec![container];
doc.objects
.insert(h, ObjectType::DgnLineStyle(DgnLineStyleObject { handle: h, owner: container, reactors, xdictionary_handle: None, data }));
};
let component = |c: DgnLsComponentData| DgnLineStyleData::Component {
kind: kind(&c),
description: "dash".into(),
version: 1,
component_uid: [7; 16],
scale: 1.0,
property_flags: 0,
component: c,
properties: vec![],
};
let h: Vec<acadrust::Handle> = (0..9).map(|_| doc.allocate_handle()).collect();
let [def, root, stroke, point, back, orphan, orphan_cycle, internal, shared] = h[..] else { panic!("nine handles") };
put(
&mut doc,
def,
DgnLineStyleData::Definition {
description: "{ dash }".into(),
version: 1,
style_number: 1,
component_uid: [1; 16],
is_continuous: false,
unit_definition: 1.0,
unit_scale: 1.0,
units_type: 0,
is_element: false,
is_physical: false,
is_scale_independent: false,
is_snappable: false,
root_component: root,
properties: vec![],
},
);
// Used: def → root → {stroke, point → shared, back → root (a cycle)}.
put(&mut doc, root, component(compound(&[stroke, point, back])));
put(&mut doc, stroke, component(DgnLsComponentData::Stroke(pattern())));
put(&mut doc, point, component(DgnLsComponentData::Point(DgnLsPointComponent { stroke_component: shared, symbols: vec![] })));
put(&mut doc, back, component(compound(&[root])));
put(&mut doc, shared, component(DgnLsComponentData::Stroke(pattern())));
// Orphans: an internal component, and two compounds that refer to each other and to a
// used component (which stays).
let pattern_internal =
DgnLsInternalComponent { pattern: pattern(), internal_version: 0, hardware_style: 0, is_hardware_style: false, line_code: 0 };
put(&mut doc, internal, component(DgnLsComponentData::Internal(pattern_internal)));
put(&mut doc, orphan, component(compound(&[orphan_cycle, shared])));
put(&mut doc, orphan_cycle, component(compound(&[orphan])));
let mut dict = Dictionary::new();
dict.handle = container;
dict.owner = nod;
dict.entries = entries;
doc.objects.insert(container, ObjectType::Dictionary(dict));
let dwg = acadrust::DwgWriter::write_to_vec(&doc).unwrap();
let (dxf, notes) = dwg_to_dxf_noted(&dwg, Limits::DEFAULT).unwrap();
let dxf = String::from_utf8(dxf).unwrap();
let handles = dxf_handles(&dxf);
for kept in [def, root, stroke, point, back, shared] {
assert!(handles.contains(&kept.value()), "{:X} was left out", kept.value());
}
for gone in [internal, orphan, orphan_cycle] {
assert!(!handles.contains(&gone.value()), "{:X} was kept or is still referred to", gone.value());
}
let lines: Vec<&str> = dxf.lines().collect();
let ls: Vec<&str> = lines.chunks(2).filter_map(|p| p.get(1).filter(|_| p[0].trim() == "0" && p[1].starts_with("LS")).copied()).collect();
assert_eq!(ls.iter().filter(|t| t.ends_with("COMPONENT")).count(), 5, "{ls:?}");
assert!(ls.contains(&"LSDEFINITION") && !ls.contains(&"LSINTERNALCOMPONENT"), "{ls:?}");
assert_eq!(notes, ["DWG: 3 DGN line style components that no line style definition uses were left out"]);
}
}
+5 -1
View File
@@ -16,12 +16,16 @@ cadcraft-dxf = { workspace = true }
cadcraft-constraints = { workspace = true }
cadcraft-fonts = { workspace = true }
cadcraft-render = { workspace = true }
craft-docpath = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
log = { workspace = true }
# R-tree spatial index for picking, window selection and object snaps (MIT OR Apache-2.0).
rstar = { version = "0.12", default-features = false }
rstar = { version = "0.13", default-features = false }
[target.'cfg(unix)'.dependencies]
libc = "0.2"
[dev-dependencies]
# Real DXF files for the external reference tests (io is a lower layer).
+163
View File
@@ -0,0 +1,163 @@
//! Safe, atomic file replacement.
//!
//! Save, export, plot, autosave and the audit log all write a file by first putting the bytes in
//! a sibling temporary file and then renaming it over the destination, so a failed or partial
//! write never truncates the real file. The temporary file must be created *exclusively*: opening
//! a predictable, pre-existing temporary path for a truncating write lets another local user plant
//! a symlink there and redirect the write onto a file the victim can write but the attacker chose
//! (CWE-59). [`write_atomic`] creates a fresh temporary file with `create_new` (`O_CREAT|O_EXCL`),
//! so an existing entry at that path — symlink or not — makes the create fail rather than follow
//! it. The handle is retained for the whole write, and only its own fresh file is renamed into
//! place.
#![cfg(not(target_arch = "wasm32"))]
use std::fs::{File, OpenOptions, Permissions};
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
/// Monotonic counter making temporary names unique within this process without a RNG dependency.
static SEQ: AtomicU64 = AtomicU64::new(0);
/// How many fresh temporary names to try before giving up (each collision is another local race).
const MAX_TRIES: u32 = 32;
/// Write `bytes` to `path`, replacing it atomically and without ever following a pre-existing
/// temporary symlink.
///
/// A fresh temporary file is created with exclusive semantics in `path`'s own directory, the bytes
/// are written through the retained handle and flushed, and the temporary is then renamed over
/// `path`. On any failure the temporary is removed and the original `path` is left untouched.
pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
let permissions = match std::fs::symlink_metadata(path) {
Ok(meta) if meta.is_file() => Some(meta.permissions()),
Ok(_) => None,
Err(e) if e.kind() == io::ErrorKind::NotFound => None,
Err(e) => return Err(e),
};
let dir = path.parent().filter(|p| !p.as_os_str().is_empty()).map_or_else(|| PathBuf::from("."), Path::to_path_buf);
let (mut file, tmp) = create_temp(&dir, permissions.as_ref())?;
let write = (|| {
if let Some(permissions) = permissions {
file.set_permissions(permissions)?;
}
file.write_all(bytes)?;
file.flush()?;
file.sync_all()
})();
if let Err(e) = write {
let _ = std::fs::remove_file(&tmp);
return Err(e);
}
drop(file);
if let Err(e) = std::fs::rename(&tmp, path) {
let _ = std::fs::remove_file(&tmp);
return Err(e);
}
Ok(())
}
/// Create a fresh, exclusively-owned temporary file inside `dir`, returning its handle and path.
///
/// `create_new(true)` opens with `O_CREAT | O_EXCL`, which refuses to follow or reuse an existing
/// entry (including a symlink), closing the plant-a-symlink race. Names are unpredictable enough to
/// avoid collisions in practice, and genuine collisions are retried with a new name.
fn create_temp(dir: &Path, permissions: Option<&Permissions>) -> io::Result<(File, PathBuf)> {
let pid = std::process::id();
let mut last_err = None;
for _ in 0..MAX_TRIES {
let seq = SEQ.fetch_add(1, Ordering::Relaxed);
let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.subsec_nanos()).unwrap_or(0);
let tmp = dir.join(format!(".cadcraft-tmp-{pid}-{seq}-{nanos}"));
match create_exclusive(&tmp, permissions) {
Ok(file) => return Ok((file, tmp)),
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {
last_err = Some(e);
continue;
}
Err(e) => return Err(e),
}
}
Err(last_err.unwrap_or_else(|| io::Error::new(io::ErrorKind::AlreadyExists, "could not create a unique temporary file")))
}
fn create_exclusive(path: &Path, permissions: Option<&Permissions>) -> io::Result<File> {
let mut options = OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
if let Some(permissions) = permissions {
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
// Restrict access in the create syscall: chmod afterwards leaves a window in which a
// different user can open the inode and retain read access to bytes written later.
options.mode(permissions.mode() & 0o777);
}
#[cfg(not(unix))]
let _ = permissions;
options.open(path)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn replaces_contents_atomically() {
let dir = std::env::temp_dir().join(format!("cadcraft-aw-{}-{}", std::process::id(), SEQ.fetch_add(1, Ordering::Relaxed)));
std::fs::create_dir_all(&dir).unwrap();
let dest = dir.join("out.bin");
std::fs::write(&dest, b"old").unwrap();
write_atomic(&dest, b"new bytes").unwrap();
assert_eq!(std::fs::read(&dest).unwrap(), b"new bytes");
// No temporary files left behind.
let leftovers: Vec<_> =
std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.file_name()).filter(|n| n.to_string_lossy().starts_with(".cadcraft-tmp")).collect();
assert!(leftovers.is_empty(), "left temp files: {leftovers:?}");
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(unix)]
#[test]
fn does_not_follow_a_planted_temp_symlink() {
use std::os::unix::fs::symlink;
let dir = std::env::temp_dir().join(format!("cadcraft-aw-sym-{}-{}", std::process::id(), SEQ.fetch_add(1, Ordering::Relaxed)));
std::fs::create_dir_all(&dir).unwrap();
let victim = dir.join("victim");
std::fs::write(&victim, b"precious").unwrap();
let planted = dir.join(".cadcraft-tmp-plant");
symlink(&victim, &planted).unwrap();
assert_eq!(create_exclusive(&planted, None).unwrap_err().kind(), io::ErrorKind::AlreadyExists);
let dest = dir.join("drawing.dxf");
write_atomic(&dest, b"drawing bytes").unwrap();
assert_eq!(std::fs::read(&victim).unwrap(), b"precious", "victim file was overwritten");
assert_eq!(std::fs::read(&dest).unwrap(), b"drawing bytes");
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(unix)]
#[test]
fn replacement_preserves_private_permissions() {
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join(format!("cadcraft-aw-mode-{}-{}", std::process::id(), SEQ.fetch_add(1, Ordering::Relaxed)));
std::fs::create_dir_all(&dir).unwrap();
let dest = dir.join("private.adt");
std::fs::write(&dest, b"old").unwrap();
std::fs::set_permissions(&dest, std::fs::Permissions::from_mode(0o600)).unwrap();
write_atomic(&dest, b"private report").unwrap();
assert_eq!(std::fs::metadata(&dest).unwrap().permissions().mode() & 0o777, 0o600);
assert_eq!(std::fs::read(&dest).unwrap(), b"private report");
std::fs::remove_dir_all(dir).unwrap();
}
#[cfg(unix)]
#[test]
fn private_temp_is_restricted_from_creation() {
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join(format!("cadcraft-aw-create-mode-{}-{}", std::process::id(), SEQ.fetch_add(1, Ordering::Relaxed)));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("private-temp");
let permissions = Permissions::from_mode(0o600);
let file = create_exclusive(&path, Some(&permissions)).unwrap();
assert_eq!(file.metadata().unwrap().permissions().mode() & 0o077, 0);
drop(file);
std::fs::remove_dir_all(dir).unwrap();
}
}
+6
View File
@@ -82,6 +82,12 @@ impl Report {
"message": self.message(),
})
}
/// Format this report for an AUDITCTL `.adt` log file.
pub fn format_adt(&self, title: &str, path: Option<&str>) -> String {
let name = path.unwrap_or(title);
format!("Audit of drawing: {name}\n\n{}\n", self.message())
}
}
/// Audit `d` without changing it.
+3 -3
View File
@@ -339,9 +339,9 @@ mod native {
if let Some(dir) = Path::new(path).parent() {
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
}
let tmp = format!("{path}.tmp");
std::fs::write(&tmp, bytes).map_err(|e| e.to_string())?;
std::fs::rename(&tmp, path).map_err(|e| e.to_string())
// Create a fresh temporary file exclusively and rename it into place, so a planted
// `<path>.tmp` symlink in a shared autosave directory cannot redirect the write (CWE-59).
crate::atomic_write::write_atomic(Path::new(path), &bytes).map_err(|e| e.to_string())
}
impl Autosaver {
+84
View File
@@ -0,0 +1,84 @@
//! Bounded reads of local files.
//!
//! Reading a path with [`std::fs::read`] buffers until EOF, so a path naming an endless readable
//! device (for example `/dev/urandom` or a FIFO) exhausts process memory before any size check
//! runs (CWE-400). [`read_capped`] opens the path once, refuses anything that is not a regular
//! file, and reads through a reader limited to `max + 1` bytes so oversized input is rejected
//! before it is fully buffered.
#![cfg(not(target_arch = "wasm32"))]
use std::fs::OpenOptions;
use std::io::{self, Read};
use std::path::Path;
/// Read at most `max` bytes from the regular file at `path`.
///
/// Returns an error if the path is not a regular file, cannot be opened, or holds more than `max`
/// bytes. At most `max + 1` bytes are ever buffered, so an endless device cannot exhaust memory.
pub fn read_capped(path: &Path, max: usize) -> io::Result<Vec<u8>> {
let mut options = OpenOptions::new();
options.read(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.custom_flags(libc::O_NONBLOCK);
}
let file = options.open(path)?;
let meta = file.metadata()?;
if !meta.is_file() {
return Err(io::Error::new(io::ErrorKind::InvalidInput, "not a regular file"));
}
// Read one byte past the limit so we can tell "exactly max" from "too big" without trusting
// the metadata length (which a device may misreport).
let mut buf = Vec::new();
let cap = (max as u64).saturating_add(1);
let read = file.take(cap).read_to_end(&mut buf)?;
if read as u64 > max as u64 {
return Err(io::Error::new(io::ErrorKind::InvalidData, format!("file is larger than the {max}-byte limit")));
}
Ok(buf)
}
#[cfg(test)]
mod tests {
use super::*;
/// Regular-file reads accept the byte boundary and reject any larger payload.
#[test]
fn reads_within_limit_and_rejects_oversize() {
let dir = std::env::temp_dir().join(format!("cadcraft-cr-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let p = dir.join("f.bin");
std::fs::write(&p, vec![7u8; 100]).unwrap();
assert_eq!(read_capped(&p, 100).unwrap().len(), 100);
assert_eq!(read_capped(&p, 200).unwrap().len(), 100);
assert!(read_capped(&p, 99).is_err());
assert_eq!(read_capped(&p, usize::MAX).unwrap().len(), 100);
std::fs::remove_dir_all(&dir).ok();
}
/// An endless device is rejected before its contents can be buffered.
#[cfg(unix)]
#[test]
fn rejects_non_regular_file() {
// /dev/zero is an endless readable device; it must be rejected, not buffered.
let p = Path::new("/dev/zero");
if p.exists() {
assert!(read_capped(p, 8 << 20).is_err());
}
}
/// Opening a FIFO without a writer returns an error without waiting for a peer.
#[cfg(unix)]
#[test]
fn rejects_fifo_without_a_writer() {
let dir = std::env::temp_dir().join(format!("cadcraft-cr-fifo-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let fifo = dir.join("pipe");
assert!(std::process::Command::new("mkfifo").arg(&fifo).status().unwrap().success());
let started = std::time::Instant::now();
assert_eq!(read_capped(&fifo, 1024).unwrap_err().kind(), io::ErrorKind::InvalidInput);
assert!(started.elapsed() < std::time::Duration::from_secs(2));
std::fs::remove_dir_all(dir).unwrap();
}
}
+12 -14
View File
@@ -260,18 +260,16 @@ fn radial(s: &mut Session, p: &Value, diameter: bool) -> Result<Value> {
dim(s, DimKind::Radius, c, Vec2::ZERO, Vec2::ZERO, pt, Vec2::ZERO, "")
};
let k = match at {
Some(at) => text_inside(k, c, c.dist(pt), at),
Some(at) => text_at(k, at),
None => k,
};
Ok(json!({ "handle": add_dim(s, k)?.hex() }))
}
/// A radius/diameter dimension line location picked inside the circle puts the text there, on
/// the dimension line (outside, the text keeps its default place beyond the arrowhead).
fn text_inside(mut k: EntityKind, c: Vec2, r: f64, at: Vec2) -> EntityKind {
if let EntityKind::Dimension(d) = &mut k
&& at.dist(c) < r
{
/// The dimension line location of a radius/diameter dimension puts the text there, inside or
/// outside the circle, as a user-positioned text (DXF flag 70 bit 128), like AutoCAD.
fn text_at(mut k: EntityKind, at: Vec2) -> EntityKind {
if let EntityKind::Dimension(d) = &mut k {
d.text_mid = v3(at);
d.user_text_pos = true;
}
@@ -429,13 +427,13 @@ fn mleader_kind(s: &Session, pts: &[Vec2], text: &str, landing: bool) -> Option<
let d = s.doc().ok()?;
let cur = d.header.str("CMLEADERSTYLE", "Standard");
let st = d.mleader_styles.iter().find(|m| m.name.eq_ignore_ascii_case(&cur)).or(d.mleader_styles.first()).cloned().unwrap_or_default();
// An annotative style's sizes are paper sizes: the annotation scale applies when drawn.
let k = if st.annotative { 1.0 } else { d.header.f64("DIMSCALE", 1.0) };
let th = st.text_height * k;
// The style's sizes are the drawn sizes: a multileader style has no scale of its own and
// DIMSCALE (a dimension-style setting) does not apply, as in AutoCAD.
let th = st.text_height;
let dir = if arrow_pts.first().is_some_and(|a| a.x > landing_pt.x) { -1.0 } else { 1.0 };
let attach = if dir > 0.0 { 4 } else { 6 };
let dogleg = if landing { st.dogleg * k } else { 0.0 };
let tpos = *landing_pt + Vec2::new((dogleg + st.landing_gap * k) * dir, 0.0);
let dogleg = if landing { st.dogleg } else { 0.0 };
let tpos = *landing_pt + Vec2::new((dogleg + st.landing_gap) * dir, 0.0);
Some(EntityKind::MLeader(MLeader {
leaders: vec![arrow_pts.iter().map(|p| v3(*p)).collect()],
landing: v3(*landing_pt),
@@ -453,7 +451,7 @@ fn mleader_kind(s: &Session, pts: &[Vec2], text: &str, landing: bool) -> Option<
columns: None,
}),
style: st.name.clone(),
arrow_size: st.arrow_size * k,
arrow_size: st.arrow_size,
spline: false,
block: None,
}))
@@ -1106,7 +1104,7 @@ impl RadialM {
} else {
dim(s, DimKind::Radius, c, Vec2::ZERO, Vec2::ZERO, pt, Vec2::ZERO, "")
});
text_inside(k, c, r, at)
text_at(k, at)
}
}
+1 -1
View File
@@ -626,7 +626,7 @@ impl Interactive for RectM {
SpaceRows => dist("Specify the distance between rows", self.rs),
UnitCell => match self.corner {
None => Prompt::new("Specify first corner of the unit cell", Accept::POINT).base(self.base),
Some(c) => Prompt::new("Specify other corner", Accept::POINT).base(c),
Some(c) => Prompt::new("Specify other corner", Accept::POINT).corner(c),
},
ColsSpace => dist("Specify the distance between columns", self.cs).kw(&["Total"]),
ColsTotal => dist("Specify the distance between the first and last column", self.cs * self.cols.saturating_sub(1) as f64),
+29 -1
View File
@@ -84,7 +84,11 @@ fn run_audit(s: &mut Session, p: &Value) -> Result<Value> {
if fix && !issues.is_empty() {
s.state_mut()?.doc = Arc::new(copy);
}
Ok(crate::audit::Report { issues, fixed: fix }.to_json())
let report = crate::audit::Report { issues, fixed: fix };
if s.settings.auditctl {
write_audit_log(s, &report);
}
Ok(report.to_json())
}
/// Open a drawing file as tolerantly as the readers allow, then fix what AUDIT finds. The
@@ -119,12 +123,36 @@ fn run_recover(s: &mut Session, p: &Value) -> Result<Value> {
st.saved = Arc::new((*st.doc).clone());
}
let uid = st.uid;
if s.settings.auditctl {
write_audit_log(s, &report);
}
let mut r = report.to_json();
r["index"] = json!(i);
r["uid"] = json!(uid);
Ok(r)
}
#[cfg(not(target_arch = "wasm32"))]
fn write_audit_log(s: &Session, report: &crate::audit::Report) {
let (path_str, title) = match s.state() {
Ok(st) => (st.path.clone(), st.title.clone()),
Err(_) => (None, "Drawing".to_string()),
};
// An unsaved drawing has no folder of its own to hold the log.
let Some(path) = &path_str else { return };
let adt_path = std::path::PathBuf::from(path).with_extension("adt");
let content = report.format_adt(&title, path_str.as_deref());
// Write through an exclusively-created temporary and rename over the companion path, so a
// planted `.adt` symlink beside the drawing cannot redirect the report onto another file
// writable by the victim (CWE-59).
if let Err(e) = crate::atomic_write::write_atomic(&adt_path, content.as_bytes()) {
log::warn!("AUDITCTL: could not write audit log {}: {e}", adt_path.display());
}
}
#[cfg(target_arch = "wasm32")]
fn write_audit_log(_: &Session, _: &crate::audit::Report) {}
#[cfg(not(target_arch = "wasm32"))]
fn read_path(cmd: &str, path: &str) -> Result<Vec<u8>> {
std::fs::read(path).map_err(|e| bad(cmd, format!("{path}: {e}")))
+41 -6
View File
@@ -16,6 +16,7 @@ use super::*;
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
mod attedit;
mod burst;
mod dyngrip;
mod extract;
pub(crate) mod refedit;
@@ -72,6 +73,7 @@ pub fn specs() -> Vec<CommandSpec> {
battman::battman_spec(),
attribs::attdisp_spec(),
attribs::attsync_spec(),
burst::burst_spec(),
extract::dataextraction_spec(),
extract::eattext_spec(),
]
@@ -108,8 +110,41 @@ fn references_block(d: &cadcraft_doc::Drawing, ents: &[Entity], name: &str) -> b
false
}
/// What BLOCK does with the selected objects once the block is defined (the dialog's Convert to
/// block, Retain and Delete).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Keep {
/// Replace the objects by a reference to the new block.
Convert,
/// Leave the objects in the drawing.
Retain,
/// Erase the objects.
Delete,
}
impl Keep {
/// The `keep` parameter's documented values, in any case; `None` for anything else.
pub(crate) fn parse(value: &str) -> Option<Keep> {
match value.trim().to_ascii_lowercase().as_str() {
"convert" => Some(Keep::Convert),
"retain" => Some(Keep::Retain),
"delete" => Some(Keep::Delete),
_ => None,
}
}
}
/// The `keep` parameter of `cmd`, `default` when absent. A value that is not documented is an
/// error (issue #935: a misspelt value used to erase the objects without converting them).
fn keep_param(cmd: &str, p: &Value, default: Keep) -> Result<Keep> {
match str_param(p, "keep") {
None => Ok(default),
Some(v) => Keep::parse(v).ok_or_else(|| bad(cmd, format!("`keep` must be \"convert\", \"retain\" or \"delete\", not {v:?}"))),
}
}
/// Create a block from entities. Returns the insert handle when converting.
pub(crate) fn make_block(s: &mut Session, name: &str, base: Vec2, hs: &[Handle], keep: &str, description: &str) -> Result<Option<Handle>> {
pub(crate) fn make_block(s: &mut Session, name: &str, base: Vec2, hs: &[Handle], keep: Keep, description: &str) -> Result<Option<Handle>> {
if !valid_block_name(name) {
return Err(bad("block", "invalid block name"));
}
@@ -136,13 +171,13 @@ pub(crate) fn make_block(s: &mut Session, name: &str, base: Vec2, hs: &[Handle],
b.entities.push(e);
}
doc.blocks.insert(name.to_string(), Arc::new(b));
if keep != "retain" {
if keep != Keep::Retain {
for h in hs {
doc.remove_entity(*h);
}
}
s.set_selection(Vec::new());
if keep == "convert" {
if keep == Keep::Convert {
let h = insert(s, name, base, 1.0, 0.0, &serde_json::Map::new())?;
return Ok(Some(h));
}
@@ -219,8 +254,8 @@ fn run_block(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").ok_or_else(|| bad("block", "`name` is required"))?.to_string();
let base = point_param(p, "base").unwrap_or(Vec2::ZERO);
let hs = targets(s, p)?;
let keep = str_param(p, "keep").unwrap_or("convert").to_string();
let r = make_block(s, &name, base, &hs, &keep, str_param(p, "description").unwrap_or(""))?;
let keep = keep_param("block", p, Keep::Convert)?;
let r = make_block(s, &name, base, &hs, keep, str_param(p, "description").unwrap_or(""))?;
// An annotative block: its references (the converted selection too) are annotative.
if bool_or(p, "annotative", false) {
let d = s.doc_mut()?;
@@ -495,7 +530,7 @@ impl BlockM {
fn finish(&mut self, s: &mut Session, hs: &[Handle]) -> Result<Step> {
let (Some(name), Some(base)) = (self.name.clone(), self.base) else { return Ok(Step::Cancel) };
// A definition that can't be made (e.g. one that would reference itself) ends the command.
let keep = if self.dash { "delete" } else { "convert" };
let keep = if self.dash { Keep::Delete } else { Keep::Convert };
if let Err(e) = make_block(s, &name, base, hs, keep, "") {
s.echo(e.to_string());
return Ok(Step::Done);
+57
View File
@@ -0,0 +1,57 @@
//! BURST (Express Tools): explode block references as EXPLODE does, but their visible attributes
//! become text with the values they show instead of the block's attribute definitions.
use cadcraft_color::Color;
use cadcraft_doc::{Common, Drawing, Entity, EntityKind, Handle, Insert};
use serde_json::{Value, json};
use crate::cmd::machines::SelectRun;
use crate::cmd::modify::{explode_kind, explode_with};
use crate::cmd::{CommandSpec, targets};
use crate::{Result, Session};
pub(super) fn burst_spec() -> CommandSpec {
CommandSpec::new("burst", "Explode Attributes to Text", run_burst)
.menu(&["Tools", "Express Tools", "Explode Attributes to Text"])
.params("{handles?} (block references; other objects are left alone) → {handles}")
.interactive(|_| Ok(Box::new(SelectRun::new("burst", "BURST"))))
}
fn run_burst(s: &mut Session, p: &Value) -> Result<Value> {
let hs = targets(s, p)?;
let r = explode_with(s, &hs, burst_pieces)?;
Ok(json!({ "handles": r.iter().map(|h| h.hex()).collect::<Vec<_>>() }))
}
/// What a block reference bursts into: EXPLODE's pieces without the attribute definitions, then
/// a text (multiline text for a multiline attribute) for each visible attribute.
fn burst_pieces(d: &Drawing, e: &Entity) -> Option<Vec<Entity>> {
let EntityKind::Insert(ins) = &e.kind else { return None };
let mut out: Vec<Entity> = explode_kind(d, e)?.into_iter().filter(|p| !matches!(p.kind, EntityKind::AttDef(_))).collect();
for a in ins.attribs.iter().filter(|a| !a.invisible) {
let kind = match a.multiline_text() {
Some(m) => EntityKind::MText(m),
None => EntityKind::Text(a.text.clone()),
};
out.push(Entity { handle: Handle(0), common: attrib_common(d, e, ins, &a.tag, &a.props), kind });
}
Some(out)
}
/// The properties an attribute is drawn with: its own, else its definition's; layer 0 is the
/// reference's layer and ByBlock colour the reference's colour, as EXPLODE gives the block's
/// objects.
fn attrib_common(d: &Drawing, e: &Entity, ins: &Insert, tag: &str, props: &cadcraft_doc::AttribProps) -> Common {
let def =
d.block(&ins.block).and_then(|b| b.entities.iter().find(|be| matches!(&be.kind, EntityKind::AttDef(ad) if ad.tag.eq_ignore_ascii_case(tag))));
// An attribute without a definition follows the reference.
let base = def.map(|de| de.common.clone()).unwrap_or_else(|| Common { color: Color::ByBlock, ..Common::default() });
let mut c = props.over(&base);
if c.layer == "0" {
c.layer.clone_from(&e.common.layer);
}
if c.color == Color::ByBlock {
c.color = e.common.color;
}
c
}
+56 -17
View File
@@ -3,8 +3,9 @@
//! grip begins, with the reference re-evaluated under the cursor as its preview. Typing a number
//! at a distance's grip sets the distance.
use cadcraft_doc::color::Color;
use cadcraft_doc::dynblock::{self, GripPart, GripShape};
use cadcraft_doc::{Drawing, EntityKind, Handle};
use cadcraft_doc::{Common, Drawing, Entity, EntityKind, Handle, Lineweight, Transparency};
use cadcraft_geom::Vec2;
use serde_json::{Value, json};
@@ -50,26 +51,59 @@ fn drag(s: &mut Session, h: Handle, node: i32, part: GripPart, to: Vec2) -> std:
Ok(())
}
/// The reference `h` as drawn in drawing `d`: its block's shown objects placed by it, and its
/// attributes (what a preview draws, in the preview colour: solid fills are left out so they
/// don't cover the outline and text).
fn drawn(d: &Drawing, h: Handle) -> Vec<EntityKind> {
let Some(EntityKind::Insert(ins)) = d.entity(h).map(|e| &e.kind) else { return Vec::new() };
/// The properties an object of a block draws with in reference `r`: on layer 0 it is on the
/// reference's layer, and its ByBlock colour, linetype, lineweight and transparency are the
/// reference's (as the renderer draws a block's contents).
fn under(c: &Common, r: &Common) -> Common {
let mut c = c.clone();
if c.layer == "0" {
c.layer.clone_from(&r.layer);
}
if c.color == Color::ByBlock {
c.color = r.color;
}
if c.linetype.eq_ignore_ascii_case("ByBlock") {
c.linetype.clone_from(&r.linetype);
}
if c.lineweight == Lineweight::ByBlock {
c.lineweight = r.lineweight;
}
if c.transparency == Transparency::ByBlock {
c.transparency = r.transparency;
}
c
}
/// The reference `h` as drawn in drawing `d`: its block's shown objects placed by it, in the
/// block's order (fills under the outline and text), and its attributes, each with the
/// properties it draws with.
fn drawn(d: &Drawing, h: Handle) -> Vec<Entity> {
let Some(e) = d.entity(h) else { return Vec::new() };
let EntityKind::Insert(ins) = &e.kind else { return Vec::new() };
let Some(b) = d.block(&ins.block) else { return Vec::new() };
let r = &e.common;
let m = ins.transform(b.base.xy());
let mut out: Vec<EntityKind> = b
let mut out: Vec<Entity> = b
.entities
.iter()
.filter(|e| e.common.visible && !matches!(&e.kind, EntityKind::AttDef(_) | EntityKind::Wipeout(_)))
.filter(|e| !matches!(&e.kind, EntityKind::Hatch(h) if h.solid))
.take(MAX_PREVIEW)
.map(|e| {
let mut k = e.kind.clone();
k.transform(&m);
k
let mut kind = e.kind.clone();
kind.transform(&m);
Entity { handle: Handle(0), common: under(&e.common, r), kind }
})
.collect();
out.extend(ins.attribs.iter().filter(|a| !a.invisible).map(|a| EntityKind::Text(a.text.clone())));
// An attribute draws with its definition's properties, except those set on it.
let by_block = Common { layer: "0".into(), color: Color::ByBlock, linetype: "ByBlock".into(), lineweight: Lineweight::ByBlock, ..r.clone() };
out.extend(ins.attribs.iter().filter(|a| !a.invisible).map(|a| {
let def = b.entities.iter().find_map(|e| match &e.kind {
EntityKind::AttDef(ad) if ad.tag.eq_ignore_ascii_case(&a.tag) => Some(&e.common),
_ => None,
});
let common = under(&a.props.over(def.unwrap_or(&by_block)), r);
Entity { handle: Handle(0), common, kind: EntityKind::Text(a.text.clone()) }
}));
out
}
@@ -121,15 +155,20 @@ impl Interactive for DynGripEdit {
Ok(Step::Done)
}
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
/// The rubber band from the grip.
fn preview(&self, _s: &Session, c: Vec2) -> Vec<EntityKind> {
vec![crate::cmd::helpers::line(self.from, c)]
}
/// The reference re-evaluated at the cursor, drawn as it will look (its own colours, so
/// text stays readable on a filled block).
fn preview_objects(&self, s: &Session, c: Vec2) -> Vec<Entity> {
let Ok(d) = s.doc() else { return Vec::new() };
let mut d = d.clone();
let mut out = match dynblock::drag_grip(&mut d, self.handle, self.node, self.part, c) {
match dynblock::drag_grip(&mut d, self.handle, self.node, self.part, c) {
Ok(()) => drawn(&d, self.handle),
Err(_) => Vec::new(),
};
out.push(crate::cmd::helpers::line(self.from, c));
out
}
}
}
+10 -13
View File
@@ -104,8 +104,8 @@ fn reference_count(d: &Drawing, key: &str) -> usize {
fn describe(d: &Drawing, b: &Block, host: Option<&str>) -> Value {
let refs = reference_count(d, &b.name);
let status = if refs == 0 && b.xref_status == XrefStatus::Loaded { "Unreferenced" } else { b.xref_status.label() };
let dir = host.and_then(|h| std::path::Path::new(h).parent().map(std::path::Path::to_path_buf));
let found = b.xref_path.as_deref().and_then(|p| load::find(p, dir.as_deref())).map(|f| f.to_string_lossy().into_owned());
let found =
b.xref_path.as_deref().and_then(|p| load::find(p, host.map(std::path::Path::new), false).ok()).map(|f| f.to_string_lossy().into_owned());
json!({
"name": b.name,
"path": b.xref_path,
@@ -146,7 +146,7 @@ fn attach(s: &mut Session, name: &str, path: &str, overlay: bool) -> Result<Stri
b.xref_path = Some(path.to_string());
b.xref_overlay = overlay;
d.blocks.insert(name.to_string(), Arc::new(b));
let out = load::load(&mut d, name, host.as_deref());
let out = load::load(&mut d, name, host.as_deref(), true);
match out.first() {
Some(o) if o.status == XrefStatus::Loaded => {}
Some(o) => return Err(bad("xattach", format!("{path} can't be attached: {}", o.detail))),
@@ -227,11 +227,7 @@ fn run_dash_xref(s: &mut Session, p: &Value) -> Result<Value> {
}
"reload" | "r" => {
let names = named(s, "-xref", p)?;
let mut out = Vec::new();
for n in &names {
let d = s.doc_mut()?;
out.extend(load::load(d, n, host.as_deref()));
}
let out = load::load_each(s.doc_mut()?, &names, host.as_deref(), false);
echo_all(s, &out);
Ok(
json!({ "xrefs": out.iter().filter(|o| !o.name.is_empty()).map(|o| json!({"name": o.name, "status": o.status.label(), "detail": o.detail})).collect::<Vec<_>>() }),
@@ -271,14 +267,13 @@ fn run_dash_xref(s: &mut Session, p: &Value) -> Result<Value> {
"path" | "p" => {
let path = str_param(p, "path").map(str::trim).filter(|t| !t.is_empty()).ok_or_else(|| bad("-xref", "`path` is required"))?.to_string();
let names = named(s, "-xref", p)?;
let mut out = Vec::new();
let d = s.doc_mut()?;
for n in &names {
let d = s.doc_mut()?;
if let Some(b) = d.blocks.get_mut(n) {
Arc::make_mut(b).xref_path = Some(path.clone());
}
out.extend(load::load(d, n, host.as_deref()));
}
let out = load::load_each(d, &names, host.as_deref(), true);
echo_all(s, &out);
Ok(json!({ "xrefs": names, "path": path }))
}
@@ -367,8 +362,10 @@ fn run_xopen(s: &mut Session, p: &Value) -> Result<Value> {
let b = d.block(&name).ok_or_else(|| bad("xopen", format!("no xref \"{name}\"")))?;
let path = b.xref_path.clone().ok_or_else(|| bad("xopen", format!("\"{name}\" is not an xref")))?;
let host = host(s);
let dir = host.as_deref().and_then(|h| std::path::Path::new(h).parent().map(std::path::Path::to_path_buf));
let file = load::find(&path, dir.as_deref()).ok_or_else(|| bad("xopen", format!("{path}: file not found")))?;
let file = load::find(&path, host.as_deref().map(std::path::Path::new), false).map_err(|m| match m {
load::Missing::NotFound => bad("xopen", format!("{path}: file not found")),
load::Missing::Refused(r) => bad("xopen", format!("{path}: not opened, {r}")),
})?;
s.execute("open", &json!({ "path": file.to_string_lossy() }))
}
+142 -60
View File
@@ -3,8 +3,10 @@
//! `xref|name`.
//!
//! A path is looked for as given (relative paths against the host drawing's folder), then by its
//! file name in the host drawing's folder. A referenced drawing's own attached xrefs load with it
//! (overlays don't), at most [`MAX_BLOCK_DEPTH`] levels deep; a drawing that is already being
//! file name in the host drawing's folder. A path stored in a drawing is followed only to this
//! computer or the host drawing's network share ([`crate::doc_path`]); one that isn't followed and
//! has no file of its name beside the host leaves the xref unresolved. A referenced drawing's own
//! attached xrefs load with it (overlays don't), at most [`MAX_BLOCK_DEPTH`] levels deep; a drawing that is already being
//! loaded further up (a circular reference) is not loaded again. Every failure leaves the xref's
//! block empty with a status saying why; nothing here stops the host drawing from opening.
@@ -13,6 +15,8 @@ use std::sync::Arc;
use cadcraft_doc::{Attrib, Drawing, Entity, EntityKind, EntityStore, MAX_BLOCK_DEPTH, XrefStatus};
use crate::doc_path::{self, Refused};
/// Most objects the xrefs of one drawing load in one go (nested xrefs included).
pub(crate) const MAX_XREF_OBJECTS: usize = 4_000_000;
/// Most referenced files read in one go (nested xrefs included).
@@ -20,6 +24,9 @@ const MAX_XREF_FILES: usize = 1000;
/// Largest referenced file read.
#[cfg(not(target_arch = "wasm32"))]
const MAX_XREF_BYTES: u64 = 512 << 20;
/// Most bytes read from referenced files in one go (nested xrefs included).
#[cfg(not(target_arch = "wasm32"))]
const MAX_XREF_TOTAL_BYTES: u64 = 1 << 30;
/// The kinds of named symbols an object can refer to.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
@@ -119,16 +126,38 @@ struct Ctx {
chain: Vec<PathBuf>,
objects: usize,
files: usize,
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
bytes: u64,
/// Messages about nested xrefs.
notes: Vec<String>,
}
/// The folder of the host drawing at `host` (a file path).
fn folder(host: Option<&str>) -> Option<PathBuf> {
let p = Path::new(host?).parent()?;
fn folder(host: Option<&Path>) -> Option<PathBuf> {
let p = host?.parent()?;
Some(if p.as_os_str().is_empty() { PathBuf::from(".") } else { p.to_path_buf() })
}
/// Why a referenced drawing wasn't found.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Missing {
NotFound,
/// The saved path isn't followed, and there is no file of its name beside the host.
Refused(Refused),
}
impl Missing {
fn outcome(self, path: &str) -> (XrefStatus, String) {
match self {
Self::NotFound => {
let why = if cfg!(target_arch = "wasm32") { "files are not available on the web" } else { "file not found" };
(XrefStatus::NotFound, format!("{path}: {why}"))
}
Self::Refused(r) => (XrefStatus::Unresolved, format!("{path}: not opened, {r}")),
}
}
}
#[cfg(not(target_arch = "wasm32"))]
fn canonical(p: &Path) -> PathBuf {
std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf())
@@ -138,58 +167,94 @@ fn canonical(p: &Path) -> PathBuf {
p.to_path_buf()
}
/// Where to look for a referenced drawing saved as `path`: the path itself (a relative one against
/// the host's folder), then its file name in the host's folder. Both separators work everywhere.
fn candidates(path: &str, dir: Option<&Path>) -> Vec<PathBuf> {
let norm = if cfg!(windows) { path.to_string() } else { path.replace('\\', "/") };
let p = PathBuf::from(norm.trim());
/// Where to look for a referenced drawing at `path` in the drawing at `host`: the path itself (a
/// relative one against the host's folder), then its file name in the host's folder. Both
/// separators work everywhere. A path stored in a drawing goes through [`doc_path::resolve`]
/// (the refusal is returned); a path the user just `chosen` is used as given.
fn candidates(path: &str, host: Option<&Path>, chosen: bool) -> (Vec<PathBuf>, Option<Refused>) {
let path = path.trim();
let mut out = Vec::new();
match dir {
Some(d) if !p.is_absolute() => out.push(d.join(&p)),
_ => out.push(p.clone()),
let mut refused = None;
if chosen {
let norm = if cfg!(windows) { path.to_string() } else { path.replace('\\', "/") };
let p = PathBuf::from(norm);
match folder(host) {
Some(d) if !p.is_absolute() => out.push(d.join(&p)),
_ => out.push(p),
}
} else {
match doc_path::resolve(path, host) {
Ok(p) => out.push(p),
Err(Refused::Invalid) => {}
Err(r) => refused = Some(r),
}
}
if let (Some(d), Some(f)) = (dir, p.file_name()) {
out.push(d.join(f));
let name = path.rsplit(['/', '\\']).next().unwrap_or(path);
if host.is_some()
&& !name.is_empty()
&& let Ok(p) = doc_path::resolve(name, host)
&& !out.contains(&p)
{
out.push(p);
}
out
(out, refused)
}
/// The file a referenced drawing saved as `path` is loaded from, if there is one.
pub(crate) fn find(path: &str, dir: Option<&Path>) -> Option<PathBuf> {
/// The file a referenced drawing at `path` (saved in the drawing at `host`, or just `chosen` by
/// the user) is loaded from.
pub(crate) fn find(path: &str, host: Option<&Path>, chosen: bool) -> Result<PathBuf, Missing> {
if path.trim().is_empty() {
return None;
return Err(Missing::NotFound);
}
let (files, refused) = candidates(path, host, chosen);
#[cfg(not(target_arch = "wasm32"))]
{
candidates(path, dir).into_iter().find(|p| p.is_file())
}
let found = files.into_iter().find(|p| p.is_file());
#[cfg(target_arch = "wasm32")]
{
let _ = candidates(path, dir);
let found: Option<PathBuf> = {
let _ = files;
None
}
};
found.ok_or(refused.map_or(Missing::NotFound, Missing::Refused))
}
/// Read a drawing file through the host's io hooks.
fn read(file: &Path) -> Result<Drawing, String> {
/// Read a drawing file through the host's io hooks, counting its bytes against the load's total.
fn read(file: &Path, cx: &mut Ctx) -> Result<Drawing, String> {
let hooks = super::super::super::file::io().ok_or("file formats are not available in this build")?;
#[cfg(not(target_arch = "wasm32"))]
{
let len = std::fs::metadata(file).map_err(|e| e.to_string())?.len();
if len > MAX_XREF_BYTES {
return Err(format!("the file is larger than {} MB", MAX_XREF_BYTES >> 20));
use std::io::Read;
let left = MAX_XREF_TOTAL_BYTES.saturating_sub(cx.bytes);
let limit = MAX_XREF_BYTES.min(left);
let mut f = std::fs::File::open(file).map_err(|e| e.to_string())?;
let len = f.metadata().map_err(|e| e.to_string())?.len();
let too_big = || {
if limit < MAX_XREF_BYTES {
format!("the referenced files are larger than {} MB together", MAX_XREF_TOTAL_BYTES >> 20)
} else {
format!("the file is larger than {} MB", MAX_XREF_BYTES >> 20)
}
};
if len > limit {
return Err(too_big());
}
let bytes = std::fs::read(file).map_err(|e| e.to_string())?;
let mut bytes = Vec::new();
f.by_ref().take(limit.saturating_add(1)).read_to_end(&mut bytes).map_err(|e| e.to_string())?;
let n = u64::try_from(bytes.len()).unwrap_or(u64::MAX);
if n > limit {
return Err(too_big());
}
cx.bytes = cx.bytes.saturating_add(n);
(hooks.read)(&bytes, &file.to_string_lossy())
}
#[cfg(target_arch = "wasm32")]
{
let _ = (hooks, file);
let _ = (hooks, file, cx);
Err("files are not available on the web".into())
}
}
/// Load every xref of `d` that isn't unloaded; `host` is the drawing's own path.
/// Load every xref of `d` that isn't unloaded from the paths saved in it; `host` is the drawing's
/// own path.
pub(crate) fn load_all(d: &mut Drawing, host: Option<&str>) -> Vec<Outcome> {
let names: Vec<String> = d
.blocks
@@ -197,28 +262,44 @@ pub(crate) fn load_all(d: &mut Drawing, host: Option<&str>) -> Vec<Outcome> {
.filter(|b| b.xref_path.is_some() && b.xref_status != XrefStatus::Unloaded && !b.name.contains('|'))
.map(|b| b.name.clone())
.collect();
names.iter().flat_map(|n| load(d, n, host)).collect()
load_each(d, &names, host, false)
}
/// Load (or reload) xref `name` of `d` from its file; `host` is the drawing's own path. Returns
/// the outcome, then notes about its nested xrefs.
pub(crate) fn load(d: &mut Drawing, name: &str, host: Option<&str>) -> Vec<Outcome> {
let host_file = host.map(Path::new).filter(|p| p.file_name().is_some());
let mut cx = Ctx { chain: host_file.map(canonical).into_iter().collect(), objects: 0, files: 0, notes: Vec::new() };
let dir = folder(host);
let (status, detail) = load_in(d, name, dir.as_deref(), &mut cx, 0);
/// Load (or reload) xref `name` of `d` from its file; `host` is the drawing's own path, and
/// `chosen` says the xref's path was just given by the user (not read from a drawing). Returns the
/// outcome, then notes about its nested xrefs.
pub(crate) fn load(d: &mut Drawing, name: &str, host: Option<&str>, chosen: bool) -> Vec<Outcome> {
load_each(d, std::slice::from_ref(&name.to_string()), host, chosen)
}
/// [`load`] the xrefs `names` within one set of limits.
pub(crate) fn load_each(d: &mut Drawing, names: &[String], host: Option<&str>, chosen: bool) -> Vec<Outcome> {
let mut cx = Ctx::new(host);
names.iter().flat_map(|n| load_with(d, n, host, chosen, &mut cx)).collect()
}
impl Ctx {
fn new(host: Option<&str>) -> Self {
let host_file = host.map(Path::new).filter(|p| p.file_name().is_some());
Ctx { chain: host_file.map(canonical).into_iter().collect(), objects: 0, files: 0, bytes: 0, notes: Vec::new() }
}
}
fn load_with(d: &mut Drawing, name: &str, host: Option<&str>, chosen: bool, cx: &mut Ctx) -> Vec<Outcome> {
let host = host.map(Path::new).filter(|p| p.file_name().is_some());
let (status, detail) = load_in(d, name, host, chosen, cx, 0);
let key = d.block(name).map(|b| b.name.clone()).unwrap_or_else(|| name.to_string());
let mut out = vec![Outcome { name: key, status, detail }];
out.extend(cx.notes.into_iter().map(|n| Outcome { name: String::new(), status: XrefStatus::Unresolved, detail: n }));
out.extend(cx.notes.drain(..).map(|n| Outcome { name: String::new(), status: XrefStatus::Unresolved, detail: n }));
out
}
fn load_in(d: &mut Drawing, name: &str, dir: Option<&Path>, cx: &mut Ctx, depth: usize) -> (XrefStatus, String) {
fn load_in(d: &mut Drawing, name: &str, host: Option<&Path>, chosen: bool, cx: &mut Ctx, depth: usize) -> (XrefStatus, String) {
let Some(b) = d.block(name) else { return (XrefStatus::NotFound, "no such xref".into()) };
let key = b.name.clone();
let Some(path) = b.xref_path.clone() else { return (XrefStatus::Unresolved, "not an xref".into()) };
clear(d, &key);
let (status, detail) = match fetch(&path, dir, cx, depth) {
let (status, detail) = match fetch(&path, host, chosen, cx, depth) {
Ok((x, file)) => match import(d, &key, &x, cx) {
Ok(()) => (XrefStatus::Loaded, file.to_string_lossy().into_owned()),
Err(e) => (XrefStatus::Unresolved, e),
@@ -234,15 +315,12 @@ fn load_in(d: &mut Drawing, name: &str, dir: Option<&Path>, cx: &mut Ctx, depth:
}
/// Read the drawing referenced as `path` and load its own attached xrefs into it.
fn fetch(path: &str, dir: Option<&Path>, cx: &mut Ctx, depth: usize) -> Result<(Drawing, PathBuf), (XrefStatus, String)> {
fn fetch(path: &str, host: Option<&Path>, chosen: bool, cx: &mut Ctx, depth: usize) -> Result<(Drawing, PathBuf), (XrefStatus, String)> {
let unresolved = |m: &str| (XrefStatus::Unresolved, m.to_string());
if depth >= MAX_BLOCK_DEPTH {
return Err(unresolved("xrefs are nested too deeply"));
}
let Some(file) = find(path, dir) else {
let why = if cfg!(target_arch = "wasm32") { "files are not available on the web" } else { "file not found" };
return Err((XrefStatus::NotFound, format!("{path}: {why}")));
};
let file = find(path, host, chosen).map_err(|m| m.outcome(path))?;
let canon = canonical(&file);
if cx.chain.contains(&canon) {
return Err(unresolved("circular reference"));
@@ -251,13 +329,12 @@ fn fetch(path: &str, dir: Option<&Path>, cx: &mut Ctx, depth: usize) -> Result<(
return Err(unresolved("too many referenced files"));
}
cx.files += 1;
let mut x = read(&file).map_err(|e| (XrefStatus::Unresolved, format!("{}: {e}", file.to_string_lossy())))?;
let mut x = read(&file, cx).map_err(|e| (XrefStatus::Unresolved, format!("{}: {e}", file.to_string_lossy())))?;
cx.chain.push(canon);
let nested: Vec<String> =
x.blocks.values().filter(|b| b.xref_path.is_some() && !b.xref_overlay && !b.name.contains('|')).map(|b| b.name.clone()).collect();
let xdir = file.parent().map(Path::to_path_buf);
for n in nested {
let (status, detail) = load_in(&mut x, &n, xdir.as_deref(), cx, depth + 1);
let (status, detail) = load_in(&mut x, &n, Some(&file), false, cx, depth + 1);
if status != XrefStatus::Loaded {
cx.notes.push(format!("nested xref \"{n}\" not loaded: {detail}"));
}
@@ -430,18 +507,23 @@ mod tests {
#[test]
fn paths_resolve_against_the_host_folder_then_by_name() {
let dir = Path::new("/proj/plans");
let c = candidates("parts/a.dxf", Some(dir));
assert_eq!(c, vec![dir.join("parts/a.dxf"), dir.join("a.dxf")]);
let c = candidates("/abs/b.dwg", Some(dir));
assert_eq!(c, vec![PathBuf::from("/abs/b.dwg"), dir.join("b.dwg")]);
let dir = std::env::temp_dir().join("proj").join("plans");
let host = dir.join("host.dxf");
let c = |path: &str, chosen| candidates(path, Some(&host), chosen);
assert_eq!(c("parts/a.dxf", false), (vec![dir.join("parts").join("a.dxf"), dir.join("a.dxf")], None));
let abs = std::env::temp_dir().join("abs").join("b.dwg");
assert_eq!(c(&abs.to_string_lossy(), false), (vec![abs.clone(), dir.join("b.dwg")], None));
if !cfg!(windows) {
// A Windows path saved elsewhere still finds the file next to the host.
let c = candidates("C:\\jobs\\site.dwg", Some(dir));
assert_eq!(c.last(), Some(&dir.join("site.dwg")));
assert_eq!(c("C:\\jobs\\site.dwg", false), (vec![dir.join("site.dwg")], None));
}
assert_eq!(candidates("a.dxf", None), vec![PathBuf::from("a.dxf")]);
assert!(find("", Some(dir)).is_none());
// Paths to other computers are only looked for beside the host.
assert_eq!(c(r"\\192.0.2.1\share\x.dxf", false), (vec![dir.join("x.dxf")], Some(Refused::Network)));
assert_eq!(c("file://192.0.2.1/s/x.dxf", false), (vec![dir.join("x.dxf")], Some(Refused::Network)));
// Without a host drawing a relative path names no file.
assert_eq!(candidates("a.dxf", None, false), (vec![], None));
assert_eq!(candidates("a.dxf", None, true), (vec![PathBuf::from("a.dxf")], None));
assert_eq!(find("", Some(&host), false), Err(Missing::NotFound));
}
#[test]
+14 -2
View File
@@ -93,7 +93,7 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("parameters", "Parameters Manager", run_parameters)
.menu(&["Tools", "Parametric", "Parameters Manager"])
.alias(&["par", "parametersclose"])
.params("{} lists; {name, expr | value, description?} sets (new name = user parameter) and re-solves; {delete: name}"),
.params("{} lists; {name, expr | value, description?} sets (new name = user parameter) and re-solves; {delete: name}; {name, rename: newName} renames it in every expression"),
CommandSpec::new("constraintsettings", "Constraint Settings", run_constraintsettings)
.menu(&["Tools", "Parametric", "Constraint Settings"])
.alias(&["csettings"])
@@ -873,6 +873,10 @@ fn run_parameters(s: &mut Session, p: &Value) -> Result<Value> {
return Err(EngineError::Other(format!("`{name}` is used by `{}`", row.name)));
}
*s.doc_mut()? = d;
} else if let (Some(old), Some(to)) = (str_param(p, "name"), str_param(p, "rename")) {
let mut d = s.doc()?.clone();
cs::rename_parameter(&mut d, old.trim(), to.trim()).map_err(err)?;
*s.doc_mut()? = d;
} else if let Some(name) = str_param(p, "name") {
let expr = match (str_param(p, "expr"), p.get("value").and_then(Value::as_f64)) {
(Some(e), _) => e.to_string(),
@@ -952,6 +956,13 @@ fn run_constraintsettings(s: &mut Session, p: &Value) -> Result<Value> {
set.angle_tolerance = f64_or(p, "angleTolerance", set.angle_tolerance).clamp(0.0, 45.0);
set.bars_visible = bool_or(p, "barsVisible", set.bars_visible);
set.dims_visible = bool_or(p, "dimsVisible", set.dims_visible);
// The checkboxes act like Show All / Hide All: exceptions are relative to the old value and would flip.
if set.bars_visible != cur.bars_visible {
set.bar_exceptions.clear();
}
if set.dims_visible != cur.dims_visible {
set.dim_exceptions.clear();
}
if let Some(t) = p.get("barTransparency").and_then(Value::as_f64).filter(|v| v.is_finite()) {
set.bar_transparency = t.clamp(0.0, 90.0) as u8;
}
@@ -1136,8 +1147,9 @@ pub fn after_command(s: &mut Session, before: Option<&Arc<Drawing>>, ok: bool) {
}
let mut d = (*st.doc).clone();
let purged = cs::purge_invalid(&mut d);
let restored = cs::restore_fixed(&mut d, before);
let satisfied = cs::all_satisfied(&d);
if purged.is_empty() && satisfied {
if purged.is_empty() && !restored && satisfied {
return;
}
let mut msg = Vec::new();
+116
View File
@@ -56,6 +56,42 @@ fn json_geometric_and_dimensional_constraints() {
assert_eq!(inspect["glyphs"][0]["kind"], "Horizontal");
}
#[test]
fn renaming_a_parameter_rewrites_the_expressions_that_use_it() {
let mut s = Session::new();
let a = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
let b = line(&mut s, [0.0, 5.0], [10.0, 5.0]);
s.execute("parameters", &json!({ "name": "user1", "expr": "4" })).unwrap();
s.execute("dcaligned", &json!({ "h1": a, "expr": "user1*2" })).unwrap();
s.execute("dcaligned", &json!({ "h1": b, "expr": "d1+1" })).unwrap();
let names = |s: &Session| -> Vec<(String, String)> {
cadcraft_constraints::parameter_table(s.doc().unwrap()).into_iter().map(|r| (r.name, r.expr)).collect()
};
s.execute("parameters", &json!({ "name": "user1", "rename": "width" })).unwrap();
assert_eq!(names(&s), vec![("d1".into(), "width*2".into()), ("d2".into(), "d1+1".into()), ("width".into(), "4".into())]);
assert!((ends(&s, &a).0.dist(ends(&s, &a).1) - 8.0).abs() < 1e-6);
// A dimensional constraint's name too, and only the whole name: d1 is not part of d10.
s.execute("parameters", &json!({ "name": "d10", "expr": "3" })).unwrap();
s.execute("parameters", &json!({ "name": "d2", "expr": "d1+d10" })).unwrap();
s.execute("parameters", &json!({ "name": "d1", "rename": "base" })).unwrap();
assert_eq!(
names(&s),
vec![("base".into(), "width*2".into()), ("d2".into(), "base+d10".into()), ("width".into(), "4".into()), ("d10".into(), "3".into())]
);
// Taken and invalid names are refused and change nothing; undo takes a rename back.
let before = s.doc().unwrap().clone();
for to in ["width", "d10", "1x", "pi", ""] {
assert!(s.execute("parameters", &json!({ "name": "base", "rename": to })).is_err(), "{to}");
}
assert!(s.execute("parameters", &json!({ "name": "nothing", "rename": "x" })).is_err());
assert_eq!(*s.doc().unwrap(), before);
s.undo().unwrap();
assert_eq!(names(&s)[0], ("d1".into(), "width*2".into()));
}
#[test]
fn edits_are_resolved_and_erase_purges() {
let mut s = Session::new();
@@ -231,3 +267,83 @@ fn delconstraint_rejects_referenced_dimension() {
s.execute("delconstraint", &json!({ "ids": ids })).unwrap();
assert!(s.doc().unwrap().constraints.is_empty());
}
/// The angle between the two rays from the vertex to the far end of each line: the angle
/// DCANGULAR draws.
fn drawn_angle(s: &Session, a: &str, b: &str) -> f64 {
let ((a0, a1), (b0, b1)) = (ends(s, a), ends(s, b));
let (o, _, _) = cadcraft_geom::line_line_infinite(a0, a1, b0, b1).unwrap();
let far = |p: Vec2, q: Vec2| if p.dist(o) > q.dist(o) { p } else { q };
let (u, v) = (far(a0, a1) - o, far(b0, b1) - o);
u.cross(v).atan2(u.dot(v)).abs().to_degrees()
}
#[test]
fn dcangular_constrains_the_drawn_angle_whichever_way_the_lines_run() {
// #1134: the second line drawn towards the shared vertex got the supplement of the value.
for b_pts in [[[2.0, 10.0], [0.0, 0.0]], [[0.0, 0.0], [2.0, 10.0]]] {
let mut s = Session::new();
s.viewport_px = (1000.0, 1000.0);
let a = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
let b = line(&mut s, b_pts[0], b_pts[1]);
s.cmdline("dcangular").unwrap();
s.input(Input::Point(Vec2::new(3.0, 0.0))).unwrap();
s.input(Input::Point(Vec2::new(0.6, 3.0))).unwrap();
assert!(s.prompt_text().contains("78.69"), "{b_pts:?}: {}", s.prompt_text());
s.cmdline("100").unwrap();
assert!(s.running.is_none());
let got = drawn_angle(&s, &a, &b);
assert!((got - 100.0).abs() < 1e-6, "{b_pts:?}: drawn angle {got}");
}
}
/// Same rule as the overlay in `ui-egui/src/parametric.rs` that draws dynamic constraints.
fn dim_shown(s: &Session, h: &str) -> bool {
let d = s.doc().unwrap();
let set = &d.parametric.settings;
let c = d.constraints.iter().find(|c| c.refs.iter().any(|r| r.handle.hex() == h)).unwrap();
set.dims_visible != c.refs.iter().any(|r| set.dim_exceptions.contains(&r.handle))
}
#[test]
fn settings_dims_checkbox_clears_exceptions() {
let mut s = Session::new();
let a = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
s.execute("dclinear", &json!({ "h1": a, "expr": "10" })).unwrap();
s.execute("dcdisplay.hideall", &json!({})).unwrap();
let b = line(&mut s, [20.0, 0.0], [25.0, 0.0]);
s.execute("dclinear", &json!({ "h1": b, "expr": "5" })).unwrap();
let r = s.execute("dcdisplay", &json!({ "mode": "show", "handles": [b] })).unwrap();
assert_eq!(r["exceptions"], json!([b]));
assert!(!dim_shown(&s, &a) && dim_shown(&s, &b));
// Re-stating the current value keeps the exceptions.
s.execute("constraintsettings", &json!({ "dimsVisible": false })).unwrap();
assert_eq!(s.doc().unwrap().parametric.settings.dim_exceptions.len(), 1);
// Ticking "Show dynamic constraints" shows all of them, like Show All.
s.execute("constraintsettings", &json!({ "dimsVisible": true })).unwrap();
assert!(s.doc().unwrap().parametric.settings.dim_exceptions.is_empty());
assert!(dim_shown(&s, &a) && dim_shown(&s, &b));
}
#[test]
fn settings_bars_checkbox_clears_exceptions() {
let mut s = Session::new();
let a = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
s.execute("gchorizontal", &json!({ "h1": a })).unwrap();
s.execute("constraintbar.hideall", &json!({})).unwrap();
let b = line(&mut s, [0.0, 5.0], [10.0, 5.0]);
s.execute("gchorizontal", &json!({ "h1": b })).unwrap();
let r = s.execute("constraintbar", &json!({ "mode": "show", "handles": [b] })).unwrap();
assert_eq!(r["exceptions"], json!([b]));
// Same rule as the bar overlay in `ui-egui/src/parametric.rs`.
let bar = |s: &Session, h: &str| {
let set = &s.doc().unwrap().parametric.settings;
set.bars_visible != set.bar_exceptions.contains(&Handle::parse_hex(h).unwrap())
};
assert!(!bar(&s, &a) && bar(&s, &b));
s.execute("constraintsettings", &json!({ "barsVisible": false })).unwrap();
assert_eq!(s.doc().unwrap().parametric.settings.bar_exceptions.len(), 1);
s.execute("constraintsettings", &json!({ "barsVisible": true })).unwrap();
assert!(s.doc().unwrap().parametric.settings.bar_exceptions.is_empty());
assert!(bar(&s, &a) && bar(&s, &b));
}
+6
View File
@@ -129,6 +129,12 @@ pub fn last_curve_end(s: &Session) -> Option<(Vec2, Vec2)> {
}
EntityKind::Arc(a) => {
let g = Arc::new(a.center.xy(), a.radius, a.start, a.end);
// Arcs are stored CCW: one drawn clockwise ended on its start point, so continue
// from the last picked point (LASTPOINT), leaving it in the drawing direction.
let tol = 1e-9 * g.radius.max(1.0);
if g.start_point().near(s.last_point, tol) && !g.end_point().near(s.last_point, tol) {
return Some((g.start_point(), -Vec2::from_angle(g.start).perp()));
}
return Some((g.end_point(), Vec2::from_angle(g.end).perp()));
}
EntityKind::LwPolyline(p) if !p.closed => {
+6 -3
View File
@@ -755,9 +755,12 @@ impl Interactive for CircleM {
let last_r = s.doc().map(|d| d.header.f64("CIRCLERAD", 0.0)).unwrap_or(0.0);
match (self.mode, n) {
(0, 0) => Prompt::new("Specify center point for circle", Accept::POINT).kw(&["3P", "2P", "Ttr (tan tan radius)"]),
(0, _) if self.diameter => Prompt::new("Specify diameter of circle", Accept::POINT_OR_NUMBER).base_opt(self.pts.first().copied()),
(0, _) if self.diameter => {
Prompt::new("Specify diameter of circle", Accept::POINT_OR_NUMBER).base_opt(self.pts.first().copied()).distance()
}
(0, _) => {
let p = Prompt::new("Specify radius of circle", Accept::POINT_OR_NUMBER).kw(&["Diameter"]).base_opt(self.pts.first().copied());
let p =
Prompt::new("Specify radius of circle", Accept::POINT_OR_NUMBER).kw(&["Diameter"]).base_opt(self.pts.first().copied()).distance();
if last_r > 0.0 { p.default(format!("{last_r:.4}")) } else { p }
}
(1, k) => Prompt::new(
@@ -890,7 +893,7 @@ impl Interactive for PolygonM {
(Some(_), false, Some(_), None) => Prompt::new("Enter an option", Accept::TEXT)
.kw(&["Inscribed in circle", "Circumscribed about circle"])
.default(if s.last_used.polygon_circumscribed { "C" } else { "I" }),
(Some(_), false, Some(c), Some(_)) => Prompt::new("Specify radius of circle", Accept::POINT_OR_NUMBER).base(c),
(Some(_), false, Some(c), Some(_)) => Prompt::new("Specify radius of circle", Accept::POINT_OR_NUMBER).base(c).distance(),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
+37 -11
View File
@@ -12,6 +12,7 @@ use super::machines::number;
use super::*;
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
pub(crate) mod breakline;
pub(crate) mod mline;
pub fn specs() -> Vec<CommandSpec> {
@@ -133,6 +134,7 @@ pub fn specs() -> Vec<CommandSpec> {
.interactive(|_| Ok(Box::new(CenterM { first: None, mark: false }))),
];
v.extend(mline::specs());
v.extend(breakline::specs());
v
}
@@ -217,7 +219,7 @@ impl ArcV {
ArcV::Sea => &[(S, Ask::Point), (E, Ask::Point), (A, Ask::Angle(0))],
ArcV::Sed => &[(S, Ask::Point), (E, Ask::Point), ("Specify tangent direction for the start point of arc", Ask::Angle(0))],
ArcV::Ser => &[(S, Ask::Point), (E, Ask::Point), ("Specify radius of arc", Ask::Dist(1))],
ArcV::Cse => &[(C, Ask::Point), (S, Ask::Point), (E, Ask::Point)],
ArcV::Cse => &[(C, Ask::Point), (S, Ask::Point), ("Specify end point of arc (hold Ctrl to switch direction)", Ask::Point)],
ArcV::Csa => &[(C, Ask::Point), (S, Ask::Point), (A, Ask::Sweep(0, 1))],
ArcV::Csl => &[(C, Ask::Point), (S, Ask::Point), (L, Ask::Dist(1))],
ArcV::CircleCd => &[("Specify center point for circle", Ask::Point), ("Specify diameter of circle", Ask::Dist(0))],
@@ -231,7 +233,7 @@ impl ArcV {
],
}
}
fn build(self, v: &[Val]) -> Option<EntityKind> {
fn build(self, v: &[Val], ctrl: bool) -> Option<EntityKind> {
let p = |i: usize| v.get(i).and_then(|x| x.p());
let n = |i: usize| v.get(i).and_then(|x| x.n());
let a = match self {
@@ -243,7 +245,14 @@ impl ArcV {
ArcV::Sea => curves::arc_sea(p(0)?, p(1)?, n(2)?),
ArcV::Sed => curves::arc_sed(p(0)?, p(1)?, Vec2::from_angle(n(2)?)),
ArcV::Ser => curves::arc_ser(p(0)?, p(1)?, n(2)?),
ArcV::Cse => Some(Arc::from_start_center_end(p(1)?, p(0)?, p(2)?)),
// Center, Start, End. Ctrl keeps the two endpoints on the circle and walks the
// other way around: swapping the start/end angles is the mirror arc through the
// same two endpoints. (Continue below flips differently, by negating the start
// tangent; both are "reverse", but only here is the chord fixed by the picks.)
ArcV::Cse => {
let a = Arc::from_start_center_end(p(1)?, p(0)?, p(2)?);
Some(if ctrl { Arc::new(a.center, a.radius, a.end, a.start) } else { a })
}
ArcV::Csa => curves::arc_sca(p(1)?, p(0)?, n(2)?),
ArcV::Csl => curves::arc_scl(p(1)?, p(0)?, n(2)?),
ArcV::CircleCd => {
@@ -338,6 +347,11 @@ impl Interactive for SeqM {
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
if let Some((st, dir)) = self.cont {
// Ctrl flips the arc: negating the start tangent makes `arc_sed` pick the mirror
// arc (same two points and center, opposite sweep). `ctrl_down` comes from the
// UI's per-frame write on `Session`; the `Input` enum itself stays free of
// modifiers so it remains UI-agnostic.
let dir = if s.ctrl_down { -dir } else { dir };
return match i {
Input::Point(e) => {
let a = curves::arc_sed(st, e, dir).ok_or_else(|| other("Invalid end point."))?;
@@ -380,7 +394,7 @@ impl Interactive for SeqM {
if self.vals.len() < self.v.steps().len() {
return Ok(Step::Continue);
}
match self.v.build(&self.vals) {
match self.v.build(&self.vals, s.ctrl_down) {
Some(k) => {
if let EntityKind::Circle(c) = &k {
let r = c.radius;
@@ -397,8 +411,9 @@ impl Interactive for SeqM {
}
}
}
fn preview(&self, _s: &Session, c: Vec2) -> Vec<EntityKind> {
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
if let Some((st, d)) = self.cont {
let d = if s.ctrl_down { -d } else { d };
return curves::arc_sed(st, c, d).map(|a| vec![arc(&a)]).unwrap_or_default();
}
let Some((_, ask)) = self.ask() else { return Vec::new() };
@@ -407,7 +422,7 @@ impl Interactive for SeqM {
vals.push(v);
let mut out: Vec<EntityKind> = self.base().map(|b| vec![line(b, c)]).unwrap_or_default();
if vals.len() == self.v.steps().len() {
out.extend(self.v.build(&vals));
out.extend(self.v.build(&vals, s.ctrl_down));
}
out
}
@@ -460,7 +475,10 @@ fn run_arc_continue(s: &mut Session, p: &Value) -> Result<Value> {
_ => curves::last_curve_end(s).ok_or_else(|| bad("arc.continue", "no line or arc to continue from"))?,
};
let a = curves::arc_sed(st, e, dir).ok_or_else(|| bad("arc.continue", "end point is on the tangent line"))?;
added(s.add_entity(arc(&a))?)
let h = s.add_entity(arc(&a))?;
// The next continuation starts where this arc was drawn to, not at its stored CCW end.
s.last_point = e;
added(h)
}
#[derive(Default)]
@@ -485,6 +503,8 @@ impl Interactive for ArcContinueM {
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
let Some((st, dir)) = self.from else { return Ok(Step::Cancel) };
// Ctrl flips the direction, exactly as in `SeqM::input`.
let dir = if s.ctrl_down { -dir } else { dir };
match i {
Input::Point(e) => {
let a = curves::arc_sed(st, e, dir).ok_or_else(|| other("Invalid end point."))?;
@@ -495,8 +515,14 @@ impl Interactive for ArcContinueM {
_ => Err(other("Point required.")),
}
}
fn preview(&self, _s: &Session, c: Vec2) -> Vec<EntityKind> {
self.from.and_then(|(st, d)| curves::arc_sed(st, c, d)).map(|a| vec![arc(&a)]).unwrap_or_default()
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
self.from
.and_then(|(st, dir)| {
let dir = if s.ctrl_down { -dir } else { dir };
curves::arc_sed(st, c, dir)
})
.map(|a| vec![arc(&a)])
.unwrap_or_default()
}
}
@@ -575,7 +601,7 @@ impl Interactive for TanCircleM {
1 => Prompt::new("Specify point on object for second tangent of circle", Accept::POINT),
_ => {
let r = s.doc().map(|d| d.header.f64("CIRCLERAD", 0.0)).unwrap_or(0.0);
let p = Prompt::new("Specify radius of circle", Accept::POINT_OR_NUMBER).base_opt(self.picks.last().map(|x| x.1));
let p = Prompt::new("Specify radius of circle", Accept::POINT_OR_NUMBER).base_opt(self.picks.last().map(|x| x.1)).distance();
if r > 0.0 { p.default(format!("{r:.4}")) } else { p }
}
}
@@ -1692,7 +1718,7 @@ impl Interactive for RectM2 {
}
match self.first {
None => Prompt::new("Specify first corner point", Accept::POINT).kw(&["Chamfer", "Elevation", "Fillet", "Thickness", "Width"]),
Some(p) => Prompt::new("Specify other corner point", Accept::POINT).kw(&["Area", "Dimensions", "Rotation"]).base(p),
Some(p) => Prompt::new("Specify other corner point", Accept::POINT).kw(&["Area", "Dimensions", "Rotation"]).corner(p),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
+301
View File
@@ -0,0 +1,301 @@
//! BREAKLINE (Express Tools): one polyline from the first point to the second with a break
//! symbol at a location along it, the line optionally extended past both points.
//!
//! The symbol is a run of vertices between two attachment points. The built-in one is our own
//! zigzag; the Block option takes a block of the drawing that marks its attachment points with
//! two POINT objects on the Defpoints layer and draws the symbol with lines and polylines.
use cadcraft_doc::{Drawing, EntityKind};
use cadcraft_geom::{PolyVertex, Vec2};
use serde_json::Value;
use super::added;
use super::curves::MAX_GEN;
use super::machines::number;
use super::*;
use crate::cmd::modify2::as_lwpoly;
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("breakline", "Break-line Symbol", run_breakline)
.menu(&["Tools", "Express Tools", "Break-line Symbol"])
.params(
"{p1, p2, at?: [x,y] (symbol location; default the midpoint), size? (default 1, times DIMSCALE), \
extension? (past both points; default 0), block? (a block with two points on Defpoints; default the built-in symbol)} → {handle}",
)
.interactive(|s| Ok(Box::new(BreaklineM::new(s)))),
]
}
/// The values BREAKLINE offers as defaults: the symbol block (empty = built-in), its size and
/// how far the line runs past the two points.
#[derive(Clone, Debug, PartialEq)]
pub struct BreaklineOptions {
pub block: String,
pub size: f64,
pub extension: f64,
}
impl Default for BreaklineOptions {
fn default() -> Self {
BreaklineOptions { block: String::new(), size: 1.0, extension: 0.0 }
}
}
fn other(m: impl Into<String>) -> EngineError {
EngineError::Other(m.into())
}
/// A break symbol in its own coordinates: the vertices from one attachment point to the other.
struct Symbol {
path: Vec<PolyVertex>,
}
impl Symbol {
/// Our own zigzag: up and across to the other side of the line, one unit between the
/// attachment points and one unit tall.
fn built_in() -> Self {
let pts = [(-0.5, 0.0), (-0.25, 0.5), (0.25, -0.5), (0.5, 0.0)];
Symbol { path: pts.into_iter().map(|(x, y)| PolyVertex::new(Vec2::new(x, y))).collect() }
}
fn of_block(d: &Drawing, name: &str) -> Result<Self> {
let b = d.block(name).ok_or_else(|| other(format!("Block \"{name}\" not found.")))?;
let mut anchors = Vec::new();
let mut pieces: Vec<Vec<PolyVertex>> = Vec::new();
for e in b.entities.iter() {
match &e.kind {
EntityKind::Point(p) if e.common.layer.eq_ignore_ascii_case("Defpoints") => anchors.push(p.p.xy()),
k => {
if let Some(pl) = as_lwpoly(k).filter(|pl| !pl.closed && pl.vertices.len() >= 2)
&& pieces.len() < MAX_GEN
{
pieces.push(pl.vertices);
}
}
}
}
let [a, z] = anchors[..] else {
return Err(other(format!("Block \"{name}\" needs exactly two points on the Defpoints layer.")));
};
let tol = a.dist(z) * 1e-6;
if !(tol > 0.0 && tol.is_finite()) {
return Err(other(format!("The two Defpoints points of block \"{name}\" coincide.")));
}
// Chain the pieces end to end from one attachment point until the other is reached.
let mut path = vec![PolyVertex::new(a)];
let mut at = a;
while !at.near(z, tol) {
let next = pieces.iter().position(|p| p.first().is_some_and(|v| v.p.near(at, tol)) || p.last().is_some_and(|v| v.p.near(at, tol)));
let Some(i) = next else {
return Err(other(format!("The lines of block \"{name}\" must run from one Defpoints point to the other.")));
};
let mut piece = pieces.swap_remove(i);
if !piece.first().is_some_and(|v| v.p.near(at, tol)) {
piece = reversed(&piece);
}
if let Some(last) = path.last_mut() {
last.bulge = piece.first().map_or(0.0, |v| v.bulge);
}
path.extend(piece.iter().skip(1).copied());
at = path.last().map_or(z, |v| v.p);
}
Ok(Symbol { path })
}
fn span(&self) -> f64 {
match (self.path.first(), self.path.last()) {
(Some(a), Some(z)) => a.p.dist(z.p),
_ => 0.0,
}
}
/// The symbol scaled by `k`, its attachment points on the line through `from` along `u`.
fn placed(&self, from: Vec2, u: Vec2, k: f64) -> Vec<PolyVertex> {
let (Some(a), Some(z)) = (self.path.first(), self.path.last()) else { return Vec::new() };
let ax = (z.p - a.p).normalized();
self.path
.iter()
.map(|v| {
let q = v.p - a.p;
let (x, y) = (q.dot(ax) * k, q.dot(ax.perp()) * k);
PolyVertex::with_bulge(from + u * x + u.perp() * y, v.bulge)
})
.collect()
}
}
/// The same vertices run backwards, each arc keeping its side.
fn reversed(vs: &[PolyVertex]) -> Vec<PolyVertex> {
let n = vs.len();
(0..n)
.rev()
.filter_map(|i| {
let v = vs.get(i)?;
let bulge = if i == 0 { 0.0 } else { -vs.get(i - 1).map_or(0.0, |p| p.bulge) };
Some(PolyVertex::with_bulge(v.p, bulge))
})
.collect()
}
fn dimscale(s: &Session) -> f64 {
s.doc().ok().map(|d| d.header.f64("DIMSCALE", 1.0)).filter(|k| k.is_finite() && *k > 0.0).unwrap_or(1.0)
}
fn symbol(s: &Session, block: &str) -> Result<Symbol> {
if block.is_empty() { Ok(Symbol::built_in()) } else { Symbol::of_block(s.doc()?, block) }
}
/// The breakline's vertices: the extension, the line up to the symbol, the symbol and the rest of
/// the line. The symbol is centred on `at` projected onto the line (the midpoint without it) and
/// kept between the two points.
fn breakline_vertices(sym: &Symbol, p1: Vec2, p2: Vec2, at: Option<Vec2>, k: f64, ext: f64) -> Result<Vec<PolyVertex>> {
let len = p1.dist(p2);
let u = (p2 - p1).normalized();
if !(len.is_finite() && len > 0.0) || u == Vec2::ZERO {
return Err(other("The two points of the breakline must differ."));
}
let span = sym.span() * k;
if !span.is_finite() || span <= 0.0 {
return Err(other("The break symbol has no size."));
}
if span > len {
return Err(other(format!("The breakline must be at least as long as the symbol ({span:.4}).")));
}
let t = at.map_or(len / 2.0, |c| (c - p1).dot(u)).clamp(span / 2.0, len - span / 2.0);
let mut pts = vec![PolyVertex::new(p1 - u * ext)];
pts.extend(sym.placed(p1 + u * (t - span / 2.0), u, k));
pts.push(PolyVertex::new(p2 + u * ext));
let mut out: Vec<PolyVertex> = Vec::with_capacity(pts.len());
for v in pts {
if !v.p.is_finite() {
return Err(other("The breakline is too large."));
}
match out.last_mut() {
Some(l) if l.p.near(v.p, len * 1e-12) => l.bulge = v.bulge,
_ => out.push(v),
}
}
if let Some(l) = out.last_mut() {
l.bulge = 0.0;
}
Ok(out)
}
fn run_breakline(s: &mut Session, p: &Value) -> Result<Value> {
let p1 = point_req("breakline", p, "p1")?;
let p2 = point_req("breakline", p, "p2")?;
let size = size_param("breakline", p, "size", false)?.unwrap_or(1.0);
let ext = size_param("breakline", p, "extension", true)?.unwrap_or(0.0);
let sym = symbol(s, str_param(p, "block").unwrap_or(""))?;
let vs = breakline_vertices(&sym, p1, p2, point_param(p, "at"), size * dimscale(s), ext)?;
added(s.add_entity(lwpoly(vs, false))?)
}
#[derive(Clone, Copy, PartialEq)]
enum Ask {
Points,
Block,
Size,
Extension,
}
struct BreaklineM {
opts: BreaklineOptions,
ask: Ask,
p1: Option<Vec2>,
p2: Option<Vec2>,
}
impl BreaklineM {
fn new(s: &Session) -> Self {
BreaklineM { opts: s.last_used.breakline.clone(), ask: Ask::Points, p1: None, p2: None }
}
fn block_label(&self) -> String {
if self.opts.block.is_empty() { "(built-in)".into() } else { self.opts.block.clone() }
}
fn finish(&self, s: &mut Session, at: Option<Vec2>) -> Result<Step> {
let (Some(p1), Some(p2)) = (self.p1, self.p2) else { return Ok(Step::Cancel) };
let vs = breakline_vertices(&symbol(s, &self.opts.block)?, p1, p2, at, self.opts.size * dimscale(s), self.opts.extension)?;
s.add_entity(lwpoly(vs, false))?;
Ok(Step::Done)
}
}
impl Interactive for BreaklineM {
fn name(&self) -> &'static str {
"BREAKLINE"
}
fn begin(&mut self, s: &mut Session) -> Result<Step> {
s.echo(format!("Block= {}, Size= {:.4}, Extension= {:.4}", self.block_label(), self.opts.size, self.opts.extension));
Ok(Step::Continue)
}
fn prompt(&self, _s: &Session) -> Prompt {
match (self.ask, self.p1, self.p2) {
(Ask::Block, ..) => {
Prompt::new("Enter name of breakline symbol block (. for the built-in symbol)", Accept::TEXT).default(self.block_label())
}
(Ask::Size, ..) => Prompt::new("Breakline symbol size", Accept::NUMBER).default(format!("{:.4}", self.opts.size)),
(Ask::Extension, ..) => Prompt::new("Breakline extension distance", Accept::NUMBER).default(format!("{:.4}", self.opts.extension)),
(Ask::Points, None, _) => Prompt::new("Specify first point for breakline", Accept::POINT).kw(&["Block", "Size", "Extension"]),
(Ask::Points, Some(a), None) => Prompt::new("Specify second point for breakline", Accept::POINT).base(a),
(Ask::Points, Some(_), Some(_)) => Prompt::new("Specify location for break symbol", Accept::POINT).default("Midpoint"),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
match (self.ask, i) {
(Ask::Block, Input::Text(t) | Input::Keyword(t)) => {
let t = t.trim();
let name = if t == "." { String::new() } else { t.to_string() };
symbol(s, &name)?;
self.opts.block = name;
self.ask = Ask::Points;
}
(Ask::Size, Input::Text(t)) => {
self.opts.size = number(&t).filter(|v| v.is_finite() && *v > 0.0).ok_or_else(|| other("Requires a positive value."))?;
self.ask = Ask::Points;
}
(Ask::Extension, Input::Text(t)) => {
self.opts.extension = number(&t).filter(|v| v.is_finite() && *v >= 0.0).ok_or_else(|| other("Requires zero or a positive value."))?;
self.ask = Ask::Points;
}
(Ask::Block | Ask::Size | Ask::Extension, Input::Enter) => self.ask = Ask::Points,
(Ask::Points, Input::Keyword(k)) if self.p1.is_none() => {
self.ask = match k.as_str() {
"Block" => Ask::Block,
"Size" => Ask::Size,
"Extension" => Ask::Extension,
_ => Ask::Points,
};
}
(Ask::Points, Input::Point(p)) => match (self.p1, self.p2) {
(None, _) => self.p1 = Some(p),
(Some(a), None) => {
let span = symbol(s, &self.opts.block)?.span() * self.opts.size * dimscale(s);
let len = a.dist(p);
if len.is_nan() || len < span {
return Err(other(format!("The breakline must be at least as long as the symbol ({span:.4}).")));
}
self.p2 = Some(p);
}
(Some(_), Some(_)) => return self.finish(s, Some(p)),
},
(Ask::Points, Input::Enter) if self.p2.is_some() => return self.finish(s, None),
(Ask::Points, Input::Enter) => return Ok(Step::Cancel),
_ => {}
}
s.last_used.breakline = self.opts.clone();
Ok(Step::Continue)
}
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
match (self.ask, self.p1, self.p2) {
(Ask::Points, Some(a), None) => vec![line(a, c)],
(Ask::Points, Some(a), Some(b)) => symbol(s, &self.opts.block)
.and_then(|sym| breakline_vertices(&sym, a, b, Some(c), self.opts.size * dimscale(s), self.opts.extension))
.map(|vs| vec![lwpoly(vs, false)])
.unwrap_or_default(),
_ => Vec::new(),
}
}
}
+24 -5
View File
@@ -576,8 +576,7 @@ fn mledit_pair(s: &mut Session, tool: Tool, ha: Handle, pa: Vec2, hb: Handle, pb
let aa = a.clone();
cut_across(&mut b, u, &outer_of(&bo), &aa, k);
}
Tool::MergedTee => return Err(other("Merged tee is not available yet.")),
Tool::ClosedTee | Tool::OpenTee => {
Tool::ClosedTee | Tool::OpenTee | Tool::MergedTee => {
let path = path_seg(&a, k).ok_or_else(|| other("*Invalid selection*"))?;
let bm = b.miters();
let (top, bottom) = outer(&bo).ok_or_else(|| other("*Invalid selection*"))?;
@@ -593,16 +592,36 @@ fn mledit_pair(s: &mut Session, tool: Tool, ha: Handle, pa: Vec2, hb: Handle, pb
_ => (sb, bottom),
};
let near = if near_e == top { lt } else { lb };
a = trim_at(&a, k, at, keep_before).ok_or_else(|| other("Nothing would be left of the first multiline."))?;
if tool == Tool::OpenTee {
// Merged tee: the first one's inner elements run on to the second one's nearest inner element.
let inner = if tool == Tool::MergedTee && ao.len() > 2 {
(0..bo.len())
.filter(|e| *e != top && *e != bottom)
.filter_map(|e| meet(&path, &line(e)?))
.min_by(|x, y| (x - at).abs().total_cmp(&(y - at).abs()))
} else {
None
};
a = trim_at(&a, k, inner.unwrap_or(at), keep_before).ok_or_else(|| other("Nothing would be left of the first multiline."))?;
if tool != Tool::ClosedTee {
// Open the near outer element of the second multiline where the first meets it.
let ak = if keep_before { a.vertices.len().saturating_sub(2) } else { 0 };
let (am, aoff) = (a.miters(), a.offsets());
if let Some((t2, b2)) = outer(&aoff) {
let al = |e: usize| aoff.get(e).and_then(|d| element_line(&a, &am, ak, *d));
if let (Some(x), Some(y)) = (al(t2).and_then(|l| meet(&near, &l)), al(b2).and_then(|l| meet(&near, &l))) {
let (lt2, lb2) = (al(t2), al(b2));
if let (Some(x), Some(y)) = (lt2.and_then(|l| meet(&near, &l)), lb2.and_then(|l| meet(&near, &l))) {
add_cut(&mut b, u, near_e, x, y);
}
// ...and the first one's outer elements stop at it.
if inner.is_some() {
for (e, l) in [(t2, lt2), (b2, lb2)] {
if let Some(l) = l
&& let Some(x) = meet(&l, &near)
{
add_cut(&mut a, ak, e, x, if keep_before { l.len() } else { 0.0 });
}
}
}
}
}
}
+135
View File
@@ -121,6 +121,28 @@ fn arc_continue_from_last_line() {
assert!(near(as_arc(&last(&s)).center, 0.0, 1.0));
}
#[test]
fn arc_continue_from_last_picked_end_of_three_point_arc() {
// (picked points, end of the continuation, its center): both arcs leave their last
// picked point heading -Y, so the continuation is a quarter turn on that side.
let cases = [
// Clockwise: stored CCW, the last picked point (2,0) is the arc's start.
("arc 0,0 1,1 2,0", "3,-1", (3.0, 0.0)),
// Counter-clockwise: the last picked point (0,0) is the arc's end.
("arc 2,0 1,1 0,0", "-1,-1", (-1.0, 0.0)),
];
for (draw, end, center) in cases {
let mut s = Session::new();
s.cmdline(draw).unwrap();
s.cmdline("arc").unwrap();
s.cmdline("").unwrap();
s.cmdline(end).unwrap();
let a = as_arc(&last(&s));
assert!(near(a.center, center.0, center.1), "{draw}: {a:?}");
assert!((a.sweep() - std::f64::consts::FRAC_PI_2).abs() < 1e-6, "{draw}: reversed tangent {a:?}");
}
}
// ---------------- CIRCLE ----------------
#[test]
@@ -350,6 +372,118 @@ fn revcloud_command_line() {
assert_eq!(kinds(&s).len(), 4);
}
// ---------------- BREAKLINE ----------------
fn breakline_points(s: &Session) -> Vec<(f64, f64)> {
match last(s) {
EntityKind::LwPolyline(p) => {
assert!(!p.closed);
p.vertices.iter().map(|v| ((v.p.x * 1e9).round() / 1e9, (v.p.y * 1e9).round() / 1e9)).collect()
}
k => panic!("not a polyline: {k:?}"),
}
}
#[test]
fn breakline_json_symbol_size_extension_and_location() {
let mut s = Session::new();
// One polyline: the line to the symbol, the zigzag centred on the midpoint, the rest of the line.
s.execute("breakline", &json!({"p1": [0, 0], "p2": [10, 0]})).unwrap();
assert_eq!(s.doc().unwrap().model.len(), 1);
assert_eq!(breakline_points(&s), vec![(0.0, 0.0), (4.5, 0.0), (4.75, 0.5), (5.25, -0.5), (5.5, 0.0), (10.0, 0.0)]);
// Size scales the symbol, Extension runs the line past both points, `at` is projected onto the line.
s.execute("breakline", &json!({"p1": [0, 0], "p2": [10, 0], "size": 2, "extension": 1, "at": [2, 3]})).unwrap();
assert_eq!(breakline_points(&s), vec![(-1.0, 0.0), (1.0, 0.0), (1.5, 1.0), (2.5, -1.0), (3.0, 0.0), (11.0, 0.0)]);
// A location past the end keeps the symbol on the line; its end then is the line's end.
s.execute("breakline", &json!({"p1": [0, 0], "p2": [10, 0], "size": 2, "at": [100, 0]})).unwrap();
assert_eq!(breakline_points(&s), vec![(0.0, 0.0), (8.0, 0.0), (8.5, 1.0), (9.5, -1.0), (10.0, 0.0)]);
// The symbol turns with the line.
s.execute("breakline", &json!({"p1": [0, 0], "p2": [0, 10]})).unwrap();
assert_eq!(breakline_points(&s), vec![(0.0, 0.0), (0.0, 4.5), (-0.5, 4.75), (0.5, 5.25), (0.0, 5.5), (0.0, 10.0)]);
// DIMSCALE scales the size.
s.doc_mut().unwrap().header.set_f64("DIMSCALE", 4.0);
s.execute("breakline", &json!({"p1": [0, 0], "p2": [10, 0]})).unwrap();
assert_eq!(breakline_points(&s), vec![(0.0, 0.0), (3.0, 0.0), (4.0, 2.0), (6.0, -2.0), (7.0, 0.0), (10.0, 0.0)]);
let n = s.doc().unwrap().model.len();
for p in [
json!({"p1": [0, 0], "p2": [3, 0]}),
json!({"p1": [0, 0], "p2": [0, 0]}),
json!({"p1": [0, 0], "p2": [10, 0], "size": 0}),
json!({"p1": [0, 0], "p2": [10, 0], "extension": -1}),
json!({"p1": [0, 0], "p2": [10, 0], "block": "NOPE"}),
json!({"p1": [0, 0], "p2": [1e308, 0], "extension": 1e308}),
] {
assert!(s.execute("breakline", &p).is_err(), "{p}");
}
assert_eq!(s.doc().unwrap().model.len(), n);
}
#[test]
fn breakline_block_symbol_runs_between_its_defpoints() {
let mut s = Session::new();
s.execute("layer.current", &json!({"name": "Defpoints"})).unwrap();
s.execute("point", &json!({"at": [0, 0]})).unwrap();
s.execute("point", &json!({"at": [2, 0]})).unwrap();
s.execute("layer.current", &json!({"name": "0"})).unwrap();
// Drawn in pieces, one of them backwards: they are chained from one point to the other.
s.execute("line", &json!({"points": [[2, 0], [1, 1]]})).unwrap();
s.execute("line", &json!({"points": [[0, 0], [1, 1]]})).unwrap();
let hs: Vec<String> = s.doc().unwrap().model.iter().map(|e| e.handle.hex()).collect();
s.execute("block", &json!({"name": "PEAK", "base": [0, 0], "handles": hs, "keep": "delete"})).unwrap();
s.execute("breakline", &json!({"p1": [0, 0], "p2": [10, 0], "block": "PEAK"})).unwrap();
assert_eq!(breakline_points(&s), vec![(0.0, 0.0), (4.0, 0.0), (5.0, 1.0), (6.0, 0.0), (10.0, 0.0)]);
// Without two Defpoints points the block is refused.
s.execute("line", &json!({"points": [[0, 5], [1, 5]]})).unwrap();
let l = s.doc().unwrap().model.last().unwrap().handle.hex();
s.execute("block", &json!({"name": "BARE", "base": [0, 0], "handles": [l], "keep": "delete"})).unwrap();
assert!(s.execute("breakline", &json!({"p1": [0, 0], "p2": [10, 0], "block": "BARE"})).is_err());
s.cmdline("breakline b PEAK 0,0 10,0").unwrap();
s.cmdline("").unwrap();
assert!(s.running.is_none(), "{}", s.prompt_text());
assert_eq!(breakline_points(&s).len(), 5);
s.cmdline("breakline b nope").unwrap();
assert!(s.prompt_text().contains("block"), "an unknown block asks again: {}", s.prompt_text());
s.cmdline(".").unwrap();
s.cmdline("0,0 10,0").unwrap();
s.cmdline("").unwrap();
assert_eq!(breakline_points(&s).len(), 6, "`.` goes back to the built-in symbol");
}
#[test]
fn breakline_command_line_prompts_options_and_undo() {
let mut s = Session::new();
s.start("breakline").unwrap();
assert!(s.prompt_text().contains("Specify first point for breakline"), "{}", s.prompt_text());
assert!(s.prompt_text().contains("Block") && s.prompt_text().contains("Size") && s.prompt_text().contains("Extension"), "{}", s.prompt_text());
s.cmdline("0,0").unwrap();
assert!(s.prompt_text().contains("Specify second point for breakline"), "{}", s.prompt_text());
// Too short for the symbol: asked again.
s.cmdline("0.5,0").unwrap();
assert!(s.prompt_text().contains("second point"), "{}", s.prompt_text());
s.cmdline("10,0").unwrap();
assert!(s.prompt_text().contains("Specify location for break symbol") && s.prompt_text().contains("Midpoint"), "{}", s.prompt_text());
assert!(matches!(s.preview(Vec2::new(3.0, 1.0)).first().map(|e| &e.kind), Some(EntityKind::LwPolyline(p)) if p.vertices.len() == 6));
s.cmdline("").unwrap();
assert!(s.running.is_none(), "{}", s.prompt_text());
assert_eq!(breakline_points(&s), vec![(0.0, 0.0), (4.5, 0.0), (4.75, 0.5), (5.25, -0.5), (5.5, 0.0), (10.0, 0.0)]);
assert_eq!(s.state().unwrap().undo.len(), 1, "one undo step");
// Size and Extension at the first prompt, a picked location; remembered for the next time.
s.cmdline("breakline s 2 e 1 0,0 10,0 2,3").unwrap();
assert!(s.running.is_none(), "{}", s.prompt_text());
assert_eq!(breakline_points(&s), vec![(-1.0, 0.0), (1.0, 0.0), (1.5, 1.0), (2.5, -1.0), (3.0, 0.0), (11.0, 0.0)]);
assert_eq!(s.state().unwrap().undo.len(), 2);
s.cmdline("breakline s").unwrap();
assert!(s.prompt_text().contains("2.0000"), "{}", s.prompt_text());
s.cmdline("0").unwrap();
assert!(s.prompt_text().contains("size"), "zero is refused: {}", s.prompt_text());
s.cancel();
s.undo().unwrap();
assert_eq!(s.doc().unwrap().model.len(), 1, "undo removes the whole breakline");
}
// ---------------- WIPEOUT ----------------
#[test]
@@ -622,6 +756,7 @@ fn new_draw_commands_survive_garbage_input() {
"revcloud.polygonal",
"revcloud.freehand",
"wipeout",
"breakline",
"3dpoly",
"mline",
"helix",
+4 -5
View File
@@ -79,10 +79,9 @@ fn extension(path: &str) -> String {
fn write_file(cmd: &str, path: &str, bytes: &[u8]) -> Result<()> {
#[cfg(not(target_arch = "wasm32"))]
{
let tmp = format!("{path}.cadcraft-tmp");
std::fs::write(&tmp, bytes).map_err(|e| bad(cmd, format!("{path}: {e}")))?;
std::fs::rename(&tmp, path).map_err(|e| bad(cmd, format!("{path}: {e}")))?;
Ok(())
// Exclusively create a fresh temporary file and rename it into place so a planted
// `<path>.cadcraft-tmp` symlink cannot redirect the export write (CWE-59).
crate::atomic_write::write_atomic(std::path::Path::new(path), bytes).map_err(|e| bad(cmd, format!("{path}: {e}")))
}
#[cfg(target_arch = "wasm32")]
{
@@ -316,7 +315,7 @@ fn run_preview(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(l) = p.get("landscape").and_then(Value::as_bool) {
page.landscape = l;
}
let sheet = Sheet::from_page(&page, paper::paper_unit_mm(d));
let sheet = Sheet::from_page(&page, paper::page_unit_mm(d, &page));
let lineweights = p.get("lineweights").and_then(Value::as_bool).unwrap_or(if space.is_paper() { page.lineweights } else { true });
let transparency = super::plotters::plot_transparency(s, p).unwrap_or(page.plot_transparency);
let fit = p.get("fit").and_then(Value::as_bool).unwrap_or(matches!(space, Space::Model));
+21 -7
View File
@@ -14,9 +14,20 @@ use serde_json::{Value, json};
use super::*;
use crate::{Accept, Input, Prompt, Result, Session, Step};
/// The rules the Command Aliases dialog checks before it sends an alias to ALIASEDIT.
pub use pgp::{MAX_ALIASES, alias_name, command_id};
pub(crate) use pgp::{specs as alias_specs, user_alias};
pub use template::{qnew_template, set_qnew_template};
/// Largest drawing accepted from a local path, matching the per-file xref limit. A path naming an
/// endless device must be rejected before it exhausts memory (CWE-400), so native opens read
/// through [`crate::capped_read`] rather than buffering to EOF.
#[cfg(not(target_arch = "wasm32"))]
const MAX_DRAWING_BYTES: usize = 512 << 20;
/// Largest `.scr` script accepted from a local path.
#[cfg(not(target_arch = "wasm32"))]
const MAX_SCRIPT_BYTES: usize = 64 << 20;
/// Plot a space to PDF bytes with JSON options (`{paper?, landscape?, fit?, lineweights?, …}`).
pub type PlotHook = fn(&Drawing, &cadcraft_doc::Space, &Value) -> std::result::Result<Vec<u8>, String>;
@@ -190,7 +201,8 @@ fn run_script(s: &mut Session, p: &Value) -> Result<Value> {
let path = _path;
#[cfg(not(target_arch = "wasm32"))]
{
let bytes = std::fs::read(path).map_err(|e| bad("script", format!("{path}: {e}")))?;
let bytes =
crate::capped_read::read_capped(std::path::Path::new(path), MAX_SCRIPT_BYTES).map_err(|e| bad("script", format!("{path}: {e}")))?;
String::from_utf8_lossy(&bytes).into_owned()
}
#[cfg(target_arch = "wasm32")]
@@ -263,7 +275,8 @@ pub fn read_file(path: &str) -> std::result::Result<ReadFile, String> {
#[cfg(not(target_arch = "wasm32"))]
{
let r = std::panic::catch_unwind(|| {
let bytes = std::fs::read(path).map_err(|e| bad("open", format!("{path}: {e}")))?;
let bytes =
crate::capped_read::read_capped(std::path::Path::new(path), MAX_DRAWING_BYTES).map_err(|e| bad("open", format!("{path}: {e}")))?;
read_bytes(&bytes, path, Some(path.to_string()))
});
match r {
@@ -324,13 +337,15 @@ fn opened(s: &Session, i: usize, entities: bool) -> Value {
}
}
/// Open a drawing from bounded regular-file bytes or base64 data and resolve its external references.
fn run_open(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(_path) = str_param(p, "path") {
#[cfg(not(target_arch = "wasm32"))]
let path = _path;
#[cfg(not(target_arch = "wasm32"))]
{
let bytes = std::fs::read(path).map_err(|e| bad("open", format!("{path}: {e}")))?;
let bytes =
crate::capped_read::read_capped(std::path::Path::new(path), MAX_DRAWING_BYTES).map_err(|e| bad("open", format!("{path}: {e}")))?;
let i = open_bytes(s, &bytes, path, Some(path.to_string()))?;
super::blocks::xref::after_open(s, i);
return Ok(opened(s, i, true));
@@ -401,10 +416,9 @@ fn save_as(s: &mut Session, cmd: &str, path: &str, opts: &SaveAs) -> Result<usiz
fn store(path: &str, bytes: &[u8]) -> Result<()> {
#[cfg(not(target_arch = "wasm32"))]
{
let tmp = format!("{path}.cadcraft-tmp");
std::fs::write(&tmp, bytes).map_err(|e| bad("save", format!("{path}: {e}")))?;
std::fs::rename(&tmp, path).map_err(|e| bad("save", format!("{path}: {e}")))?;
Ok(())
// Exclusively create a fresh temporary file and rename it over `path`, so a planted
// `<path>.cadcraft-tmp` symlink cannot redirect the write onto another file (CWE-59).
crate::atomic_write::write_atomic(std::path::Path::new(path), bytes).map_err(|e| bad("save", format!("{path}: {e}")))
}
#[cfg(target_arch = "wasm32")]
{
+3 -2
View File
@@ -43,13 +43,13 @@ pub(crate) fn user_alias(name: &str) -> Option<String> {
}
/// The command id `target` names (`*LINE`, `_.line`, `-LAYER`…), if there is such a command.
fn command_id(target: &str) -> Option<String> {
pub fn command_id(target: &str) -> Option<String> {
let t = target.trim().trim_start_matches('*').trim().trim_start_matches(['_', '.']).to_ascii_lowercase();
find_command(&t).map(|c| c.id.to_string())
}
/// Whether `a` can be typed as an alias: one word of printable characters, not a command name.
fn alias_name(a: &str) -> std::result::Result<String, String> {
pub fn alias_name(a: &str) -> std::result::Result<String, String> {
let a = a.trim();
if a.is_empty() || a.chars().count() > MAX_ALIAS_CHARS || a.chars().any(|c| c.is_whitespace() || c.is_control() || c == '\'' || c == '{') {
return Err(format!("`{a}` can't be an alias"));
@@ -193,6 +193,7 @@ fn run_reinit(s: &mut Session, p: &Value) -> Result<Value> {
pub(crate) fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("aliasedit", "Command Aliases", run_aliasedit)
.menu(&["Tools", "Customize", "Edit Command Aliases (PGP)..."])
.params("{file?: path to a .pgp alias file | text?: its contents, set?: {alias: command}, remove?: [alias], clear?: bool} → {aliases, file, loaded, warnings}")
.enabled(always)
.noundo(),
+1 -1
View File
@@ -25,7 +25,7 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("gradient", "Gradient...", run_gradient).menu(&["Draw", "Gradient..."]).alias(&["gd"]).params("{points? and/or handles?, name?: \"LINEAR\"|\"CYLINDER\"|\"CURVED\"|\"SPHERICAL\"|\"HEMISPHERICAL\" or INV… (colours swapped), color1?, color2?, angle? (degrees), centered? (default true)}").interactive(|s| Ok(Box::new(HatchM::new(s, true)))),
CommandSpec::new("boundary", "Boundary...", run_boundary).menu(&["Draw", "Boundary..."]).alias(&["bo", "bpoly"]).params("{points: [[x,y]]}").interactive(|_| Ok(Box::new(BoundaryM { region: false }))),
CommandSpec::new("region", "Region", run_region).menu(&["Draw", "Region"]).alias(&["reg"]).params("{handles?}"),
CommandSpec::new("hatchedit", "Hatch Edit", edit::run_hatchedit).menu(&["Modify", "Object", "Hatch..."]).alias(&["he", "-hatchedit"]).params("{handles?, pattern?, scale?, angle?, gradient?: {name?, color1?, color2?, angle? (degrees), centered?} (makes or changes a gradient fill; `pattern` turns it back), associative?: bool (false unlinks; true links each loop to a closed object of the same shape, or to `boundary` objects), boundary?: [hex], style?, origin? | originMode?, separate?: true, recreate?: \"polyline\"|\"region\", associate?: bool (link to the recreated boundary)}").interactive(|s| Ok(Box::new(edit::HatchEditM::new(s)))),
CommandSpec::new("hatchedit", "Hatch Edit", edit::run_hatchedit).menu(&["Modify", "Object", "Hatch..."]).alias(&["he", "-hatchedit"]).params("{handles?, pattern?, scale?, angle?, gradient?: {name?, color1?, color2?, angle? (degrees), centered?} (makes or changes a gradient fill; `pattern` turns it back), associative?: bool (false unlinks; true links each loop to a closed object of the same shape, or to `boundary` objects), boundary?: [hex], addPoints?: [[x,y]], addBoundary?: [hex], removeLoops?: [zero-based index] (geometry edits require one hatch), style?, origin? | originMode?, separate?: true, recreate?: \"polyline\"|\"region\", associate?: bool (link to the recreated boundary)}").interactive(|s| Ok(Box::new(edit::HatchEditM::new(s)))),
CommandSpec::new("hatchpick", "Hatch Pick", pick::run_hatchpick)
.params("{points?: [[x,y]] (internal points), handles?: [hex] (closed objects)} (typed: Points, Objects, oRigin or Hatch, then the picks): the Hatch and Gradient dialog's picks, kept in the session; changes nothing")
.enabled(has_doc)
+13 -1
View File
@@ -137,6 +137,18 @@ fn reshaped(prev: &Hatch, h: &Hatch) -> bool {
h.loops.iter().any(|l| !prev.loops.iter().any(|q| q.vertices == l.vertices))
}
/// HATCHEDIT may add or remove whole loops without moving the ones it kept. New associative
/// loops must still match their source objects; a hatch moved on its own must not keep its link.
fn valid_loop_edit(prev: &Hatch, h: &Hatch, after: &Drawing) -> bool {
if prev.loops.len().saturating_mul(h.loops.len()) > 4_000_000 {
return false;
}
h.loops.iter().all(|l| {
prev.loops.iter().any(|old| old.vertices == l.vertices)
|| (!l.sources.is_empty() && matches!(refill(after, after, l), Refill::Loop(vs) if same_shape(&vs, &l.vertices)))
})
}
/// What happens to one associative hatch after a command; `None` when nothing changes.
fn follow(before: &Drawing, after: &Drawing, handle: Handle, h: &Hatch, checked: &mut usize) -> Option<(Hatch, Option<String>)> {
let prev = match before.entity(handle).map(|e| &e.kind) {
@@ -153,7 +165,7 @@ fn follow(before: &Drawing, after: &Drawing, handle: Handle, h: &Hatch, checked:
}
match prev {
// Moved or reshaped on its own: no longer tied to the boundary.
Some(p) if reshaped(p, h) => {
Some(p) if reshaped(p, h) && !valid_loop_edit(p, h, after) => {
detach(&mut nh);
return Some((nh, None));
}
+144 -3
View File
@@ -8,9 +8,9 @@ use cadcraft_geom::{Bounds2, Polyline, Vec2, point_in_polygon};
use serde_json::{Value, json};
use super::assoc::{detach, same_shape};
use super::{GRADIENT_NAMES, ORIGIN_KW, OriginMode, closed_loop, gradient_name, island_style};
use super::{GRADIENT_NAMES, ORIGIN_KW, OriginMode, closed_loop, dedup_loops, gradient_name, island_style, loops_at, loops_from_handles};
use crate::cmd::helpers::lwpoly;
use crate::cmd::{bad, bool_or, point_param, size_param, str_param, targets};
use crate::cmd::{bad, bool_or, point_param, points_param, size_param, str_param, targets};
use crate::{Accept, Input, Interactive, Picker, Prompt, Result, Session, Step};
/// Upper bound on hatches edited by one call.
@@ -259,9 +259,111 @@ fn check_numbers(p: &Value) -> Result<()> {
}
}
/// Boundary changes are prepared before any hatch is edited, so a bad pick cannot leave a
/// partially changed drawing. Loop numbers refer to the hatch before this request.
fn boundary_changes(s: &Session, p: &Value, hs: &[Handle]) -> Result<(Vec<HatchLoop>, Vec<usize>)> {
let given = |key: &str| p.get(key).is_some_and(|v| !v.is_null());
if !["addPoints", "addBoundary", "removeLoops"].into_iter().any(given) {
return Ok((Vec::new(), Vec::new()));
}
if hs.len() != 1 {
return Err(bad("hatchedit", "edit the boundaries of one hatch at a time"));
}
let mut added = Vec::new();
if given("addPoints") {
if s.doc()?.space_of(hs[0]) != Some(s.space()) {
return Err(bad("hatchedit", "open the hatch's space before adding points"));
}
let points = points_param(p, "addPoints").ok_or_else(|| bad("hatchedit", "`addPoints` is an array of finite [x, y] points"))?;
if points.len() > MAX_LOOPS {
return Err(bad("hatchedit", format!("at most {MAX_LOOPS} points")));
}
for q in points {
if !q.is_finite() {
return Err(bad("hatchedit", "`addPoints` must contain finite points"));
}
added.extend(loops_at(s, q)?);
if added.len() > MAX_LOOPS {
return Err(bad("hatchedit", format!("at most {MAX_LOOPS} added loops")));
}
}
}
if given("addBoundary") {
let raw =
p.get("addBoundary").and_then(Value::as_array).ok_or_else(|| bad("hatchedit", "`addBoundary` is an array of closed object handles"))?;
if raw.len() > MAX_LOOPS {
return Err(bad("hatchedit", format!("at most {MAX_LOOPS} boundary objects")));
}
let d = s.doc()?;
let space = d.space_of(hs[0]);
let mut handles = Vec::with_capacity(raw.len());
for value in raw {
let h = value.as_str().and_then(Handle::parse_hex).ok_or_else(|| bad("hatchedit", "invalid `addBoundary` handle"))?;
let e = d.entity(h).ok_or_else(|| bad("hatchedit", format!("boundary object {} not found", h.hex())))?;
if d.space_of(h) != space || closed_loop(d, e).is_none() {
return Err(bad("hatchedit", format!("boundary object {} must be closed and in the hatch's space", h.hex())));
}
handles.push(h);
}
if !handles.is_empty() {
added.extend(loops_from_handles(s, &handles)?);
}
}
let mut removed = Vec::new();
if given("removeLoops") {
let raw =
p.get("removeLoops").and_then(Value::as_array).ok_or_else(|| bad("hatchedit", "`removeLoops` is an array of zero-based loop numbers"))?;
if raw.len() > MAX_LOOPS {
return Err(bad("hatchedit", format!("at most {MAX_LOOPS} loop numbers")));
}
let count = hs
.first()
.and_then(|h| s.doc().ok()?.entity(*h))
.and_then(|e| match &e.kind {
EntityKind::Hatch(h) => Some(h.loops.len()),
_ => None,
})
.unwrap_or(0);
for value in raw {
let index = value
.as_u64()
.and_then(|n| usize::try_from(n).ok())
.filter(|n| *n < count)
.ok_or_else(|| bad("hatchedit", "`removeLoops` contains an invalid loop number"))?;
if !removed.contains(&index) {
removed.push(index);
}
}
}
Ok((added, removed))
}
fn edit_loops(h: &mut Hatch, added: &[HatchLoop], removed: &[usize]) -> Result<()> {
if added.is_empty() && removed.is_empty() {
return Ok(());
}
h.loops = h.loops.drain(..).enumerate().filter_map(|(i, l)| (!removed.contains(&i)).then_some(l)).collect();
h.loops.extend_from_slice(added);
dedup_loops(&mut h.loops);
if h.loops.is_empty() {
return Err(bad("hatchedit", "a hatch needs at least one boundary loop"));
}
if h.loops.len() > MAX_LOOPS {
return Err(bad("hatchedit", format!("a hatch may have at most {MAX_LOOPS} loops")));
}
let outlines: Vec<Vec<Vec2>> = h.loops.iter().map(outline).collect();
for (i, l) in h.loops.iter_mut().enumerate() {
let p = outlines.get(i).and_then(|v| v.first()).copied();
let depth = p.map_or(0, |q| outlines.iter().enumerate().filter(|(j, other)| *j != i && point_in_polygon(other, q)).count());
l.outer = depth % 2 == 0;
}
Ok(())
}
pub(super) fn run_hatchedit(s: &mut Session, p: &Value) -> Result<Value> {
check_numbers(p)?;
let hs = hatches(s, p)?;
let (added, removed) = boundary_changes(s, p, &hs)?;
let style = match str_param(p, "style") {
Some(t) => Some(island_style(t).ok_or_else(|| bad("hatchedit", "`style` is \"normal\", \"outer\" or \"ignore\""))?),
None => None,
@@ -308,6 +410,14 @@ pub(super) fn run_hatchedit(s: &mut Session, p: &Value) -> Result<Value> {
let mut budget = MAX_LOOPS;
for handle in &hs {
let Some(EntityKind::Hatch(mut h)) = s.doc()?.entity(*handle).map(|e| e.kind.clone()) else { continue };
edit_loops(&mut h, &added, &removed)?;
if h.associative && h.loops.iter().any(|l| l.sources.is_empty()) && p.get("associative").and_then(Value::as_bool) != Some(false) {
let missing = link(s.doc()?, *handle, &mut h, None, &mut budget);
if missing != 0 {
detach(&mut h);
notes.push(format!("Hatch {}: {missing} new boundary loop(s) could not be linked; it is now non-associative.", handle.hex()));
}
}
properties(&mut h, p);
if let Some(g) = &grad {
g.apply(&mut h);
@@ -360,6 +470,9 @@ pub(super) fn run_hatchedit(s: &mut Session, p: &Value) -> Result<Value> {
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Ask {
Option,
AddPoint,
AddObject,
Remove,
Style,
Pattern,
Scale,
@@ -370,7 +483,18 @@ enum Ask {
Link,
}
const OPTIONS: [&str; 7] = ["Disassociate", "Style", "Properties", "recreate Boundary", "ASsociate", "separate Hatches", "Origin"];
const OPTIONS: [&str; 10] = [
"Disassociate",
"Style",
"Properties",
"recreate Boundary",
"ASsociate",
"separate Hatches",
"Origin",
"add Point",
"add Object",
"remove Boundary",
];
/// -HATCHEDIT: select hatches, then one option.
pub(crate) struct HatchEditM {
@@ -422,6 +546,9 @@ impl Interactive for HatchEditM {
let h = self.first(s);
match self.ask {
Ask::Option => Prompt::new("Enter a hatch option", Accept::TEXT).kw(&OPTIONS),
Ask::AddPoint => Prompt::new("Pick an internal point for the new boundary", Accept::POINT),
Ask::AddObject => Prompt::new("Select closed boundary objects to add", Accept::SELECT),
Ask::Remove => Prompt::new("Enter a boundary loop number to remove (starting at 1)", Accept::TEXT),
Ask::Style => {
let cur = match h.map_or(0, |h| h.style) {
1 => "Outer",
@@ -475,10 +602,24 @@ impl Interactive for HatchEditM {
Some("recreate Boundary") => self.ask = Ask::Recreate,
Some("ASsociate") => self.ask = Ask::Link,
Some("Origin") => self.ask = Ask::Origin,
Some("add Point") => self.ask = Ask::AddPoint,
Some("add Object") => self.ask = Ask::AddObject,
Some("remove Boundary") => self.ask = Ask::Remove,
_ => s.echo("Choose one of the options."),
}
Ok(Step::Continue)
}
(Ask::AddPoint, Input::Point(p)) => self.apply(s, json!({ "addPoints": [[p.x, p.y]] })),
(Ask::AddObject, Input::Pick(hs)) => self.apply(s, json!({ "addBoundary": hs.iter().map(|h| h.hex()).collect::<Vec<_>>() })),
(Ask::AddPoint | Ask::AddObject, Input::Enter) => Ok(Step::Done),
(Ask::AddPoint | Ask::AddObject, _) => Ok(Step::Continue),
(Ask::Remove, _) => {
let Some(index) = text.as_deref().and_then(|t| t.parse::<usize>().ok()).and_then(|n| n.checked_sub(1)) else {
s.echo("Enter a positive boundary loop number.");
return Ok(Step::Continue);
};
self.apply(s, json!({ "removeLoops": [index] }))
}
(Ask::Style, Input::Enter) => Ok(Step::Done),
(Ask::Style, _) => match text.as_deref().and_then(island_style) {
Some(st) => {
+136 -29
View File
@@ -1,7 +1,7 @@
//! Inquiry: DIST, ID, LIST, AREA, drawing inspection for agents.
use cadcraft_doc::{EntityKind, Handle};
use cadcraft_geom::{Polyline, Vec2};
use cadcraft_doc::{Entity, EntityKind, Handle};
use cadcraft_geom::{Polyline, Vec2, Vec3};
use serde_json::{Value, json};
use super::*;
@@ -9,6 +9,7 @@ use crate::units::{format_angle, format_distance};
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
mod regions;
mod shapes;
pub fn specs() -> Vec<CommandSpec> {
let mut v = vec![
@@ -41,8 +42,9 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("status", "Status", run_status).menu(&["Tools", "Inquiry", "Status"]).noundo(),
CommandSpec::new("drawing.inspect", "Inspect Drawing", run_inspect).params("{entities?: bool, limit?: n}").noundo(),
CommandSpec::new("entities", "Query Entities", run_entities)
.params("{type?, layer?, window?: [[x,y],[x,y]], crossing?: bool (default true; false = entirely inside), limit?, offset?} → {count (all matches), returned, offset, entities}")
.params("{type?, layer?, selected?: bool (default false; true = current selection in active space), window?: [[x,y],[x,y]], crossing?: bool (default true; false = entirely inside), limit?, offset?} → {count (all matches), returned, offset, entities}")
.noundo(),
CommandSpec::new("shapes", "Analyze Shapes", shapes::run).params(shapes::PARAMS).noundo(),
CommandSpec::new("texts", "Query Texts", run_texts)
.params("{contains?: text (any case), layer?, window?: [[x,y],[x,y]] (by insertion point), limit? (default 200), offset?} → {count (all matches), returned, offset, texts: [{handle, type, layer, text, at, height, rotation (degrees), style, tag? (attributes)}]}: the readable text of TEXT, MTEXT (formatting removed), block attributes and multileaders")
.noundo(),
@@ -71,28 +73,119 @@ fn fmt_a(s: &Session, v: f64) -> String {
format_angle(v, h.0, h.1)
}
fn dist_text(s: &Session, a: Vec2, b: Vec2) -> String {
/// The angle of `d` up from the XY plane.
fn angle_from_xy(d: Vec3) -> f64 {
d.z.atan2(d.xy().len())
}
fn dist_text(s: &Session, a: Vec3, b: Vec3) -> String {
let d = b - a;
format!(
"Distance = {}, Angle in XY Plane = {}, Angle from XY Plane = 0\nDelta X = {}, Delta Y = {}, Delta Z = {}",
fmt_d(s, a.dist(b)),
fmt_a(s, d.angle()),
"Distance = {}, Angle in XY Plane = {}, Angle from XY Plane = {}\nDelta X = {}, Delta Y = {}, Delta Z = {}",
fmt_d(s, (b - a).len()),
fmt_a(s, d.xy().angle()),
fmt_a(s, angle_from_xy(d)),
fmt_d(s, d.x),
fmt_d(s, d.y),
fmt_d(s, 0.0)
fmt_d(s, d.z)
)
}
/// A point parameter with its Z: the third number when given, else the Z of what is drawn
/// there ([`z_at`]).
fn point3_req(s: &Session, cmd: &str, p: &Value, key: &str) -> Result<Vec3> {
let q = point_req(cmd, p, key)?;
let z = p.get(key).and_then(|v| v.get(2)).and_then(Value::as_f64).filter(|z| z.is_finite());
Ok(Vec3::new(q.x, q.y, z.unwrap_or_else(|| z_at(s, q))))
}
fn run_dist(s: &mut Session, p: &Value) -> Result<Value> {
let a = point_req("dist", p, "p1")?;
let b = point_req("dist", p, "p2")?;
Ok(json!({ "distance": a.dist(b), "angle": (b - a).angle().to_degrees(), "dx": b.x - a.x, "dy": b.y - a.y, "message": dist_text(s, a, b) }))
let a = point3_req(s, "dist", p, "p1")?;
let b = point3_req(s, "dist", p, "p2")?;
let d = b - a;
Ok(json!({
"distance": d.len(),
"angle": d.xy().angle().to_degrees(),
"angleFromXY": angle_from_xy(d).to_degrees(),
"dx": d.x,
"dy": d.y,
"dz": d.z,
"message": dist_text(s, a, b),
}))
}
fn id_text(s: &Session, a: Vec3) -> String {
format!("X = {} Y = {} Z = {}", fmt_d(s, a.x), fmt_d(s, a.y), fmt_d(s, a.z))
}
fn run_id(s: &mut Session, p: &Value) -> Result<Value> {
let a = point_req("id", p, "at")?;
s.last_point = a;
Ok(json!({ "x": a.x, "y": a.y, "message": format!("X = {} Y = {} Z = {}", fmt_d(s, a.x), fmt_d(s, a.y), fmt_d(s, 0.0)) }))
let a = point3_req(s, "id", p, "at")?;
s.last_point = a.xy();
Ok(json!({ "x": a.x, "y": a.y, "z": a.z, "message": id_text(s, a) }))
}
/// The Z of what is drawn at `p`: the object a pick finds there, at that point, or the current
/// elevation (ELEVATION) when there is none. Points come from the canvas in 2D; the object
/// under a snapped point gives its Z back, as AutoCAD's ID and DIST report a snapped point's.
pub(crate) fn z_at(s: &Session, p: Vec2) -> f64 {
let Ok(d) = s.doc() else { return 0.0 };
let elevation = d.header.f64("ELEVATION", 0.0);
let ap = s.pixel_size() * s.settings.pickbox.max(1.0) * 1.5;
let space = s.space();
crate::select::pick(d, &space, p, ap)
.and_then(|h| d.entity(h))
.and_then(|e| entity_z(e, p))
.filter(|z| z.is_finite())
.unwrap_or(if elevation.is_finite() { elevation } else { 0.0 })
}
/// The Z of `e` at the point of it nearest `p` (seen from above), when it has one there.
fn entity_z(e: &Entity, p: Vec2) -> Option<f64> {
let on_segment = |a: Vec3, b: Vec3| {
let d = b.xy() - a.xy();
let l2 = d.dot(d);
let t = if l2 > 0.0 { ((p - a.xy()).dot(d) / l2).clamp(0.0, 1.0) } else { 0.0 };
(a.z + (b.z - a.z) * t, (a.xy() + d * t).dist(p))
};
let along = |pts: &[Vec3], closed: bool| {
let n = pts.len();
let segs = if closed { n } else { n.saturating_sub(1) };
(0..segs)
.filter_map(|i| Some(on_segment(*pts.get(i)?, *pts.get((i + 1) % n)?)))
.min_by(|a, b| a.1.total_cmp(&b.1))
.map(|(z, _)| z)
.or_else(|| pts.first().map(|q| q.z))
};
let n = e.common.extrusion;
let plan = n.x.abs() < 1e-9 && n.y.abs() < 1e-9 && n.z != 0.0;
match &e.kind {
EntityKind::Point(q) => Some(q.p.z),
EntityKind::Line(l) => Some(on_segment(l.a, l.b).0),
EntityKind::Polyline3d(pl) => along(&pl.points, pl.closed),
// Elevation is in the polyline's OCS: down Z for a mirrored one.
EntityKind::LwPolyline(pl) if plan => Some(if n.z < 0.0 { -pl.elevation } else { pl.elevation }),
// Read in world coordinates already.
EntityKind::Circle(c) => Some(c.center.z),
EntityKind::Arc(a) => Some(a.center.z),
EntityKind::Text(t) | EntityKind::AttDef(cadcraft_doc::Attrib { text: t, .. }) => Some(t.insert.z),
EntityKind::MText(t) => Some(t.insert.z),
EntityKind::Insert(i) => Some(i.insert.z),
EntityKind::Face3d(f) => face_z(&f.corners, p).or_else(|| along(&f.corners, true)),
_ => None,
}
}
/// The Z at `p` on a 3D face: on the plane of the triangle of its corners that holds `p`.
fn face_z(c: &[Vec3; 4], p: Vec2) -> Option<f64> {
let tri = |a: Vec3, b: Vec3, d: Vec3| -> Option<f64> {
let n = (b - a).cross(d - a);
if n.z.abs() < 1e-12 * (1.0 + n.len()) {
return None;
}
let inside = cadcraft_geom::point_in_polygon(&[a.xy(), b.xy(), d.xy()], p);
inside.then(|| a.z - (n.x * (p.x - a.x) + n.y * (p.y - a.y)) / n.z)
};
tri(c[0], c[1], c[2]).or_else(|| if c[3] == c[2] { None } else { tri(c[0], c[2], c[3]) })
}
fn list_text(s: &Session, h: Handle) -> String {
@@ -112,23 +205,25 @@ fn list_text(s: &Session, h: Handle) -> String {
"\n from point, X={} Y={} Z={}\n to point, X={} Y={} Z={}",
fmt_d(s, l.a.x),
fmt_d(s, l.a.y),
fmt_d(s, 0.0),
fmt_d(s, l.a.z),
fmt_d(s, l.b.x),
fmt_d(s, l.b.y),
fmt_d(s, 0.0)
fmt_d(s, l.b.z)
));
t.push_str(&format!(
"\n Length = {}, Angle in XY Plane = {}",
fmt_d(s, l.a.xy().dist(l.b.xy())),
fmt_d(s, (l.b - l.a).len()),
fmt_a(s, l.a.xy().angle_to(l.b.xy()))
));
let d = l.b - l.a;
t.push_str(&format!("\n Delta X = {}, Delta Y = {}, Delta Z = {}", fmt_d(s, d.x), fmt_d(s, d.y), fmt_d(s, d.z)));
}
EntityKind::Circle(c) => {
t.push_str(&format!(
"\n center point, X={} Y={} Z={}\n radius {}\n circumference {}\n area {}",
fmt_d(s, c.center.x),
fmt_d(s, c.center.y),
fmt_d(s, 0.0),
fmt_d(s, c.center.z),
fmt_d(s, c.radius),
fmt_d(s, c.radius * cadcraft_geom::TAU),
fmt_d(s, c.radius * c.radius * cadcraft_geom::PI)
@@ -139,7 +234,7 @@ fn list_text(s: &Session, h: Handle) -> String {
"\n center point, X={} Y={} Z={}\n radius {}\n start angle {}\n end angle {}",
fmt_d(s, a.center.x),
fmt_d(s, a.center.y),
fmt_d(s, 0.0),
fmt_d(s, a.center.z),
fmt_d(s, a.radius),
fmt_a(s, a.start),
fmt_a(s, a.end)
@@ -153,16 +248,20 @@ fn list_text(s: &Session, h: Handle) -> String {
} else {
t.push_str(&format!("\n length {}", fmt_d(s, pl.len())));
}
// The elevation, in world Z (down Z for a mirrored polyline).
let z = entity_z(e, Vec2::ZERO).unwrap_or(p.elevation);
t.push_str(&format!("\n elevation {}", fmt_d(s, z)));
for v in &p.vertices {
t.push_str(&format!("\n at point X={} Y={} Z={}", fmt_d(s, v.p.x), fmt_d(s, v.p.y), fmt_d(s, 0.0)));
t.push_str(&format!("\n at point X={} Y={} Z={}", fmt_d(s, v.p.x), fmt_d(s, v.p.y), fmt_d(s, z)));
}
}
EntityKind::Text(tx) => {
t.push_str(&format!(
"\n Style = \"{}\"\n start point, X={} Y={}\n height {}\n text {}\n rotation angle {}",
"\n Style = \"{}\"\n start point, X={} Y={} Z={}\n height {}\n text {}\n rotation angle {}",
tx.style,
fmt_d(s, tx.insert.x),
fmt_d(s, tx.insert.y),
fmt_d(s, tx.insert.z),
fmt_d(s, tx.height),
tx.value,
fmt_a(s, tx.rotation)
@@ -183,7 +282,7 @@ fn run_list(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "message": texts.join("\n\n") }))
}
fn area_of(pts: &[Vec2]) -> (f64, f64) {
pub(super) fn area_of(pts: &[Vec2]) -> (f64, f64) {
let a = cadcraft_geom::shoelace(pts).abs();
let n = pts.len();
let per: f64 = (0..n).filter_map(|i| Some(pts.get(i)?.dist(*pts.get((i + 1) % n)?))).sum();
@@ -205,14 +304,16 @@ fn area_text(s: &Session, a: f64, per: f64) -> String {
}
/// Area and perimeter of a closed object (AREA's Object option).
fn object_area(s: &Session, h: Handle) -> Result<(f64, f64)> {
pub(super) fn object_area(s: &Session, h: Handle) -> Result<(f64, f64)> {
let d = s.doc()?;
let e = d.entity(h).ok_or_else(|| bad("area", "no such object"))?;
Ok(match &e.kind {
EntityKind::Circle(c) => (c.radius * c.radius * cadcraft_geom::PI, c.radius * cadcraft_geom::TAU),
EntityKind::LwPolyline(pl) => {
// An open polyline is measured as if closed for the area, but its perimeter is its own length (AutoCAD's AREA help).
let g = Polyline { vertices: pl.vertices.clone(), closed: true };
(g.area().abs(), g.len())
let drawn = Polyline { vertices: pl.vertices.clone(), closed: pl.closed };
(g.area().abs(), drawn.len())
}
EntityKind::Ellipse(el) => {
let arc = cadcraft_geom::Ellipse { center: el.center.xy(), major: el.major.xy(), ratio: el.ratio, start: el.start, end: el.end };
@@ -221,7 +322,9 @@ fn object_area(s: &Session, h: Handle) -> Result<(f64, f64)> {
}
let a = el.major.xy().len();
let b = a * el.ratio;
(cadcraft_geom::PI * a * b, cadcraft_geom::PI * (3.0 * (a + b) - ((3.0 * a + b) * (a + 3.0 * b)).sqrt()))
// The perimeter integrated around the ellipse, not a closed-form estimate (those
// fall short as the ellipse flattens: 0.3% on a 100:1 one).
(cadcraft_geom::PI * a * b, arc.arc_length_from(0.0, cadcraft_geom::TAU))
}
EntityKind::Hatch(hh) => {
// Odd parity, as drawn: a loop nested inside an odd number of other loops is an unfilled island.
@@ -335,14 +438,17 @@ fn run_entities(s: &mut Session, p: &Value) -> Result<Value> {
})
.transpose()?;
let crossing = given("crossing").map_or(Ok(true), |v| v.as_bool().ok_or_else(|| bad(CMD, "`crossing` must be true or false")))?;
let selected = given("selected").map_or(Ok(false), |v| v.as_bool().ok_or_else(|| bad(CMD, "`selected` must be true or false")))?;
let limit = count("limit", 500)?;
let offset = count("offset", 0)?;
let st = s.state()?;
let d = &st.doc;
let Some(store) = d.space(&st.space) else { return Ok(json!({ "count": 0, "returned": 0, "entities": [] })) };
let tol = window.as_ref().map(crate::select::window_tol).unwrap_or(0.0);
let selection: std::collections::HashSet<Handle> = if selected { st.selection.iter().copied().collect() } else { Default::default() };
let matches: Vec<&std::sync::Arc<cadcraft_doc::Entity>> = store
.iter()
.filter(|e| !selected || selection.contains(&e.handle))
.filter(|e| ty.as_ref().is_none_or(|t| e.kind.type_name().to_ascii_lowercase() == *t || e.kind.dxf_name().to_ascii_lowercase() == *t))
.filter(|e| layer.as_ref().is_none_or(|l| e.common.layer.to_ascii_lowercase() == *l))
.filter(|e| window.as_ref().is_none_or(|w| crate::select::in_window(d, e, None, w, crossing, tol)))
@@ -427,7 +533,7 @@ fn run_texts(s: &mut Session, p: &Value) -> Result<Value> {
mod count;
#[derive(Default)]
struct DistM {
pub(super) struct DistM {
first: Option<Vec2>,
first_source: Option<crate::snap::ViewportPoint>,
/// Multiple points: the points so far, the first included.
@@ -553,7 +659,8 @@ impl Interactive for DistM {
(Some(first), Some(second)) if first.viewport == second.viewport => (first.point, second.point),
_ => (a, b),
};
let t = dist_text(s, a, b);
let (za, zb) = if self.first_source.is_some() || s.picked_viewport.is_some() { (0.0, 0.0) } else { (z_at(s, a), z_at(s, b)) };
let t = dist_text(s, Vec3::new(a.x, a.y, za), Vec3::new(b.x, b.y, zb));
for l in t.lines() {
s.echo(l.to_string());
}
@@ -600,7 +707,7 @@ impl Interactive for IdM {
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
if let Input::Point(p) = i {
let m = format!("X = {} Y = {} Z = {}", fmt_d(s, p.x), fmt_d(s, p.y), fmt_d(s, 0.0));
let m = id_text(s, Vec3::new(p.x, p.y, z_at(s, p)));
s.echo(m);
}
Ok(Step::Done)
@@ -662,7 +769,7 @@ enum AreaMode {
}
#[derive(Default)]
struct AreaM {
pub(super) struct AreaM {
pts: Vec<Vec2>,
mode: AreaMode,
total: f64,
+196
View File
@@ -0,0 +1,196 @@
//! SHAPES: geometry facts for every closed shape in the current space — perimeter, area, centroid,
//! winding, the first self-intersection and coincident vertices — for agents that read a drawing.
//! Read-only.
use std::collections::{BTreeMap, BTreeSet};
use cadcraft_doc::{Entity, EntityKind};
use cadcraft_geom::{Bounds2, Polyline, Vec2, intersect_segments};
use serde_json::{Value, json};
use super::super::*;
use super::{area_of, object_area};
use crate::{Result, Session};
const CMD: &str = "shapes";
pub const PARAMS: &str = "{type?, layer?, window?: [[x,y],[x,y]], crossing?: bool (default true; false = entirely inside), limit? (default 200), offset?, tolerance?: positive number (default 1e-9 × the extents diagonal; near-duplicate vertices and a first vertex equal to the last)} → {schema: 1, count (all closed shapes), returned, offset, truncated, shapes: [{handle, type, layer, perimeter, area (≥ 0), winding: ccw|cw, centroid: [x,y] | null (zero area), selfIntersection: [x,y] | null (the first crossing of two non-adjacent edges; null past 4000 edges), duplicateVertices: [{index, distance} (vertices index and index+1, wrapping)]}], skipped: {<type>: {count, reason}} (the other matches)}: closed polylines, circles, full ellipses and closed splines, arcs and bulges exact";
/// The facts SHAPES reports for one closed shape, or why an entity is not one.
enum Facts {
Shape { perimeter: f64, area: f64, signed: f64, centroid: Option<Vec2>, cross: Option<Vec2>, dups: Vec<(usize, f64)> },
Skip(&'static str),
}
pub fn run(s: &mut Session, p: &Value) -> Result<Value> {
let given = |k: &str| p.get(k).filter(|v| !v.is_null());
let text = |k: &str| -> Result<Option<String>> {
given(k).map(|v| v.as_str().map(str::to_ascii_lowercase).ok_or_else(|| bad(CMD, format!("`{k}` must be a string")))).transpose()
};
let count = |k: &str, default: usize| -> Result<usize> {
given(k).map_or(Ok(default), |v| {
v.as_u64().map(|n| usize::try_from(n).unwrap_or(usize::MAX)).ok_or_else(|| bad(CMD, format!("`{k}` must be a whole number, 0 or more")))
})
};
let ty = text("type")?;
let layer = text("layer")?;
let window = given("window")
.map(|w| {
let corners: Option<Vec<Vec2>> = w.as_array().filter(|a| a.len() == 2).and_then(|a| a.iter().map(point_value).collect());
match corners.as_deref() {
Some([a, b]) => Ok(Bounds2::new(*a, *b)),
_ => Err(bad(CMD, "`window` must be two corner points [[x0, y0], [x1, y1]]")),
}
})
.transpose()?;
let crossing = given("crossing").map_or(Ok(true), |v| v.as_bool().ok_or_else(|| bad(CMD, "`crossing` must be true or false")))?;
let limit = count("limit", 200)?;
let offset = count("offset", 0)?;
let tolerance = given("tolerance")
.map(|v| v.as_f64().filter(|t| t.is_finite() && *t > 0.0).ok_or_else(|| bad(CMD, "`tolerance` must be a positive number")))
.transpose()?;
let s: &Session = s;
let st = s.state()?;
let d = &st.doc;
let empty = || json!({ "schema": 1, "count": 0, "returned": 0, "offset": offset, "truncated": false, "shapes": [], "skipped": {} });
let Some(store) = d.space(&st.space) else { return Ok(empty()) };
let tol = tolerance.unwrap_or_else(|| {
let ext = d.extents(&st.space);
let diag = if ext.is_empty() { 0.0 } else { ext.min.dist(ext.max) };
(diag * 1e-9).max(1e-12)
});
let wtol = window.as_ref().map(crate::select::window_tol).unwrap_or(0.0);
let mut skipped: BTreeMap<&str, (usize, BTreeSet<&str>)> = BTreeMap::new();
let mut shapes: Vec<(&Entity, Facts)> = Vec::new();
for e in store
.iter()
.filter(|e| ty.as_ref().is_none_or(|t| e.kind.type_name().to_ascii_lowercase() == *t || e.kind.dxf_name().to_ascii_lowercase() == *t))
.filter(|e| layer.as_ref().is_none_or(|l| e.common.layer.to_ascii_lowercase() == *l))
.filter(|e| window.as_ref().is_none_or(|w| crate::select::in_window(d, e, None, w, crossing, wtol)))
{
match facts(s, e, tol)? {
Facts::Skip(why) => {
let entry = skipped.entry(e.kind.type_name()).or_default();
entry.0 += 1;
entry.1.insert(why);
}
f => shapes.push((e, f)),
}
}
let page: Vec<Value> = shapes.iter().skip(offset).take(limit).map(|(e, f)| shape_json(e, f)).collect();
let skipped: BTreeMap<&str, Value> =
skipped.into_iter().map(|(k, (n, why))| (k, json!({ "count": n, "reason": why.into_iter().collect::<Vec<_>>().join("; ") }))).collect();
Ok(json!({
"schema": 1,
"count": shapes.len(),
"returned": page.len(),
"offset": offset,
"truncated": page.len() < shapes.len().saturating_sub(offset),
"shapes": page,
"skipped": skipped,
}))
}
fn shape_json(e: &Entity, f: &Facts) -> Value {
let Facts::Shape { perimeter, area, signed, centroid, cross, dups } = f else { return Value::Null };
json!({
"handle": e.handle.hex(),
"type": e.kind.type_name(),
"layer": e.common.layer,
"perimeter": perimeter,
"area": area,
"winding": if *signed < 0.0 { "cw" } else { "ccw" },
"centroid": centroid.map(|c| [c.x, c.y]),
"selfIntersection": cross.map(|c| [c.x, c.y]),
"duplicateVertices": dups.iter().map(|(i, dist)| json!({ "index": i, "distance": dist })).collect::<Vec<_>>(),
})
}
fn facts(s: &Session, e: &Entity, tol: f64) -> Result<Facts> {
Ok(match &e.kind {
EntityKind::Circle(c) => {
let (area, perimeter) = object_area(s, e.handle)?;
Facts::Shape { perimeter, area, signed: area, centroid: Some(c.center.xy()), cross: None, dups: Vec::new() }
}
EntityKind::Ellipse(el) => {
let geom = cadcraft_geom::Ellipse { center: el.center.xy(), major: el.major.xy(), ratio: el.ratio, start: el.start, end: el.end };
if !geom.is_full() {
return Ok(Facts::Skip("open"));
}
let (area, perimeter) = object_area(s, e.handle)?;
Facts::Shape { perimeter, area, signed: area, centroid: Some(geom.center), cross: None, dups: Vec::new() }
}
EntityKind::LwPolyline(pl) => {
let mut vertices = pl.vertices.clone();
let ends_meet = match (vertices.first(), vertices.last()) {
(Some(a), Some(b)) => vertices.len() > 1 && a.p.dist(b.p) <= tol,
_ => false,
};
if !pl.closed {
if !ends_meet {
return Ok(Facts::Skip("open"));
}
// The last vertex repeats the first: the shape closes there.
vertices.pop();
}
if vertices.len() < 2 {
return Ok(Facts::Skip("fewer than two vertices"));
}
let g = Polyline { vertices, closed: true };
let signed = g.area();
let n = g.vertices.len();
let dups = (0..n)
.filter_map(|i| {
let dist = g.vertices.get(i)?.p.dist(g.vertices.get((i + 1) % n)?.p);
(dist <= tol).then_some((i, dist))
})
.collect();
Facts::Shape { perimeter: g.len(), area: signed.abs(), signed, centroid: g.centroid(), cross: first_crossing(&g), dups }
}
EntityKind::Spline(sp) => {
let mut pts = sp.tessellate(1e-3);
let ends_meet = match (pts.first(), pts.last()) {
(Some(a), Some(b)) => pts.len() > 2 && a.dist(*b) <= tol.max(1e-9),
_ => false,
};
if !sp.closed && !ends_meet {
return Ok(Facts::Skip("open"));
}
if ends_meet {
pts.pop();
}
if pts.len() < 3 {
return Ok(Facts::Skip("degenerate"));
}
let (area, perimeter) = area_of(&pts);
let g = Polyline::from_points(&pts, true);
Facts::Shape { perimeter, area, signed: g.area(), centroid: g.centroid(), cross: first_crossing(&g), dups: Vec::new() }
}
EntityKind::Arc(_) => Facts::Skip("open"),
_ => Facts::Skip("not a closed shape"),
})
}
/// Shapes with more edges than this report no selfIntersection.
const MAX_CROSSING_SEGMENTS: usize = 4_000;
/// The first crossing between two non-adjacent edges, in edge order.
fn first_crossing(g: &Polyline) -> Option<Vec2> {
let segs = g.segments();
let n = segs.len();
// The pairwise test is quadratic: past this many edges the answer is null, not a hang.
if n > MAX_CROSSING_SEGMENTS {
return None;
}
for (i, a) in segs.iter().enumerate() {
for (j, b) in segs.iter().enumerate().skip(i + 2) {
if i == 0 && j + 1 == n {
continue;
}
if let Some(x) = intersect_segments(a, b).first() {
return Some(*x);
}
}
}
None
}
+3 -1
View File
@@ -40,7 +40,9 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("layoff", "Layer Off", |s, p| set_obj_layers(s, p, |l| l.on = false))
.menu(&["Format", "Layer Tools", "Layer Off"])
.params("{handles?}")
.interactive(|_| Ok(Box::new(SelectRun::single("layoff", "LAYOFF", "Select an object on the layer to be turned off")))),
// Layer Off stays active until Enter or Esc, as in AutoCAD (#1418): one object per
// pick and the prompt comes straight back.
.interactive(|_| Ok(Box::new(SelectRun::repeat("layoff", "LAYOFF", "Select an object on the layer to be turned off")))),
CommandSpec::new("laylck", "Lock Layer", |s, p| set_obj_layers(s, p, |l| l.locked = true))
.menu(&["Format", "Layer Tools", "Lock Layer"])
.params("{handles?}")
+3 -3
View File
@@ -826,9 +826,9 @@ fn run_plot(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
#[cfg(not(target_arch = "wasm32"))]
{
let path = _path;
let tmp = format!("{path}.cadcraft-tmp");
std::fs::write(&tmp, &bytes).map_err(|e| bad(cmd, format!("{path}: {e}")))?;
std::fs::rename(&tmp, path).map_err(|e| bad(cmd, format!("{path}: {e}")))?;
// Exclusively create a fresh temporary file and rename it into place so a planted
// `<path>.cadcraft-tmp` symlink cannot redirect the plot write (CWE-59).
crate::atomic_write::write_atomic(std::path::Path::new(path), &bytes).map_err(|e| bad(cmd, format!("{path}: {e}")))?;
Ok(json!({ "path": path, "bytes": bytes.len(), "layout": layout, "pages": pages }))
}
#[cfg(target_arch = "wasm32")]
+39 -9
View File
@@ -121,31 +121,50 @@ impl SelectPhase {
}
/// "Select objects:", then run a JSON command on them as `{handles}`. With a pickfirst selection it
/// runs at once, exactly like the JSON form does on the current selection.
/// runs at once, exactly like the JSON form does on the current selection. A repeating run acts on
/// every pick and asks for the next object, the way AutoCAD's LAYOFF does.
pub struct SelectRun {
id: &'static str,
name: &'static str,
/// Single-object prompt (e.g. LAYFRZ's "Select an object on the layer to be frozen").
single: Option<&'static str>,
/// Stay active after each pick, until Enter or Esc (LAYOFF).
repeat: bool,
sel: SelectPhase,
}
impl SelectRun {
pub fn new(id: &'static str, name: &'static str) -> Self {
SelectRun { id, name, single: None, sel: SelectPhase::default() }
SelectRun { id, name, single: None, repeat: false, sel: SelectPhase::default() }
}
/// Pick one object, with a command-specific prompt.
pub fn single(id: &'static str, name: &'static str, msg: &'static str) -> Self {
SelectRun { single: Some(msg), ..SelectRun::new(id, name) }
}
fn run(&self, s: &mut Session, hs: &[Handle]) -> Result<Step> {
/// Get an object, act on it and ask again, until Enter or Esc: AutoCAD's Layer Off (LAYOFF)
/// turns off the layer of every object picked, one after another, in one command.
pub fn repeat(id: &'static str, name: &'static str, msg: &'static str) -> Self {
SelectRun { single: Some(msg), repeat: true, ..SelectRun::new(id, name) }
}
fn run(&self, s: &mut Session, hs: &[Handle]) -> Result<()> {
let spec = super::find_command(self.id).ok_or_else(|| EngineError::UnknownCommand(self.id.into()))?;
// Call the command body directly: the running command records the single undo step.
let r = (spec.run)(s, &json!({ "handles": hs.iter().map(|h| h.hex()).collect::<Vec<_>>() }))?;
if let Some(m) = r.get("message").and_then(Value::as_str) {
s.echo(m.to_string());
}
Ok(Step::Done)
Ok(())
}
/// One pick of a repeating run: act on it at once and keep the prompt up.
fn pick(&self, s: &mut Session, hs: &[Handle]) -> Result<Step> {
if hs.is_empty() {
s.echo("0 found");
return Ok(Step::Continue);
}
self.run(s, hs)?;
// Objects picked at the prompt don't stay selected afterwards.
s.set_selection(Vec::new());
Ok(Step::Continue)
}
}
@@ -157,26 +176,37 @@ impl Interactive for SelectRun {
self.sel = SelectPhase::begin(s);
if self.sel.done {
let hs = self.sel.picked.clone();
return self.run(s, &hs);
self.run(s, &hs)?;
return Ok(Step::Done);
}
self.sel.single = self.single.is_some();
// A repeating run acts on every pick, so no single pick may finish it.
self.sel.single = self.single.is_some() && !self.repeat;
Ok(Step::Continue)
}
fn prompt(&self, _s: &Session) -> Prompt {
match self.single {
Some(msg) if !self.sel.removing => Prompt::new(msg, Accept::SELECT),
// A repeating run keeps asking for the next object, whatever selection keywords were typed.
Some(msg) if self.repeat || !self.sel.removing => Prompt::new(msg, Accept::SELECT),
_ => self.sel.prompt(),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
// A repeating run acts on each pick and prompts again; Enter (or Esc) ends it.
if self.repeat {
return match i {
Input::Pick(hs) => self.pick(s, &hs),
Input::Enter => Ok(Step::Done),
other => self.sel.feed(s, &other).map(|_| Step::Continue),
};
}
match self.sel.feed(s, &i)? {
SelOutcome::More => Ok(Step::Continue),
SelOutcome::Empty => Ok(Step::Done),
SelOutcome::Done(hs) => {
// Objects picked at the prompt don't stay selected afterwards.
let step = self.run(s, &hs)?;
self.run(s, &hs)?;
s.set_selection(Vec::new());
Ok(step)
Ok(Step::Done)
}
}
}
+4
View File
@@ -31,6 +31,7 @@ mod overkill;
mod pline_opts;
pub mod plotters;
mod props;
pub use draw2::breakline::BreaklineOptions;
pub use props::MatchProps;
mod qselect;
mod selecttools;
@@ -39,6 +40,8 @@ mod spline_opts;
mod table;
mod tolerance;
mod trimextend;
mod txt2mtxt;
pub use txt2mtxt::Txt2MtxtOptions;
mod utility;
mod view;
@@ -200,6 +203,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(utility::specs());
v.extend(table::specs());
v.extend(tolerance::specs());
v.extend(txt2mtxt::specs());
v.extend(constraints::specs());
v.extend(lisp::specs());
v
+123 -36
View File
@@ -92,7 +92,7 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("join", "Join", run_join)
.menu(&["Modify", "Join"])
.alias(&["j"])
.params("{handles?}")
.params("{handles?, close?: bool (a single arc becomes its circle, an elliptical arc its full ellipse)}")
.interactive(|_| Ok(Box::new(SelectThen::new(Op::Join)))),
]
}
@@ -421,6 +421,8 @@ impl StretchM {
let d = s.doc()?;
let store = d.space(&s.space());
let word = t.trim().to_ascii_lowercase();
// `_C`, `_L`: the English name, as scripts write it.
let word = word.strip_prefix('_').unwrap_or(&word).to_string();
if let Some(m) = SelectMode::from_keyword(&word).filter(|m| m.kind != SelectKind::Group) {
self.mode = Some(m);
return Ok(Step::Continue);
@@ -584,10 +586,14 @@ pub(crate) fn offset_kind(k: &EntityKind, dist: f64, side: Vec2) -> Option<Entit
EntityKind::Ellipse(e) => {
// Approximate with a polyline offset of the tessellation.
let ge = cadcraft_geom::Ellipse { center: e.center.xy(), major: e.major.xy(), ratio: e.ratio, start: e.start, end: e.end };
// Inside = on the centre side of the full ellipse, even for an open arc.
let inside = {
let mut pts = Vec::new();
ge.tessellate(e.major.xy().len() * 1e-3, &mut pts);
cadcraft_geom::point_in_polygon(&pts, side)
let a = e.major.xy().len();
let b = a * e.ratio.abs();
let u = e.major.xy().normalized();
let d = side - e.center.xy();
let (x, y) = (d.dot(u) * b, d.dot(u.perp()) * a);
x * x + y * y < (a * b) * (a * b)
};
// offset_points shifts along the left normal, which points into a counter-clockwise ellipse.
let mut pts = Vec::new();
@@ -1397,6 +1403,16 @@ pub(crate) fn explode_kind(d: &cadcraft_doc::Drawing, e: &Entity) -> Option<Vec<
}
fn explode(s: &mut Session, hs: &[Handle]) -> Result<Vec<Handle>> {
explode_with(s, hs, explode_kind)
}
/// Replace each of `hs` (off locked layers) by the pieces `pieces` gives for it; objects it gives
/// none for are left alone. EXPLODE, and BURST with its own pieces for block references.
pub(crate) fn explode_with(
s: &mut Session,
hs: &[Handle],
pieces: impl Fn(&cadcraft_doc::Drawing, &Entity) -> Option<Vec<Entity>>,
) -> Result<Vec<Handle>> {
let space = s.space();
let mut out = Vec::new();
for h in hs {
@@ -1404,7 +1420,7 @@ fn explode(s: &mut Session, hs: &[Handle]) -> Result<Vec<Handle>> {
continue;
}
let Some(e) = s.doc()?.entity(*h).map(|e| (**e).clone()) else { continue };
let Some(parts) = explode_kind(s.doc()?, &e) else { continue };
let Some(parts) = pieces(s.doc()?, &e) else { continue };
// The pieces of an object are new objects: its xdata goes with it. A block reference's
// pieces are copies of the block's objects and keep theirs.
let own_pieces = !matches!(e.kind, EntityKind::Insert(_));
@@ -1431,6 +1447,16 @@ fn run_explode(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "handles": r.iter().map(|h| h.hex()).collect::<Vec<_>>() }))
}
/// Gives a rebuilt arc piece its source centre Z, or a rebuilt polyline piece its source elevation.
fn with_z(mut k: EntityKind, z: f64) -> EntityKind {
match &mut k {
EntityKind::Arc(a) => a.center.z = z,
EntityKind::LwPolyline(p) => p.elevation = z,
_ => {}
}
k
}
/// Break `h` between two points (or at one point when p1 == p2).
pub(crate) fn break_entity(s: &mut Session, h: Handle, p1: Vec2, p2: Vec2) -> Result<Vec<Handle>> {
if super::curves::is_locked(s, h) {
@@ -1444,12 +1470,13 @@ pub(crate) fn break_entity(s: &mut Session, h: Handle, p1: Vec2, p2: Vec2) -> Re
if t1 > t2 {
std::mem::swap(&mut t1, &mut t2);
}
let at3 = |t: f64| ln.at(t).to3(l.a.z + (l.b.z - l.a.z) * t);
let mut v = Vec::new();
if t1 > 1e-12 {
v.push(line(ln.a, ln.at(t1)));
v.push(EntityKind::Line(cadcraft_doc::Line { a: l.a, b: at3(t1) }));
}
if t2 < 1.0 - 1e-12 {
v.push(line(ln.at(t2), ln.b));
v.push(EntityKind::Line(cadcraft_doc::Line { a: at3(t2), b: l.b }));
}
v
}
@@ -1460,7 +1487,7 @@ pub(crate) fn break_entity(s: &mut Session, h: Handle, p1: Vec2, p2: Vec2) -> Re
let a1 = c.center.xy().angle_to(p1);
let a2 = c.center.xy().angle_to(p2);
// AutoCAD removes the CCW portion from the first to the second point.
vec![arc(&Arc::new(c.center.xy(), c.radius, a2, a1))]
vec![with_z(arc(&Arc::new(c.center.xy(), c.radius, a2, a1)), c.center.z)]
}
EntityKind::Arc(a) => {
let ga = Arc::new(a.center.xy(), a.radius, a.start, a.end);
@@ -1471,15 +1498,15 @@ pub(crate) fn break_entity(s: &mut Session, h: Handle, p1: Vec2, p2: Vec2) -> Re
}
let mut v = Vec::new();
if t1 > 1e-12 {
v.push(arc(&Arc::new(ga.center, ga.radius, ga.start, ga.start + t1)));
v.push(with_z(arc(&Arc::new(ga.center, ga.radius, ga.start, ga.start + t1)), a.center.z));
}
if t2 < ga.sweep() - 1e-12 {
v.push(arc(&Arc::new(ga.center, ga.radius, ga.start + t2, ga.end)));
v.push(with_z(arc(&Arc::new(ga.center, ga.radius, ga.start + t2, ga.end)), a.center.z));
}
v
}
EntityKind::LwPolyline(p) if !p.closed => {
let pl = Polyline { vertices: p.vertices.clone(), closed: false };
EntityKind::LwPolyline(p) => {
let pl = Polyline { vertices: p.vertices.clone(), closed: p.closed };
let segs = pl.segments();
let total: f64 = segs.iter().map(Segment::len).sum();
let lp = |q: Vec2| {
@@ -1496,17 +1523,28 @@ pub(crate) fn break_entity(s: &mut Session, h: Handle, p1: Vec2, p2: Vec2) -> Re
best.1
};
let (mut t1, mut t2) = (lp(p1), lp(p2));
if t1 > t2 {
std::mem::swap(&mut t1, &mut t2);
if p.closed {
if (t1 - t2).abs() < 1e-9 {
return Err(EngineError::Other("Cannot break a closed polyline at a single point.".into()));
}
// Like a circle: the stretch from the first point to the second, along the
// vertices, goes; what is left runs from the second point round to the first.
let doubled: Vec<Segment> = segs.iter().chain(segs.iter()).copied().collect();
let end = if t1 > t2 { t1 } else { t1 + total };
vec![with_z(lwpoly(sub_polyline(&doubled, t2, end), false), p.elevation)]
} else {
if t1 > t2 {
std::mem::swap(&mut t1, &mut t2);
}
let mut v = Vec::new();
if t1 > 1e-12 {
v.push(with_z(lwpoly(sub_polyline(&segs, 0.0, t1), false), p.elevation));
}
if t2 < total - 1e-12 {
v.push(with_z(lwpoly(sub_polyline(&segs, t2, total), false), p.elevation));
}
v
}
let mut v = Vec::new();
if t1 > 1e-12 {
v.push(lwpoly(sub_polyline(&segs, 0.0, t1), false));
}
if t2 < total - 1e-12 {
v.push(lwpoly(sub_polyline(&segs, t2, total), false));
}
v
}
_ => return Err(EngineError::Other("Cannot break that object.".into())),
};
@@ -1624,6 +1662,10 @@ pub(crate) fn join(s: &mut Session, hs: &[Handle]) -> Result<Option<Handle>> {
fn run_join(s: &mut Session, p: &Value) -> Result<Value> {
let hs = targets(s, p)?;
if p.get("close").and_then(Value::as_bool) == Some(true) {
let h = super::modify2::join_close(s, &hs)?;
return Ok(json!({ "handle": h.hex() }));
}
let r = join(s, &hs)?;
Ok(json!({ "handle": r.map(|h| h.hex()) }))
}
@@ -1660,6 +1702,8 @@ struct SelectThen {
ref_len: Option<f64>,
copy_mode: bool,
ask_erase: bool,
/// JOIN of a single arc or elliptical arc: the prompt asks for arcs to join to it, or cLose.
join_source: Option<Handle>,
}
impl SelectThen {
@@ -1676,6 +1720,7 @@ impl SelectThen {
ref_len: None,
copy_mode: false,
ask_erase: false,
join_source: None,
}
}
fn apply(&mut self, s: &mut Session) -> Result<Step> {
@@ -1691,23 +1736,57 @@ impl SelectThen {
Ok(Step::Done)
}
Op::Join => {
match join(s, &self.objs) {
Ok(h) => {
// Objects outside the joined chain are kept, so count only the ones JOIN consumed
// (and the source object an ellipse or spline join keeps).
let d = s.doc()?;
let n = self.objs.iter().filter(|o| d.entity(**o).is_none() || h == Some(**o)).count();
let what = match h.and_then(|h| d.entity(h)).map(|e| &e.kind) {
Some(EntityKind::Spline(_)) => "spline",
Some(EntityKind::Ellipse(_)) => "ellipse",
_ => "polyline",
};
s.echo(format!("{n} objects joined into 1 {what}"))
}
Err(e) => s.echo(e.to_string()),
if let [h] = self.objs[..]
&& s.doc()?.entity(h).is_some_and(|e| super::modify2::closed_kind(&e.kind).is_some())
{
self.join_source = Some(h);
return Ok(Step::Continue);
}
self.join_now(s)
}
_ => Ok(Step::Continue),
}
}
/// JOIN the selected objects and report what they became.
fn join_now(&mut self, s: &mut Session) -> Result<Step> {
match join(s, &self.objs) {
Ok(h) => {
// Objects outside the joined chain are kept, so count only the ones JOIN consumed
// (and the source object an ellipse or spline join keeps).
let d = s.doc()?;
let n = self.objs.iter().filter(|o| d.entity(**o).is_none() || h == Some(**o)).count();
let what = match h.and_then(|h| d.entity(h)).map(|e| &e.kind) {
Some(EntityKind::Spline(_)) => "spline",
Some(EntityKind::Ellipse(_)) => "ellipse",
_ => "polyline",
};
s.echo(format!("{n} objects joined into 1 {what}"))
}
Err(e) => s.echo(e.to_string()),
}
Ok(Step::Done)
}
/// JOIN of a single arc or elliptical arc: more arcs picked join to it on Enter, cLose makes
/// it its circle or full ellipse.
fn join_source_input(&mut self, s: &mut Session, src: Handle, i: Input) -> Result<Step> {
match i {
Input::Keyword(k) if k == "cLose" => {
let ellipse = matches!(s.doc()?.entity(src).map(|e| &e.kind), Some(EntityKind::Ellipse(_)));
super::modify2::join_close(s, &[src])?;
s.echo(if ellipse { "The elliptical arc is now an ellipse." } else { "The arc is now a circle." });
Ok(Step::Done)
}
Input::Point(p) => {
if let Some(h) = super::curves::pick_at(s, p)
&& !self.objs.contains(&h)
{
self.objs.push(h);
}
Ok(Step::Continue)
}
Input::Enter => self.join_now(s),
_ => Ok(Step::Continue),
}
}
@@ -1892,6 +1971,11 @@ impl Interactive for SelectThen {
if self.ask_erase {
return Prompt::new("Erase source objects?", Accept::TEXT).kw(&["Yes", "No"]).default("No");
}
if let Some(src) = self.join_source {
let ellipse = s.doc().is_ok_and(|d| matches!(d.entity(src).map(|e| &e.kind), Some(EntityKind::Ellipse(_))));
let msg = if ellipse { "Select elliptical arcs to join to source" } else { "Select arcs to join to source" };
return Prompt::new(msg, Accept::POINT).kw(&["cLose"]);
}
let k = self.pts.len();
let base = self.pts.first().copied();
if self.op == Op::Scale && self.reference {
@@ -1939,6 +2023,9 @@ impl Interactive for SelectThen {
}
}
}
if let Some(src) = self.join_source {
return self.join_source_input(s, src, i);
}
if self.ask_erase {
let yes = matches!(&i, Input::Keyword(k) if k == "Yes") || matches!(&i, Input::Text(t) if t.trim().to_ascii_lowercase().starts_with('y'));
let (a, b) = (self.pts[0], self.pts[1]);
+16 -4
View File
@@ -1,4 +1,4 @@
//! Modify menu depth (M2): PEDIT, LENGTHEN, ALIGN, BLEND, REVERSE, SPLINEDIT, NCOPY, CHSPACE,
//! Modify menu depth (M2): PEDIT, LENGTHEN, ALIGN, BLEND, REVERSE, SPLINEDIT, NCOPY, MOCORO, CHSPACE,
//! FLATTEN, plus the fillet/trim/extend extensions used by modify.rs (arcs, polylines,
//! ellipses and splines).
@@ -16,7 +16,8 @@ use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, St
mod curve_edits;
mod fillet_pline;
pub(crate) use curve_edits::join_curves;
mod mocoro;
pub(crate) use curve_edits::{closed_kind, join_close, join_curves};
pub(crate) use fillet_pline::{ChamferDistances, fillet_pline};
pub fn specs() -> Vec<CommandSpec> {
@@ -61,6 +62,10 @@ pub fn specs() -> Vec<CommandSpec> {
.menu(&["Modify", "Copy Nested Objects"])
.params("{handle (block reference), pick?: [x,y] (nearest nested object; default all), delta? | from,to}")
.interactive(|_| Ok(Box::new(NcopyM::default()))),
CommandSpec::new("mocoro", "Move/Copy/Rotate", mocoro::run_mocoro)
.menu(&["Tools", "Express Tools", "Move/Copy/Rotate"])
.params("{handles?, base: [x,y], ops: [{move: [x,y]} | {copy: [x,y]} | {rotate: degrees} | {scale: factor} | {base: [x,y]}]} → {handles (copies)}")
.interactive(|_| Ok(Box::new(mocoro::MocoroM::default()))),
CommandSpec::new("chspace", "Change Space", run_chspace)
.menu(&["Modify", "Change Space"])
.params("{handles?, to?: \"model\" | layout name, viewport?: handle}")
@@ -761,7 +766,13 @@ pub(crate) fn lengthen_kind(k: &EntityKind, pick: Option<Vec2>, mode: LenMode) -
return Err(too_short());
}
let ne = fixed + dir * nl;
Ok(if at_b { line(a, ne) } else { line(ne, b) })
let mut out = l.clone();
if at_b {
out.b = ne.to3(l.b.z);
} else {
out.a = ne.to3(l.a.z);
}
Ok(EntityKind::Line(out))
}
EntityKind::Arc(ar) => {
let g = Arc::new(ar.center.xy(), ar.radius, ar.start, ar.end);
@@ -782,7 +793,8 @@ pub(crate) fn lengthen_kind(k: &EntityKind, pick: Option<Vec2>, mode: LenMode) -
if !(ns > 1e-12 && ns < TAU - 1e-9) {
return Err(other("The new arc would be zero length or a full circle."));
}
Ok(if at_end { arc(&Arc::new(g.center, g.radius, g.start, g.start + ns)) } else { arc(&Arc::new(g.center, g.radius, g.end - ns, g.end)) })
let na = if at_end { Arc::new(g.center, g.radius, g.start, g.start + ns) } else { Arc::new(g.center, g.radius, g.end - ns, g.end) };
Ok(EntityKind::Arc(cadcraft_doc::Arc { radius: na.radius, start: na.start, end: na.end, ..ar.clone() }))
}
EntityKind::LwPolyline(p) if !p.closed && p.vertices.len() >= 2 => {
let total = Polyline { vertices: p.vertices.clone(), closed: false }.len();
@@ -316,6 +316,32 @@ pub(crate) fn join_curves(s: &mut Session, hs: &[Handle]) -> Result<Option<Handl
Ok(Some(keep))
}
/// What JOIN's Close option makes of a single arc (its circle) or elliptical arc (its full
/// ellipse); `None` for anything else.
pub(crate) fn closed_kind(k: &EntityKind) -> Option<EntityKind> {
match k {
EntityKind::Arc(a) => Some(EntityKind::Circle(cadcraft_doc::Circle { center: a.center, radius: a.radius })),
EntityKind::Ellipse(e) if !ellipse_of(e).is_full() => Some(ellipse_kind(&Ellipse { start: 0.0, end: TAU, ..ellipse_of(e) })),
_ => None,
}
}
/// JOIN Close: the single arc or elliptical arc in `hs` becomes its circle or full ellipse,
/// keeping its handle.
pub(crate) fn join_close(s: &mut Session, hs: &[Handle]) -> Result<Handle> {
let mut seen = std::collections::HashSet::new();
let hs: Vec<Handle> = hs.iter().copied().filter(|h| seen.insert(*h)).collect();
let refuse = || other("Close works on a single arc or elliptical arc.");
let [h] = hs[..] else { return Err(refuse()) };
let k = s.doc()?.entity(h).and_then(|e| closed_kind(&e.kind)).ok_or_else(refuse)?;
if curves::is_locked(s, h) {
return Err(other("An object is on a locked layer."));
}
set_kind(s, h, k)?;
s.set_selection(Vec::new());
Ok(h)
}
// ---------------- FILLET with elliptical arcs and splines ----------------
/// An entity as a tangency target for FILLET (lines infinite, arcs as their circle, elliptical
+293
View File
@@ -0,0 +1,293 @@
//! MOCORO (Express Tools Move/Copy/Rotate): select objects and a base point once, then Move,
//! Copy, Rotate, Scale, Base and Undo at one option prompt until eXit.
//!
//! Copy leaves the selected objects where they are and adds copies: the options after it act on
//! the originals, from the same base point. Move carries the base point along, so a later Rotate
//! or Scale turns about the same spot on the objects. The whole command is one undo step; Undo
//! inside it steps back one option at a time (Base included), up to the last 1000.
use std::sync::Arc;
use cadcraft_doc::{Drawing, EntityKind, Handle};
use cadcraft_geom::{Mat3, Vec2};
use serde_json::{Value, json};
use super::other;
use crate::cmd::curves::KW;
use crate::cmd::helpers::line;
use crate::cmd::machines::{SelOutcome, SelectPhase, number};
use crate::cmd::modify::transform_entities;
use crate::cmd::{bad, point_req, point_value, targets};
use crate::prompt::Drag;
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
/// Most options one JSON call runs.
const MAX_OPS: usize = 10_000;
/// Most options Undo can step back through; the oldest are dropped past it.
const MAX_HISTORY: usize = 1000;
#[derive(Clone, Copy, Debug, PartialEq)]
enum Op {
Move(Vec2),
Copy(Vec2),
/// Radians, counterclockwise.
Rotate(f64),
Scale(f64),
Base(Vec2),
}
fn positive_factor(f: f64) -> Option<f64> {
(f.is_finite() && f > 1e-12).then_some(f)
}
/// One `ops` entry: an object with exactly one of move, copy, rotate (degrees), scale or base.
fn parse_op(i: usize, v: &Value) -> Result<Op> {
let err = |m: &str| bad("mocoro", format!("`ops[{i}]`: {m}"));
let Some(o) = v.as_object().filter(|o| o.len() == 1) else {
return Err(err("one of {move|copy|base: [x,y]} | {rotate: degrees} | {scale: factor}"));
};
let Some((k, v)) = o.iter().next() else { return Err(err("empty")) };
let point = || point_value(v).ok_or_else(|| err("a point [x, y] is required"));
match k.as_str() {
"move" => Ok(Op::Move(point()?)),
"copy" => Ok(Op::Copy(point()?)),
"base" => Ok(Op::Base(point()?)),
"rotate" => v.as_f64().filter(|a| a.is_finite()).map(|a| Op::Rotate(a.to_radians())).ok_or_else(|| err("`rotate` is an angle in degrees")),
"scale" => v.as_f64().and_then(positive_factor).map(Op::Scale).ok_or_else(|| err("`scale` is a factor above zero")),
_ => Err(err("unknown option (move, copy, rotate, scale, base)")),
}
}
/// How many of `objs` are still in the drawing, and how many of those are on a locked layer
/// (they don't move, rotate or scale; copies are made).
fn counts(s: &Session, objs: &[Handle]) -> Result<(usize, usize)> {
let d = s.doc()?;
let live: Vec<_> = objs.iter().filter_map(|h| d.entity(*h)).collect();
let locked = live.iter().filter(|e| d.layer(&e.common.layer).is_some_and(|l| l.locked)).count();
Ok((live.len(), locked))
}
fn locked_message(n: usize) -> String {
if n == 1 { "1 object was on a locked layer.".into() } else { format!("{n} objects were on a locked layer.") }
}
/// Apply one option to the objects; returns the copies made. The drawing is not touched when
/// nothing would change (objects gone or all locked), so no empty undo step is recorded.
fn apply(s: &mut Session, objs: &[Handle], base: &mut Vec2, op: Op) -> Result<Vec<Handle>> {
let b = *base;
let (live, locked) = counts(s, objs)?;
let m = match op {
Op::Base(p) => {
*base = p;
return Ok(Vec::new());
}
Op::Copy(p) if live > 0 => return transform_entities(s, objs, &Mat3::translate(p - b), true),
Op::Copy(_) => return Ok(Vec::new()),
Op::Move(p) => Mat3::translate(p - b),
Op::Rotate(a) => Mat3::rotate_about(b, a),
Op::Scale(f) => Mat3::scale_about(b, f),
};
if locked > 0 {
s.echo(locked_message(locked));
}
if locked < live {
transform_entities(s, objs, &m, false)?;
if let Op::Move(p) = op {
*base = p;
}
}
Ok(Vec::new())
}
pub(super) fn run_mocoro(s: &mut Session, p: &Value) -> Result<Value> {
let objs = targets(s, p)?;
let mut base = point_req("mocoro", p, "base")?;
let list = p.get("ops").and_then(Value::as_array).ok_or_else(|| bad("mocoro", "`ops` (array) is required"))?;
if list.len() > MAX_OPS {
return Err(bad("mocoro", format!("at most {MAX_OPS} `ops`")));
}
// Every option is checked before the drawing changes.
let ops = list.iter().enumerate().map(|(i, v)| parse_op(i, v)).collect::<Result<Vec<_>>>()?;
let mut copies = Vec::new();
for op in ops {
copies.extend(apply(s, &objs, &mut base, op)?);
}
Ok(json!({ "handles": copies.iter().map(|h| h.hex()).collect::<Vec<_>>() }))
}
/// The prompt MOCORO is at.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
enum Mode {
#[default]
Select,
BasePoint,
Options,
Move,
Copy,
Rotate,
Scale,
Base,
}
#[derive(Default)]
pub(super) struct MocoroM {
sel: SelectPhase,
objs: Vec<Handle>,
base: Vec2,
mode: Mode,
/// The drawing and base point before each option that changed either, for Undo.
history: Vec<(Arc<Drawing>, Vec2)>,
}
impl MocoroM {
fn run(&mut self, s: &mut Session, op: Op) -> Result<Step> {
let before = s.state()?.doc.clone();
let base = self.base;
apply(s, &self.objs, &mut self.base, op)?;
if !Arc::ptr_eq(&before, &s.state()?.doc) || self.base != base {
if self.history.len() >= MAX_HISTORY {
self.history.remove(0);
}
self.history.push((before, base));
}
self.mode = Mode::Options;
Ok(Step::Continue)
}
fn undo(&mut self, s: &mut Session) -> Result<Step> {
match self.history.pop() {
Some((doc, base)) => {
// Settings changed meanwhile (a transparent command) stay.
let header = s.doc()?.header.clone();
s.state_mut()?.doc = doc;
if s.doc()?.header != header {
s.doc_mut()?.header = header;
}
self.base = base;
}
None => s.echo("Nothing to undo."),
}
Ok(Step::Continue)
}
/// The transform under the cursor in an option that drags the objects.
fn matrix(&self, c: Vec2) -> Option<Mat3> {
let b = self.base;
match self.mode {
Mode::Move | Mode::Copy => Some(Mat3::translate(c - b)),
Mode::Rotate => Some(Mat3::rotate_about(b, b.angle_to(c))),
Mode::Scale => positive_factor(b.dist(c)).map(|f| Mat3::scale_about(b, f)),
_ => None,
}
}
}
impl Interactive for MocoroM {
fn name(&self) -> &'static str {
"MOCORO"
}
fn begin(&mut self, s: &mut Session) -> Result<Step> {
self.sel = SelectPhase::begin(s);
if self.sel.done {
self.objs = self.sel.picked.clone();
self.mode = Mode::BasePoint;
}
Ok(Step::Continue)
}
fn prompt(&self, _s: &Session) -> Prompt {
let b = self.base;
match self.mode {
Mode::Select => self.sel.prompt(),
Mode::BasePoint => Prompt::new("Specify base point", Accept::POINT),
Mode::Options => Prompt::new("Enter an option", KW).kw(&["Move", "Copy", "Rotate", "Scale", "Base", "Undo", "eXit"]).default("eXit"),
Mode::Move | Mode::Copy => Prompt::new("Specify second point", Accept::POINT).base(b),
Mode::Rotate => Prompt::new("Specify rotation angle", Accept::POINT_OR_NUMBER).base(b),
Mode::Scale => Prompt::new("Specify scale factor", Accept::POINT_OR_NUMBER).base(b),
Mode::Base => Prompt::new("Specify base point", Accept::POINT).base(b),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
match (self.mode, i) {
(Mode::Select, i) => match self.sel.feed(s, &i)? {
SelOutcome::More => Ok(Step::Continue),
SelOutcome::Empty => Ok(Step::Done),
SelOutcome::Done(hs) => {
self.objs = hs;
self.mode = Mode::BasePoint;
Ok(Step::Continue)
}
},
(Mode::BasePoint, Input::Point(p)) => {
self.base = p;
self.mode = Mode::Options;
Ok(Step::Continue)
}
(Mode::BasePoint, Input::Enter) => {
s.set_selection(Vec::new());
Ok(Step::Done)
}
(Mode::Options, Input::Keyword(k)) => {
self.mode = match k.as_str() {
"Move" => Mode::Move,
"Copy" => Mode::Copy,
"Rotate" => Mode::Rotate,
"Scale" => Mode::Scale,
"Base" => Mode::Base,
"Undo" => return self.undo(s),
_ => {
s.set_selection(Vec::new());
return Ok(Step::Done);
}
};
Ok(Step::Continue)
}
(Mode::Options, Input::Enter) => {
s.set_selection(Vec::new());
Ok(Step::Done)
}
(Mode::Options, _) => Err(other("Invalid option keyword.")),
// Enter in an option goes back to the option prompt without changing anything.
(Mode::Move | Mode::Copy | Mode::Rotate | Mode::Scale | Mode::Base, Input::Enter) => {
self.mode = Mode::Options;
Ok(Step::Continue)
}
(Mode::Move, Input::Point(p)) => self.run(s, Op::Move(p)),
(Mode::Copy, Input::Point(p)) => self.run(s, Op::Copy(p)),
(Mode::Base, Input::Point(p)) => self.run(s, Op::Base(p)),
(Mode::Rotate, Input::Point(p)) => self.run(s, Op::Rotate(self.base.angle_to(p))),
(Mode::Rotate, Input::Text(t)) => {
let a = s.angle_settings().rotation(&t).filter(|a| a.is_finite()).ok_or_else(|| other("Requires an angle or point."))?;
self.run(s, Op::Rotate(a))
}
(Mode::Scale, Input::Point(p)) => {
let f = positive_factor(self.base.dist(p)).ok_or_else(|| other("Requires a positive number."))?;
self.run(s, Op::Scale(f))
}
(Mode::Scale, Input::Text(t)) => {
let f = number(&t).and_then(positive_factor).ok_or_else(|| other("Requires a positive number."))?;
self.run(s, Op::Scale(f))
}
_ => Ok(Step::Continue),
}
}
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
let (Some(m), Ok(d)) = (self.matrix(c), s.doc()) else { return Vec::new() };
let mut out: Vec<EntityKind> = self
.objs
.iter()
.take(500)
.filter_map(|h| d.entity(*h))
.map(|e| {
let mut k = e.kind.clone();
k.transform(&m);
k
})
.collect();
out.push(line(self.base, c));
out
}
fn drag(&self, _s: &Session, c: Vec2) -> Option<Drag> {
let matrix = self.matrix(c)?;
Some(Drag { handles: self.objs.clone(), matrix, extra: vec![line(self.base, c)], clipboard: false })
}
}
+48
View File
@@ -214,6 +214,39 @@ fn lengthen_json() {
assert!(near(poly(&kind(&s, &p)).vertices[0].p, -5.0, 10.0));
}
#[test]
fn lengthen_keeps_elevation() {
let mut s = Session::new();
let l = h(&s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap());
let a = h(&s.execute("arc", &json!({"center": [0, 0], "radius": 1, "start": 0, "end": 90})).unwrap());
let doc = s.doc_mut().unwrap();
doc.modify_entity(Handle::parse_hex(&l).unwrap(), |e| {
if let EntityKind::Line(x) = &mut e.kind {
x.a.z = 5.0;
x.b.z = 5.0;
}
})
.unwrap();
doc.modify_entity(Handle::parse_hex(&a).unwrap(), |e| {
if let EntityKind::Arc(x) = &mut e.kind {
x.center.z = 3.0;
}
})
.unwrap();
s.execute("lengthen", &json!({"handle": l, "delta": 2})).unwrap();
let EntityKind::Line(x) = kind(&s, &l) else { panic!("not a line") };
assert_eq!((x.a.x, x.a.y, x.a.z), (0.0, 0.0, 5.0));
assert_eq!((x.b.x, x.b.y, x.b.z), (12.0, 0.0, 5.0));
s.execute("lengthen", &json!({"handle": l, "delta": 1, "pick": [0, 0]})).unwrap();
let EntityKind::Line(x) = kind(&s, &l) else { panic!("not a line") };
assert_eq!((x.a.x, x.a.y, x.a.z), (-1.0, 0.0, 5.0));
assert_eq!((x.b.x, x.b.y, x.b.z), (12.0, 0.0, 5.0));
s.execute("lengthen", &json!({"handle": a, "deltaAngle": 10})).unwrap();
let EntityKind::Arc(x) = kind(&s, &a) else { panic!("not an arc") };
assert_eq!(x.center.z, 3.0);
assert!((arc_of(&kind(&s, &a)).sweep() - 100f64.to_radians()).abs() < 1e-9);
}
#[test]
fn lengthen_command_line() {
let mut s = Session::new();
@@ -721,3 +754,18 @@ fn offset_polyline_arc_bulge_follows_joined_endpoints() {
assert!(near(arc.center, 10.0, 5.0), "arc centre drifted to {:?}", arc.center);
assert!((arc.radius - 4.0).abs() < 1e-6, "radius {}", arc.radius);
}
#[test]
fn offset_elliptical_arc_follows_picked_side() {
let max_x = |side: Value| {
let mut s = Session::new();
let e = h(&s.execute("ellipse", &json!({"center": [0, 0], "major": [10, 0], "ratio": 0.5, "start": 0, "end": 90})).unwrap());
s.execute("offset", &json!({"handle": e, "distance": 1, "side": side})).unwrap();
let p = poly(&last(&s));
p.vertices.iter().map(|v| v.p.x).fold(f64::MIN, f64::max)
};
let centre = max_x(json!([0, 0]));
assert!((centre - 9.0).abs() < 0.05, "centre side went outward: max x = {centre}");
let outer = max_x(json!([20, 20]));
assert!((outer - 11.0).abs() < 0.05, "outer side went inward: max x = {outer}");
}
+3 -1
View File
@@ -50,6 +50,7 @@ pub const PROFILE_VARS: &[&str] = &[
"CURSORSIZE",
"PICKFIRST",
"PICKADD",
"PICKDRAG",
"MAXARRAY",
];
@@ -61,7 +62,7 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("options", "Options...", run_options)
.menu(&["Tools", "Options..."])
.alias(&["op", "preferences"])
.params("{reset?: bool, OSMODE?, PICKBOX?, APERTURE?, GRIPSIZE?, CURSORSIZE?, PICKFIRST?, PICKADD?, …: any profile variable}")
.params("{reset?: bool, OSMODE?, PICKBOX?, APERTURE?, GRIPSIZE?, CURSORSIZE?, PICKFIRST?, PICKADD?, PICKDRAG?: 0 (two clicks) | 2 (clicks or drag), …: any profile variable}")
.enabled(always)
.noundo(),
]
@@ -141,6 +142,7 @@ pub fn reset(s: &mut Session) {
st.cursorsize = d.cursorsize;
st.pickfirst = d.pickfirst;
st.pickadd = d.pickadd;
st.pickdrag = d.pickdrag;
st.maxarray = d.maxarray;
}
+3 -3
View File
@@ -90,19 +90,19 @@ impl PlineAsk {
PlineAsk::Angle | PlineAsk::CenterAngle(_) | PlineAsk::RadiusAngle(_) => Prompt::new("Specify included angle", Accept::NUMBER),
PlineAsk::AngleEnd(_) => end(&["CEnter", "Radius"]),
PlineAsk::AngleCenter(_) | PlineAsk::Center => Prompt::new("Specify center point of arc", Accept::POINT).base(st),
PlineAsk::AngleRadius(_) | PlineAsk::Radius => Prompt::new("Specify radius of arc", Accept::POINT_OR_NUMBER).base(st),
PlineAsk::AngleRadius(_) | PlineAsk::Radius => Prompt::new("Specify radius of arc", Accept::POINT_OR_NUMBER).base(st).distance(),
PlineAsk::Chord(a, _) => {
let d = shown_direction(s, chord_default(tangent, a));
Prompt::new("Specify direction of chord for arc", Accept::POINT_OR_NUMBER).base(st).default(d)
}
PlineAsk::CenterEnd(_) => end(&["Angle", "Length"]),
PlineAsk::CenterLength(_) => Prompt::new("Specify length of chord", Accept::POINT_OR_NUMBER).base(st),
PlineAsk::CenterLength(_) => Prompt::new("Specify length of chord", Accept::POINT_OR_NUMBER).base(st).distance(),
PlineAsk::Direction => Prompt::new("Specify the tangent direction for the start point of arc", Accept::POINT_OR_NUMBER).base(st),
PlineAsk::DirectionEnd(_) => Prompt::new("Specify endpoint of the arc", Accept::POINT).base(st),
PlineAsk::RadiusEnd(_) => end(&["Angle"]),
PlineAsk::Second => Prompt::new("Specify second point on arc", Accept::POINT).base(st),
PlineAsk::SecondEnd(_) => Prompt::new("Specify end point of arc", Accept::POINT).base(st),
PlineAsk::Length => Prompt::new("Specify length of line", Accept::POINT_OR_NUMBER).base(st),
PlineAsk::Length => Prompt::new("Specify length of line", Accept::POINT_OR_NUMBER).base(st).distance(),
}
}
+61 -3
View File
@@ -11,12 +11,12 @@ use cadcraft_geom::Vec3;
use serde_json::{Value, json};
use super::*;
use crate::{Result, Session};
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("properties", "Properties", run_get).menu(&["Modify", "Properties"]).alias(&["pr", "props", "ch"]).params("{handles?} → properties of the selection").noundo(),
CommandSpec::new("properties.set", "Set Properties", run_set).params("{handles?, layer?, color?, linetype?, lineweight?, ltscale?, transparency?: \"ByLayer\" | \"ByBlock\" | 0..90, visible?, <geometry fields: radius, center, start, end, text, height, rotation…>}"),
CommandSpec::new("properties.set", "Set Properties", run_set).params("{handles?, layer?, color?, linetype?, lineweight?, ltscale?, transparency?: \"ByLayer\" | \"ByBlock\" | 0..90, visible?, <geometry fields: radius, center, start, end, text, height, rotation…>; dimensions: textOverride, dimStyle, textHeight, arrowSize, dimScale, precision (per-dimension overrides)}"),
CommandSpec::new("matchprop", "Match Properties", matchprop::run_matchprop)
.menu(&["Modify", "Match Properties"])
.alias(&["ma", "painter"])
@@ -30,7 +30,7 @@ pub fn specs() -> Vec<CommandSpec> {
.noundo(),
CommandSpec::new("-linetype", "Linetype", run_linetype).params("{current?, load?, file? | text?, reload?} (as LINETYPE)").interactive(|_| Ok(Box::new(linfile::DashLinetype::new()))),
CommandSpec::new("lweight", "Lineweight...", run_lweight).menu(&["Format", "Lineweight..."]).alias(&["lw", "lineweight"]).params("{lineweight: mm | ByLayer}"),
CommandSpec::new("ltscale", "Linetype Scale", run_ltscale).alias(&["lts"]).params("{scale}"),
CommandSpec::new("ltscale", "Linetype Scale", run_ltscale).alias(&["lts"]).params("{scale}").interactive(|_| Ok(Box::new(LtscaleM))),
CommandSpec::new("units", "Units...", run_units).menu(&["Format", "Units..."]).alias(&["un"]).params("{lunits?: 1..5, luprec?: 0..8, aunits?: 0..4, auprec?: 0..8, insunits?}"),
CommandSpec::new("limits", "Drawing Limits", run_limits).menu(&["Format", "Drawing Limits"]).params("{min: [x,y], max: [x,y]}"),
CommandSpec::new("style", "Text Style...", run_style)
@@ -203,6 +203,7 @@ fn run_set(s: &mut Session, p: &Value) -> Result<Value> {
let dim_style = str_param(p, "dimStyle")
.map(|n| d.dim_style(n).map(|st| st.name.clone()).ok_or_else(|| bad("properties.set", format!("dimension style `{n}` not found"))))
.transpose()?;
let dim_over = dim_overrides(p)?;
let anno_scale = d.current_anno_scale();
for h in &hs {
d.modify_entity(*h, |e| {
@@ -374,6 +375,10 @@ fn run_set(s: &mut Session, p: &Value) -> Result<Value> {
dm.style.clone_from(st);
dm.block = None;
}
if !dim_over.is_empty() {
dm.overrides.extend(dim_over.clone());
dm.block = None;
}
}
EntityKind::Hatch(h) => {
if let Some(v) = str_param(p, "pattern") {
@@ -471,6 +476,39 @@ fn run_ltscale(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "message": "Regenerating model." }))
}
/// Typed LTSCALE: "Enter new linetype scale factor <1.0000>:".
struct LtscaleM;
impl Interactive for LtscaleM {
fn name(&self) -> &'static str {
"LTSCALE"
}
fn prompt(&self, s: &Session) -> Prompt {
let cur = s.doc().map(|d| d.header.f64("LTSCALE", 1.0)).unwrap_or(1.0);
Prompt::new("Enter new linetype scale factor", Accept::NUMBER).default(format!("{cur:.4}"))
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
let text = match i {
Input::Enter | Input::Cancel => return Ok(Step::Done),
Input::Text(t) => t,
_ => return Ok(Step::Continue),
};
let scale = text.trim().parse::<f64>().ok().filter(|v| v.is_finite() && *v > 0.0);
match scale.map(|v| run_ltscale(s, &json!({ "scale": v }))) {
Some(Ok(r)) => {
if let Some(m) = r.get("message").and_then(Value::as_str) {
s.echo(m.to_string());
}
Ok(Step::Done)
}
_ => {
s.echo("Requires a positive number.");
Ok(Step::Continue)
}
}
}
}
fn run_units(s: &mut Session, p: &Value) -> Result<Value> {
let d = s.doc_mut()?;
for (k, lo, hi) in [("lunits", 1, 5), ("luprec", 0, 8), ("aunits", 0, 4), ("auprec", 0, 8), ("insunits", 0, 24)] {
@@ -831,6 +869,26 @@ fn run_dimstyle_delete(s: &mut Session, p: &Value) -> Result<Value> {
Ok(dimstyles_json(d))
}
/// A dimension's text height, arrow size, overall scale (DIMSCALE) and precision as Properties
/// sets them: per-dimension overrides, as in AutoCAD's Properties palette.
fn dim_overrides(p: &Value) -> Result<serde_json::Map<String, Value>> {
let mut o = serde_json::Map::new();
for (key, field, zero_ok) in [("textHeight", "textHeight", false), ("arrowSize", "arrowSize", true), ("dimScale", "scale", false)] {
if let Some(v) = p.get(key).filter(|v| !v.is_null()) {
let x = v
.as_f64()
.filter(|x| x.is_finite() && (*x > 0.0 || (zero_ok && *x == 0.0)))
.ok_or_else(|| bad("properties.set", format!("`{key}` must be a positive number")))?;
o.insert(field.into(), json!(x));
}
}
if let Some(v) = p.get("precision").filter(|v| !v.is_null()) {
let n = v.as_u64().filter(|n| *n <= 8).ok_or_else(|| bad("properties.set", "`precision` must be 0 to 8 decimal places"))?;
o.insert("decimals".into(), json!(n));
}
Ok(o)
}
/// Per-dimension style overrides (DIMOVERRIDE): merge fields, or `clear` them.
pub(super) fn dim_override(s: &mut Session, p: &Value) -> Result<Value> {
let hs = targets(s, p)?;
+36 -9
View File
@@ -224,16 +224,27 @@ fn separator(path: &str, text: &str) -> char {
if first.contains(';') && !first.contains(',') { ';' } else { ',' }
}
/// The file to read for a data link: a path the user just `chosen` as given, a path stored in the
/// drawing at `host` only through [`crate::doc_path::resolve`].
fn link_file(id: &str, path: &str, host: Option<&str>, chosen: bool) -> Result<std::path::PathBuf> {
if chosen {
return Ok(std::path::PathBuf::from(path));
}
crate::doc_path::resolve(path, host.map(std::path::Path::new)).map_err(|r| bad(id, format!("{path}: not opened, {r}")))
}
/// Read a linked file through the host (natively the file system; not on the web).
fn read_link(id: &str, path: &str) -> Result<String> {
fn read_link(id: &str, file: &std::path::Path) -> Result<String> {
let path = file.display();
#[cfg(not(target_arch = "wasm32"))]
{
let meta = std::fs::metadata(path).map_err(|e| bad(id, format!("{path}: {e}")))?;
let meta = std::fs::metadata(file).map_err(|e| bad(id, format!("{path}: {e}")))?;
if meta.len() > MAX_LINK_BYTES {
return Err(bad(id, format!("{path}: larger than {} MB", MAX_LINK_BYTES >> 20)));
}
let bytes = std::fs::read(path).map_err(|e| bad(id, format!("{path}: {e}")))?;
Ok(String::from_utf8_lossy(&bytes).into_owned())
let bytes = std::fs::read(file).map_err(|e| bad(id, format!("{path}: {e}")))?;
let text = String::from_utf8_lossy(&bytes);
Ok(text.strip_prefix('\u{feff}').unwrap_or(&text).to_owned())
}
#[cfg(target_arch = "wasm32")]
{
@@ -278,14 +289,16 @@ fn fill(t: &mut Table, start: usize, data: &[Vec<String>]) {
}
}
/// Re-read the linked file of table `h`.
fn update(s: &mut Session, h: Handle) -> Result<(usize, usize)> {
/// Re-read the linked file of table `h`; `chosen` says its path was just given by the user.
fn update(s: &mut Session, h: Handle, chosen: bool) -> Result<(usize, usize)> {
let id = "datalinkupdate";
let link = match s.doc()?.entity(h).map(|e| &e.kind) {
Some(EntityKind::Table(t)) => t.data_link.clone().ok_or_else(|| bad(id, "the table has no data link"))?,
_ => return Err(bad(id, "object is not a table")),
};
let text = read_link(id, &link.path)?;
let host = s.state()?.path.clone();
let file = link_file(id, &link.path, host.as_deref(), chosen)?;
let text = read_link(id, &file)?;
let data = parse_csv(&text, separator(&link.path, &text));
let mut size = (0, 0);
edit(s, &json!({ "handle": h.hex() }), id, |t| {
@@ -313,7 +326,7 @@ fn run_datalink(s: &mut Session, p: &Value) -> Result<Value> {
Ok(())
})?;
let h = r.get("handle").and_then(Value::as_str).and_then(Handle::parse_hex).ok_or_else(|| bad(id, "no table"))?;
let (rows, cols) = update(s, h)?;
let (rows, cols) = update(s, h, true)?;
Ok(json!({ "handle": h.hex(), "rows": rows, "cols": cols }))
}
@@ -340,7 +353,7 @@ fn run_update(s: &mut Session, p: &Value) -> Result<Value> {
let mut updated = 0;
let mut errors = Vec::new();
for h in hs {
match update(s, h) {
match update(s, h, false) {
Ok(_) => updated += 1,
Err(e) => errors.push(e.to_string()),
}
@@ -369,4 +382,18 @@ mod tests {
assert_eq!(separator("a.tsv", "x,y"), '\t');
assert_eq!(separator("a.csv", "x;y"), ';');
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn link_strips_utf8_bom() {
let path = std::env::temp_dir().join(format!("cadcraft_bom_link_{}.csv", std::process::id()));
let mut bytes = vec![0xEF, 0xBB, 0xBF];
bytes.extend_from_slice(b"\"Item, name\",Qty\r\nBolt,4\r\n");
std::fs::write(&path, bytes).expect("write temp csv");
let text = read_link("datalink", &path);
let _ = std::fs::remove_file(&path);
let text = text.expect("read link");
let rows = parse_csv(&text, separator("bom.csv", &text));
assert_eq!(rows, vec![vec!["Item, name", "Qty"], vec!["Bolt", "4"]]);
}
}
+50 -10
View File
@@ -646,6 +646,20 @@ pub enum Shift {
DeleteCol(usize),
}
/// The `lo..=hi` extent of a range after inserting a row/column at `at`.
fn shift_span(lo: usize, hi: usize, at: usize) -> (usize, usize) {
(if lo >= at { lo + 1 } else { lo }, if hi >= at { hi + 1 } else { hi })
}
/// The `lo..=hi` extent of a range after deleting row/column `at` (the start stays, the next
/// row moves up into it; the end moves up); `None` when the range was only that row/column.
fn shift_span_delete(lo: usize, hi: usize, at: usize) -> Option<(usize, usize)> {
if lo == at && hi == at {
return None;
}
Some((if lo > at { lo - 1 } else { lo }, if hi >= at { hi.checked_sub(1)? } else { hi }))
}
/// `formula` with its references moved for a row/column insertion or deletion; references to a
/// deleted row/column become `#REF` (range corners move inward instead).
pub fn shift_refs(formula: &str, shift: Shift) -> String {
@@ -655,24 +669,42 @@ pub fn shift_refs(formula: &str, shift: Shift) -> String {
let body = src.get(body_start..).unwrap_or("");
let mut out = String::from(src.get(..body_start).unwrap_or(""));
let mut last = 0;
let mut skip_to = 0;
for (k, (tok, span)) in toks.iter().enumerate() {
if k < skip_to {
continue;
}
let Tok::Cell(r, c) = *tok else { continue };
let range_start = toks.get(k + 1).is_some_and(|t| t.0 == Tok::Op(':'));
let range_end = k > 0 && toks.get(k - 1).is_some_and(|t| t.0 == Tok::Op(':'));
// `A5:A2` is the same range as `A2:A5`: move the normalised corners as one unit.
if let (Some((Tok::Op(':'), _)), Some((Tok::Cell(r2, c2), end))) = (toks.get(k + 1), toks.get(k + 2)) {
skip_to = k + 3;
let (rlo, rhi, clo, chi) = (r.min(*r2), r.max(*r2), c.min(*c2), c.max(*c2));
let moved = match shift {
Shift::InsertRow(at) => Some((shift_span(rlo, rhi, at), (clo, chi))),
Shift::DeleteRow(at) => shift_span_delete(rlo, rhi, at).map(|rr| (rr, (clo, chi))),
Shift::InsertCol(at) => Some(((rlo, rhi), shift_span(clo, chi, at))),
Shift::DeleteCol(at) => shift_span_delete(clo, chi, at).map(|cc| ((rlo, rhi), cc)),
};
out.push_str(body.get(last..span.start).unwrap_or(""));
match moved {
Some(((r0, r1), (c0, c1))) if (r0, r1, c0, c1) == (rlo, rhi, clo, chi) => {
out.push_str(body.get(span.start..end.end).unwrap_or(""));
}
Some(((r0, r1), (c0, c1))) => {
out.push_str(&format!("{}:{}", cell_name(r0, c0), cell_name(r1, c1)));
}
None => out.push_str("#REF"),
}
last = end.end;
continue;
}
let moved = |v: usize, at: usize, insert: bool| -> Option<usize> {
if insert {
Some(if v >= at { v + 1 } else { v })
} else if v > at {
Some(v - 1)
} else if v == at {
// A range's start stays (the next row moves up into it); its end moves up.
if range_start {
Some(v)
} else if range_end {
v.checked_sub(1)
} else {
None
}
None
} else {
Some(v)
}
@@ -728,5 +760,13 @@ mod tests {
assert_eq!(shift_refs("=Sum(A2:A5)", Shift::DeleteRow(4)), "=Sum(A2:A4)");
assert_eq!(shift_refs("=B2*2", Shift::DeleteCol(1)), "=#REF*2");
assert_eq!(shift_refs("=C1", Shift::InsertCol(0)), "=D1");
assert_eq!(shift_refs("=Sum(A3:A3)", Shift::DeleteRow(2)), "=Sum(#REF)");
assert_eq!(shift_refs("=Sum(A3:C3)", Shift::DeleteCol(1)), "=Sum(A3:B3)");
assert_eq!(shift_refs("=Sum(B3:B3)", Shift::DeleteCol(1)), "=Sum(#REF)");
assert_eq!(shift_refs("=Sum(A5:A2)", Shift::DeleteRow(1)), "=Sum(A2:A4)");
assert_eq!(shift_refs("=Sum(A5:A2)", Shift::DeleteRow(4)), "=Sum(A2:A4)");
assert_eq!(shift_refs("=Sum(C1:A1)", Shift::DeleteCol(0)), "=Sum(A1:B1)");
assert_eq!(shift_refs("=Sum(A5:A2)", Shift::InsertRow(2)), "=Sum(A2:A6)");
assert_eq!(shift_refs("=Sum(A5:A2)", Shift::InsertRow(9)), "=Sum(A5:A2)");
}
}
+338
View File
@@ -0,0 +1,338 @@
//! TXT2MTXT (Express Tools): single-line TEXT, and MTEXT when combining, turned into MTEXT.
//!
//! Combined, the texts become one MTEXT in place of the first of them (its handle, layer and
//! other properties kept), one line each, from the top down or in selection order, at the top
//! left of their extents. Otherwise each TEXT becomes its own MTEXT, attached at the point it
//! is justified by.
use cadcraft_doc::{EntityKind, HAlign, Handle, MText, Text, VAlign, entity_bounds, mtext_literal};
use cadcraft_geom::Bounds2;
use serde_json::{Value, json};
use super::curves::KW;
use super::helpers::v3;
use super::machines::{SelOutcome, SelectPhase};
use super::{CommandSpec, bad, bool_or, str_param, targets};
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("txt2mtxt", "Convert Text to Mtext", run_txt2mtxt)
.menu(&["Tools", "Express Tools", "Convert Text to Mtext"])
.params(
"{handles? (default the selection; TEXT, and MTEXT when combining), combine? (default true: one MTEXT; false: one per TEXT), \
sort?: \"topdown\" (default) | \"selection\", wrap? (default false: one line per text)} → {handles}",
)
.interactive(|s| Ok(Box::new(Txt2MtxtM::new(s)))),
]
}
/// TXT2MTXT's settings: one MTEXT or one per TEXT, the order of the lines and word wrap.
#[derive(Clone, Debug, PartialEq)]
pub struct Txt2MtxtOptions {
pub combine: bool,
pub top_down: bool,
pub wrap: bool,
}
impl Default for Txt2MtxtOptions {
fn default() -> Self {
Txt2MtxtOptions { combine: true, top_down: true, wrap: false }
}
}
/// Converts the TEXT (and, combining, MTEXT) objects among `hs`; objects on locked layers are
/// left alone. Returns the MTEXT objects made and how many texts were on a locked layer.
fn convert(s: &mut Session, hs: &[Handle], o: &Txt2MtxtOptions) -> Result<(Vec<Handle>, usize)> {
let d = s.doc()?;
let mut locked = 0;
let mut items: Vec<(Handle, EntityKind, Bounds2)> = Vec::new();
let mut seen = std::collections::HashSet::new();
for h in hs {
let Some(e) = d.entity(*h) else { continue };
let wanted = match &e.kind {
EntityKind::Text(_) => true,
EntityKind::MText(_) => o.combine,
_ => false,
};
if !wanted || !seen.insert(*h) {
continue;
}
if d.layer(&e.common.layer).is_some_and(|l| l.locked) {
locked += 1;
continue;
}
items.push((*h, e.kind.clone(), entity_bounds(d, e, 0)));
}
if !o.combine {
let mut made = Vec::new();
for (h, k, _) in items {
if let EntityKind::Text(t) = k {
s.doc_mut()?.modify_entity(h, |e| e.kind = EntityKind::MText(single(&t)))?;
made.push(h);
}
}
return Ok((made, locked));
}
if o.top_down {
// Highest first; a line shared by several texts reads left to right.
items.sort_by(|a, b| {
let (pa, pb) = (insertion(&a.1), insertion(&b.1));
pb.y.total_cmp(&pa.y).then(pa.x.total_cmp(&pb.x))
});
}
// A lone MTEXT is already what TXT2MTXT would make of it.
if let [(_, EntityKind::MText(_), _)] = &items[..] {
return Ok((Vec::new(), locked));
}
let Some((first, k0, _)) = items.first() else { return Ok((Vec::new(), locked)) };
let (height, style, rotation) = match k0 {
EntityKind::Text(t) => (t.height, t.style.clone(), t.rotation),
EntityKind::MText(m) => (m.height, m.style.clone(), m.rotation),
_ => return Ok((Vec::new(), locked)),
};
let ext = items.iter().fold(Bounds2::EMPTY, |b, (.., x)| b.union(x));
let corner = if ext.is_empty() { insertion(k0) } else { cadcraft_geom::Vec2::new(ext.min.x, ext.max.y) };
let sep = if o.wrap { " " } else { "\\P" };
let contents = items.iter().map(|(_, k, _)| contents(k, height)).collect::<Vec<_>>().join(sep);
let width = if o.wrap { items.iter().map(|(_, k, _)| line_width(k)).fold(0.0, f64::max) } else { 0.0 };
let m = MText {
insert: v3(corner),
height,
width,
attach: 1,
rotation,
style,
contents,
line_spacing: 1.0,
line_spacing_exact: false,
columns: None,
};
let first = *first;
let rest: Vec<Handle> = items.iter().skip(1).map(|(h, ..)| *h).collect();
let d = s.doc_mut()?;
d.modify_entity(first, |e| e.kind = EntityKind::MText(m))?;
for h in rest {
d.remove_entity(h);
}
Ok((vec![first], locked))
}
/// The point a TEXT or MTEXT is sorted by: its insertion point.
fn insertion(k: &EntityKind) -> cadcraft_geom::Vec2 {
match k {
EntityKind::Text(t) => t.insert.xy(),
EntityKind::MText(m) => m.insert.xy(),
_ => cadcraft_geom::Vec2::ZERO,
}
}
/// A text's lines as MTEXT contents of base height `base`: the TEXT's characters read literally
/// (its width factor and oblique angle as inline codes), an MTEXT's contents as they are; either
/// keeps its own height with an absolute `\H` code when that differs from `base`.
fn contents(k: &EntityKind, base: f64) -> String {
match k {
EntityKind::Text(t) => text_contents(t, base),
EntityKind::MText(m) if differs(m.height, base) => format!("{{\\H{};{}}}", m.height, m.contents),
EntityKind::MText(m) => m.contents.clone(),
_ => String::new(),
}
}
/// Whether a line of height `h` needs its own height code in an MTEXT of height `base`.
fn differs(h: f64, base: f64) -> bool {
h.is_finite() && h > 0.0 && (h - base).abs() > base.abs() * 1e-9
}
fn text_contents(t: &Text, base: f64) -> String {
let mut codes = String::new();
if differs(t.height, base) {
codes.push_str(&format!("\\H{};", t.height));
}
if t.width_factor.is_finite() && t.width_factor > 0.0 && (t.width_factor - 1.0).abs() > 1e-9 {
codes.push_str(&format!("\\W{};", t.width_factor));
}
if t.oblique.is_finite() && t.oblique.abs() > 1e-9 {
codes.push_str(&format!("\\Q{};", t.oblique.to_degrees()));
}
let lit = mtext_literal(&t.value);
if codes.is_empty() { lit } else { format!("{{{codes}{lit}}}") }
}
/// How wide a text's longest line is in the drafting font: word wrap's MTEXT width.
fn line_width(k: &EntityKind) -> f64 {
let w = match k {
EntityKind::Text(t) => cadcraft_fonts::line_width(&t.value, t.height, t.width_factor),
EntityKind::MText(m) if m.width > 0.0 => m.width,
EntityKind::MText(m) => {
cadcraft_fonts::plain_mtext(&m.contents).lines().map(|l| cadcraft_fonts::line_width(l, m.height, 1.0)).fold(0.0, f64::max)
}
_ => 0.0,
};
if w.is_finite() { w } else { 0.0 }
}
/// One TEXT as an MTEXT of its own, attached where the TEXT is justified (a baseline as the
/// bottom).
fn single(t: &Text) -> MText {
let col = match t.halign {
HAlign::Left | HAlign::Aligned | HAlign::Fit => 0,
HAlign::Center | HAlign::Middle => 1,
HAlign::Right => 2,
};
let row = match (t.halign, t.valign) {
(HAlign::Middle, _) | (_, VAlign::Middle) => 1,
(_, VAlign::Top) => 0,
_ => 2,
};
MText {
insert: v3(t.justify_point()),
height: t.height,
width: 0.0,
attach: row * 3 + col + 1,
rotation: t.rotation,
style: t.style.clone(),
contents: text_contents(t, t.height),
line_spacing: 1.0,
line_spacing_exact: false,
columns: None,
}
}
fn locked_note(n: usize) -> Option<String> {
(n > 0).then(|| format!("{n} object(s) on a locked layer."))
}
fn run_txt2mtxt(s: &mut Session, p: &Value) -> Result<Value> {
let hs = targets(s, p)?;
let top_down = match str_param(p, "sort") {
None | Some("topdown") => true,
Some("selection") => false,
Some(v) => return Err(bad("txt2mtxt", format!("`sort` must be \"topdown\" or \"selection\", got \"{v}\""))),
};
let o = Txt2MtxtOptions { combine: bool_or(p, "combine", true), top_down, wrap: bool_or(p, "wrap", false) };
let (made, locked) = convert(s, &hs, &o)?;
if made.is_empty() {
let why = locked_note(locked).unwrap_or_else(|| "No text objects selected.".into());
return Err(EngineError::Other(why));
}
s.set_selection(Vec::new());
let mut r = json!({ "handles": made.iter().map(|h| h.hex()).collect::<Vec<_>>() });
if let Some(m) = locked_note(locked) {
r["message"] = json!(m);
}
Ok(r)
}
#[derive(Clone, Copy, PartialEq)]
enum Ask {
Select,
Combine,
Sort,
Wrap,
}
struct Txt2MtxtM {
opts: Txt2MtxtOptions,
ask: Ask,
sel: SelectPhase,
}
impl Txt2MtxtM {
fn new(s: &Session) -> Self {
Txt2MtxtM { opts: s.last_used.txt2mtxt.clone(), ask: Ask::Select, sel: SelectPhase::default() }
}
fn finish(&self, s: &mut Session, hs: &[Handle]) -> Result<Step> {
let (made, locked) = convert(s, hs, &self.opts)?;
if let Some(m) = locked_note(locked) {
s.echo(m);
}
if made.is_empty() {
s.echo("No text objects selected.");
}
s.set_selection(Vec::new());
Ok(Step::Done)
}
}
fn yes_no(b: bool) -> &'static str {
if b { "Yes" } else { "No" }
}
impl Interactive for Txt2MtxtM {
fn name(&self) -> &'static str {
"TXT2MTXT"
}
fn begin(&mut self, s: &mut Session) -> Result<Step> {
let o = &self.opts;
s.echo(format!("Current settings: Combine= {}, Sort top-down= {}, Word-wrap= {}", yes_no(o.combine), yes_no(o.top_down), yes_no(o.wrap)));
self.sel = SelectPhase::begin(s);
if self.sel.done {
let hs = self.sel.picked.clone();
return self.finish(s, &hs);
}
Ok(Step::Continue)
}
fn prompt(&self, _s: &Session) -> Prompt {
match self.ask {
Ask::Select if self.sel.removing => self.sel.prompt(),
Ask::Select => Prompt::new("Select objects", Accept::SELECT).kw(&["SEttings"]),
Ask::Combine => Prompt::new("Combine text into a single MTEXT?", KW).kw(&["Yes", "No"]).default(yes_no(self.opts.combine)),
Ask::Sort => Prompt::new("Sort text top-down?", KW).kw(&["Yes", "No"]).default(yes_no(self.opts.top_down)),
Ask::Wrap => Prompt::new("Word-wrap text?", KW).kw(&["Yes", "No"]).default(yes_no(self.opts.wrap)),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
let k = match &i {
Input::Keyword(k) => Some(k.as_str()),
_ => None,
};
match self.ask {
Ask::Select => {
if k == Some("SEttings") {
self.ask = Ask::Combine;
return Ok(Step::Continue);
}
return match self.sel.feed(s, &i)? {
SelOutcome::More => Ok(Step::Continue),
SelOutcome::Empty => Ok(Step::Done),
SelOutcome::Done(hs) => self.finish(s, &hs),
};
}
Ask::Combine => {
match k {
Some("Yes") => self.opts.combine = true,
Some("No") => self.opts.combine = false,
_ if i == Input::Enter => {}
_ => return Ok(Step::Continue),
}
// Sorting and word wrap only apply to text combined into one MTEXT.
self.ask = if self.opts.combine { Ask::Sort } else { Ask::Select };
}
Ask::Sort => {
match k {
Some("Yes") => self.opts.top_down = true,
Some("No") => self.opts.top_down = false,
_ if i == Input::Enter => {}
_ => return Ok(Step::Continue),
}
self.ask = Ask::Wrap;
}
Ask::Wrap => {
match k {
Some("Yes") => self.opts.wrap = true,
Some("No") => self.opts.wrap = false,
_ if i == Input::Enter => {}
_ => return Ok(Step::Continue),
}
self.ask = Ask::Select;
}
}
s.last_used.txt2mtxt = self.opts.clone();
Ok(Step::Continue)
}
}
#[cfg(test)]
#[path = "txt2mtxt_tests.rs"]
mod tests;
+166
View File
@@ -0,0 +1,166 @@
use cadcraft_doc::{EntityKind, MText};
use serde_json::json;
use crate::Session;
/// Three TEXT lines made out of order: "B" lowest, "A" highest. The highest is on layer TOP with
/// height 2.5, the others on layer 0 with height 1, so the first text's properties are told apart.
fn three_lines(s: &mut Session) -> Vec<String> {
s.execute("layer.new", &json!({"name": "TOP"})).unwrap();
let h = |r: serde_json::Value| r["handle"].as_str().unwrap().to_string();
let b = h(s.execute("text", &json!({"at": [0, 0], "text": "B", "height": 1})).unwrap());
s.execute("layer.current", &json!({"name": "TOP"})).unwrap();
let a = h(s.execute("text", &json!({"at": [2, 10], "text": "A", "height": 2.5})).unwrap());
s.execute("layer.current", &json!({"name": "0"})).unwrap();
let c = h(s.execute("text", &json!({"at": [1, 5], "text": "C{1}", "height": 1})).unwrap());
vec![b, a, c]
}
fn handles(hs: &[String]) -> Vec<cadcraft_doc::Handle> {
hs.iter().map(|h| cadcraft_doc::Handle::parse_hex(h).unwrap()).collect()
}
fn mtexts(s: &Session) -> Vec<(String, MText)> {
s.doc()
.unwrap()
.model
.iter()
.filter_map(|e| match &e.kind {
EntityKind::MText(m) => Some((e.common.layer.clone(), m.clone())),
_ => None,
})
.collect()
}
#[test]
fn combines_texts_top_down_into_one_mtext_with_the_first_ones_properties() {
let mut s = Session::new();
let hs = three_lines(&mut s);
let r = s.execute("txt2mtxt", &json!({"handles": hs})).unwrap();
let doc = s.doc().unwrap();
assert_eq!(doc.model.len(), 1, "the three texts became one object");
let ms = mtexts(&s);
let [(layer, m)] = &ms[..] else { panic!("one MTEXT: {ms:?}") };
// Highest first whatever the selection order; braces are literal characters; lines of
// another height than the first keep theirs.
assert_eq!(m.contents, "A\\P{\\H1;C\\{1\\}}\\P{\\H1;B}");
assert_eq!((layer.as_str(), m.height, m.attach, m.width), ("TOP", 2.5, 1, 0.0));
// Top left of the texts: the leftmost start point, the top of the highest text.
assert_eq!((m.insert.x, m.insert.y), (0.0, 12.5));
assert_eq!(r["handles"], json!([hs[1]]), "the highest text's object holds the MTEXT");
s.undo().unwrap();
let kinds: Vec<_> = s.doc().unwrap().model.iter().map(|e| e.kind.type_name()).collect();
assert_eq!(kinds, ["Text", "Text", "Text"], "one undo step brings the three texts back");
}
#[test]
fn selection_order_wrap_and_one_mtext_per_text() {
let mut s = Session::new();
let hs = three_lines(&mut s);
s.execute("txt2mtxt", &json!({"handles": hs, "sort": "selection", "wrap": true})).unwrap();
let ms = mtexts(&s);
let [(layer, m)] = &ms[..] else { panic!("one MTEXT: {ms:?}") };
assert_eq!(m.contents, "B {\\H2.5;A} C\\{1\\}", "selection order, one wrapped paragraph");
assert_eq!((layer.as_str(), m.height), ("0", 1.0), "the first selected text's properties");
assert!(m.width > 0.0, "word wrap gives the MTEXT a width");
s.undo().unwrap();
s.execute("txt2mtxt", &json!({"handles": hs, "combine": false})).unwrap();
let ms = mtexts(&s);
assert_eq!(ms.len(), 3, "one MTEXT per TEXT: {ms:?}");
let a = ms.iter().find(|(_, m)| m.contents == "A").unwrap();
// A baseline-left TEXT is attached bottom left at its start point, keeping layer and height.
assert_eq!((a.0.as_str(), a.1.height, a.1.attach, a.1.insert.x, a.1.insert.y), ("TOP", 2.5, 7, 2.0, 10.0));
assert_eq!(s.doc().unwrap().model.len(), 3);
}
#[test]
fn command_line_settings_and_locked_layers() {
let mut s = Session::new();
let hs = three_lines(&mut s);
let steps = s.state().unwrap().undo.len();
s.start("txt2mtxt").unwrap();
assert!(s.prompt_text().contains("Select objects or [SEttings]"), "{}", s.prompt_text());
s.cmdline("se").unwrap();
assert!(s.prompt_text().contains("Combine text into a single MTEXT? [Yes/No] <Yes>"), "{}", s.prompt_text());
s.cmdline("n").unwrap();
assert!(s.prompt_text().contains("Select objects"), "sorting and wrapping are skipped: {}", s.prompt_text());
s.input(crate::Input::Pick(handles(&hs))).unwrap();
s.cmdline("").unwrap();
assert!(s.running.is_none(), "{}", s.prompt_text());
assert_eq!(mtexts(&s).len(), 3);
assert_eq!(s.state().unwrap().undo.len(), steps + 1, "one undo step");
s.undo().unwrap();
// The setting is remembered; a text on a locked layer is left alone.
s.execute("layer.set", &json!({"name": "TOP", "locked": true})).unwrap();
s.start("txt2mtxt").unwrap();
s.cmdline("se").unwrap();
assert!(s.prompt_text().contains("<No>"), "{}", s.prompt_text());
s.cmdline("y").unwrap();
assert!(s.prompt_text().contains("Sort text top-down? [Yes/No] <Yes>"), "{}", s.prompt_text());
s.cmdline("").unwrap();
assert!(s.prompt_text().contains("Word-wrap text? [Yes/No] <No>"), "{}", s.prompt_text());
s.cmdline("").unwrap();
s.input(crate::Input::Pick(handles(&hs))).unwrap();
s.cmdline("").unwrap();
let ms = mtexts(&s);
let [(_, m)] = &ms[..] else { panic!("one MTEXT: {ms:?}") };
assert_eq!(m.contents, "C\\{1\\}\\PB");
assert_eq!(s.doc().unwrap().model.len(), 2, "the locked text stays a TEXT");
let n = s.doc().unwrap().model.len();
for p in [json!({"handles": []}), json!({"handles": [hs[1]]}), json!({"handles": hs, "sort": "sideways"})] {
assert!(s.execute("txt2mtxt", &p).is_err(), "{p}");
}
assert_eq!(s.doc().unwrap().model.len(), n);
}
/// The vertical extent of each group of strokes that overlap vertically: one per line of
/// capitals, top line first.
fn line_extents(strokes: &[Vec<cadcraft_geom::Vec2>]) -> Vec<f64> {
let mut spans: Vec<(f64, f64)> = strokes
.iter()
.filter(|st| !st.is_empty())
.map(|st| st.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), p| (lo.min(p.y), hi.max(p.y))))
.collect();
spans.sort_by(|a, b| b.1.total_cmp(&a.1));
let mut lines: Vec<(f64, f64)> = Vec::new();
for (lo, hi) in spans {
match lines.last_mut() {
Some(l) if hi >= l.0 => l.0 = l.0.min(lo),
_ => lines.push((lo, hi)),
}
}
lines.iter().map(|(lo, hi)| hi - lo).collect()
}
#[test]
fn combined_lines_keep_their_own_heights() {
let mut s = Session::new();
for (y, t, h) in [(0, "LOW", 3.5), (20, "TOP", 5.0), (10, "MID", 3.5)] {
s.execute("text", &json!({"at": [0, y], "text": t, "height": h})).unwrap();
}
s.execute("txt2mtxt", &json!({"handles": s.doc().unwrap().model.iter().map(|e| e.handle.hex()).collect::<Vec<_>>()})).unwrap();
let ms = mtexts(&s);
let [(_, m)] = &ms[..] else { panic!("one MTEXT: {ms:?}") };
assert_eq!((m.height, m.contents.as_str()), (5.0, "TOP\\P{\\H3.5;MID}\\P{\\H3.5;LOW}"));
// Drawn, each line is as tall as the TEXT it came from.
let drawn = |c: &str, h: f64| cadcraft_fonts::layout_mtext(c, m.insert.xy(), h, 0.0, 1, 0.0, 1.0).strokes;
let got = line_extents(&drawn(&m.contents, m.height));
let want: Vec<f64> = [("TOP", 5.0), ("MID", 3.5), ("LOW", 3.5)].iter().flat_map(|(t, h)| line_extents(&drawn(t, *h))).collect();
assert_eq!(got.len(), 3, "{got:?}");
for (g, w) in got.iter().zip(&want) {
assert!((g - w).abs() < 1e-6, "line heights {got:?}, TEXT heights {want:?}");
}
assert!(want[1] < want[0] * 0.8, "the fixture's heights differ: {want:?}");
s.undo().unwrap();
// One MTEXT per TEXT: each its own height.
let hs: Vec<String> = s.doc().unwrap().model.iter().map(|e| e.handle.hex()).collect();
s.execute("txt2mtxt", &json!({"handles": hs, "combine": false})).unwrap();
let mut got: Vec<(String, f64)> = mtexts(&s).into_iter().map(|(_, m)| (m.contents, m.height)).collect();
got.sort_by(|a, b| a.0.cmp(&b.0));
assert_eq!(got, [("LOW".to_string(), 3.5), ("MID".to_string(), 3.5), ("TOP".to_string(), 5.0)]);
}
+59 -1
View File
@@ -26,7 +26,10 @@ pub fn specs() -> Vec<CommandSpec> {
.noundo()
.interactive(|_| Ok(Box::<PickM>::default())),
CommandSpec::new("find", "Find...", run_find).menu(&["Edit", "Find..."]).params("{find, replace?, matchCase?: bool, wholeWord?: bool}"),
CommandSpec::new("measuregeom", "Measure Geometry", run_measuregeom).params("{mode: distance|radius|angle|area, ...}").noundo(),
CommandSpec::new("measuregeom", "Measure Geometry", run_measuregeom)
.params("{mode: distance|radius|angle|area, ...}")
.noundo()
.interactive(|_| Ok(Box::<MeasureM>::default())),
CommandSpec::new("radius.measure", "Radius", run_radius).menu(&["Tools", "Inquiry", "Radius"]).params("{handle}").noundo(),
CommandSpec::new("angle.measure", "Angle", run_angle).menu(&["Tools", "Inquiry", "Angle"]).params("{vertex, p1, p2} | {h1, h2}").noundo(),
]
@@ -319,6 +322,61 @@ fn run_measuregeom(s: &mut Session, p: &Value) -> Result<Value> {
}
}
/// MEASUREGEOM typed or picked: choose what to measure, then continue as DIST, AREA or a radius pick.
#[derive(Default)]
struct MeasureM {
inner: Option<Box<dyn Interactive>>,
/// Waiting for the arc or circle of the Radius option.
radius: bool,
}
impl Interactive for MeasureM {
fn name(&self) -> &'static str {
"MEASUREGEOM"
}
fn prompt(&self, s: &Session) -> Prompt {
match &self.inner {
Some(m) => m.prompt(s),
None if self.radius => Prompt::new("Select arc or circle", Accept::POINT),
None => Prompt::new("Enter an option", Accept { point: false, number: false, text: false, select: false, enter: true })
.kw(&["Distance", "Radius", "ARea"])
.default("Distance"),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
if let Some(m) = &mut self.inner {
return m.input(s, i);
}
if self.radius {
let Input::Point(p) = i else { return Ok(Step::Done) };
let Some(h) = super::curves::pick_at(s, p) else { return Ok(Step::Continue) };
match run_radius(s, &json!({ "handle": h.0 })) {
Ok(r) => {
for l in r["message"].as_str().unwrap_or_default().lines() {
s.echo(l.to_string());
}
return Ok(Step::Done);
}
Err(_) => s.echo("Select an arc or circle."),
}
return Ok(Step::Continue);
}
match i {
Input::Enter => self.inner = Some(Box::new(super::inquiry::DistM::default())),
Input::Keyword(k) => match k.as_str() {
"Radius" => self.radius = true,
"ARea" => self.inner = Some(Box::new(super::inquiry::AreaM::default())),
_ => self.inner = Some(Box::new(super::inquiry::DistM::default())),
},
_ => {}
}
Ok(Step::Continue)
}
fn preview(&self, s: &Session, cursor: Vec2) -> Vec<EntityKind> {
self.inner.as_ref().map(|m| m.preview(s, cursor)).unwrap_or_default()
}
}
#[cfg(test)]
mod tests {
use super::*;
+36 -4
View File
@@ -68,6 +68,7 @@ struct Parser<'a> {
i: usize,
vars: &'a Map<String, Value>,
depth: usize,
unary_depth: usize,
}
impl Parser<'_> {
@@ -121,19 +122,31 @@ impl Parser<'_> {
}
Ok(v)
}
/// A unary operand reached by a leading `-`/`+` or after `^`. These recurse without passing
/// through `expr`, so they are bounded by their own counter (`unary_depth`), leaving the
/// parenthesis/bracket limit in `depth` untouched.
fn operand(&mut self) -> Result<Val, String> {
if self.unary_depth >= MAX_DEPTH {
return Err("expression too deeply nested".into());
}
self.unary_depth += 1;
let v = self.unary();
self.unary_depth -= 1;
v
}
fn unary(&mut self) -> Result<Val, String> {
if self.eat(b'-') {
return Ok(match self.unary()? {
return Ok(match self.operand()? {
Val::Num(v) => Val::Num(-v),
Val::Pt(p) => Val::Pt(Vec2::ZERO - p),
});
}
if self.eat(b'+') {
return self.unary();
return self.operand();
}
let b = self.atom()?;
if self.eat(b'^') {
let e = self.unary()?.num("^")?;
let e = self.operand()?.num("^")?;
return Ok(Val::Num(b.num("^")?.powf(e)));
}
Ok(b)
@@ -395,7 +408,7 @@ pub fn eval_val(expr: &str, vars: &Map<String, Value>) -> Result<Val, String> {
if expr.len() > MAX_LEN {
return Err("expression too long".into());
}
let mut p = Parser { s: expr.as_bytes(), i: 0, vars, depth: 0 };
let mut p = Parser { s: expr.as_bytes(), i: 0, vars, depth: 0, unary_depth: 0 };
let v = p.expr()?;
if p.peek().is_some() {
return Err(format!("unexpected `{}`", expr.get(p.i..).unwrap_or("")));
@@ -588,6 +601,25 @@ mod tests {
assert!(ev("max(1,2,3,4,5,6,7,8,9)").is_err());
}
#[test]
fn deep_unary_chain_does_not_overflow_the_stack() {
assert!(ev(&format!("{}1", "-".repeat(600))).is_err());
assert!(ev(&format!("{}1", "+".repeat(600))).is_err());
assert!(ev(&format!("2{}", "^2".repeat(600))).is_err());
// The size from the report: under MAX_LEN, but ~10,000 frames deep without a guard.
assert!(ev(&format!("{}1", "-".repeat(9990))).is_err());
assert!(ev(&format!("2{}", "^2".repeat(4990))).is_err());
assert_eq!(ev("--1"), Ok(Val::Num(1.0)));
assert_eq!(ev("2^3^2"), Ok(Val::Num(512.0)));
}
#[test]
fn parenthesis_nesting_just_under_the_limit_still_parses() {
let n = MAX_DEPTH - 1;
assert_eq!(ev(&format!("{}1{}", "(".repeat(n), ")".repeat(n))), Ok(Val::Num(1.0)));
assert!(ev(&format!("{}1{}", "(".repeat(n + 1), ")".repeat(n + 1))).is_err());
}
#[test]
fn number_text() {
assert_eq!(num_text(0.1 + 0.2), "0.3");
+38
View File
@@ -0,0 +1,38 @@
//! Paths stored in a drawing: xref paths and table data links.
//!
//! Such a path reaches the file system only through [`resolve`]: a relative path is taken from
//! the drawing's folder, and only files on this computer, or on the network share the drawing
//! itself is on, are opened (see `craft_docpath`). A refused path is handled like a missing file,
//! with the reason in its status. Paths the user gives a command (XATTACH, -XREF Path, DATALINK)
//! are their own choice and are used as given.
use std::path::{Path, PathBuf};
pub use craft_docpath::Refused;
/// The file to open for `stored`, a path or `file:` URL stored in the drawing saved at
/// `document` (`None` for a drawing without a file).
///
/// A result that isn't absolute is refused as [`Refused::Invalid`]: without a document a
/// relative path would depend on the working directory, and a Windows drive path names no file
/// on other platforms.
pub fn resolve(stored: &str, document: Option<&Path>) -> Result<PathBuf, Refused> {
let document = document.filter(|d| !d.as_os_str().is_empty()).map(|d| std::path::absolute(d).unwrap_or_else(|_| d.to_path_buf()));
let path = craft_docpath::resolve(stored.trim(), document.as_deref())?;
if path.is_absolute() { Ok(path) } else { Err(Refused::Invalid) }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn relative_paths_resolve_beside_the_drawing_and_other_computers_are_refused() {
let doc = std::env::temp_dir().join("plans").join("host.dxf");
assert_eq!(resolve("parts/a.dxf", Some(&doc)), Ok(std::env::temp_dir().join("plans").join("parts").join("a.dxf")));
assert_eq!(resolve("a.dxf", None), Err(Refused::Invalid));
assert_eq!(resolve(r"\\192.0.2.1\share\a.dxf", Some(&doc)), Err(Refused::Network));
assert_eq!(resolve("file://192.0.2.1/s/a.dxf", Some(&doc)), Err(Refused::Network));
assert_eq!(resolve("//192.0.2.1/s/a.dxf", None), Err(Refused::Network));
}
}
+9
View File
@@ -75,6 +75,15 @@ impl Interactive for Guarded {
Vec::new()
})
}
fn preview_objects(&self, s: &Session, cursor: Vec2) -> Vec<cadcraft_doc::Entity> {
if self.broken.get().is_some() {
return Vec::new();
}
catch_unwind(AssertUnwindSafe(|| self.inner.preview_objects(s, cursor))).unwrap_or_else(|_| {
self.broken.set(Some("panic in preview"));
Vec::new()
})
}
fn drag(&self, s: &Session, cursor: Vec2) -> Option<crate::prompt::Drag> {
if self.broken.get().is_some() {
return None;
+125 -21
View File
@@ -9,10 +9,15 @@
#![forbid(unsafe_code)]
pub mod assoc;
#[cfg(not(target_arch = "wasm32"))]
pub mod atomic_write;
pub mod audit;
pub mod autosave;
pub mod blockedit;
#[cfg(not(target_arch = "wasm32"))]
pub mod capped_read;
pub mod cmd;
pub mod doc_path;
mod finite;
pub mod grip_dims;
pub mod grips;
@@ -378,7 +383,11 @@ pub struct Settings {
/// PLOTTRANSPARENCYOVERRIDE: 0 never plots transparency, 1 (default) uses the page setup's or
/// the Plot dialog's "Plot transparency", 2 always plots it.
pub plot_transparency_override: u8,
/// Selection cycling on (the status-bar toggle, the sign of SELECTIONCYCLING).
pub selection_cycling: bool,
/// What selection cycling shows over overlapping objects (SELECTIONCYCLING's magnitude): 0
/// nothing, 1 a badge, 2 (default) the badge and a list to pick from on a click.
pub selection_cycling_display: u8,
/// SELECTSIMILARMODE: what SELECTSIMILAR matches besides the object type (bits: 1 colour,
/// 2 layer, 4 linetype, 8 linetype scale, 16 lineweight, 32 plot style, 64 object style,
/// 128 name).
@@ -392,6 +401,9 @@ pub struct Settings {
/// ACADLSPASDOC: acad.lsp loads into every drawing, not once per session.
pub acadlspasdoc: bool,
pub pickadd: bool,
/// PICKDRAG: false = two-click windows (0), true = clicks or dragging (2).
/// Drag-only mode (1) is not supported yet.
pub pickdrag: bool,
/// PICKSTYLE: bit 1 picks whole selectable groups ([`cmd::groups`]), bit 2 associative hatches
/// with their boundaries (stored, not used yet).
pub pickstyle: u8,
@@ -443,6 +455,8 @@ pub struct Settings {
pub polarsnap: bool,
/// POLARDIST: the PolarSnap step; 0 uses the snap X spacing.
pub polardist: f64,
/// AUDITCTL: 1 creates an audit report (.adt) file when AUDIT is run (default: 0).
pub auditctl: bool,
}
impl Default for Settings {
@@ -470,6 +484,7 @@ impl Default for Settings {
transparency_display: true,
plot_transparency_override: 1,
selection_cycling: false,
selection_cycling_display: 2,
selectsimilarmode: 130,
otrack: true,
pickbox: 3.0,
@@ -477,6 +492,7 @@ impl Default for Settings {
pickfirst: true,
acadlspasdoc: false,
pickadd: true,
pickdrag: true,
pickstyle: 1,
gripsize: 5.0,
cursorsize: 5.0,
@@ -501,6 +517,7 @@ impl Default for Settings {
polaraddang: Vec::new(),
polarsnap: false,
polardist: 0.0,
auditctl: false,
}
}
}
@@ -535,12 +552,16 @@ pub struct LastUsed {
pub rotate_angle: f64,
/// SCALE factor.
pub scale_factor: f64,
/// TXT2MTXT's Settings.
pub txt2mtxt: cmd::Txt2MtxtOptions,
/// MATCHPROP Settings: the properties it copies.
pub matchprop: cmd::MatchProps,
/// MLEADER placement order and Options.
pub mleader: MLeaderOptions,
/// MLEADER's block content name (Options > Content type > Block).
pub mleader_block: String,
/// BREAKLINE's Block, Size and Extension.
pub breakline: cmd::BreaklineOptions,
}
impl Default for LastUsed {
@@ -550,9 +571,11 @@ impl Default for LastUsed {
polygon_circumscribed: false,
rotate_angle: 0.0,
scale_factor: 1.0,
txt2mtxt: cmd::Txt2MtxtOptions::default(),
matchprop: cmd::MatchProps::default(),
mleader: MLeaderOptions::default(),
mleader_block: String::new(),
breakline: cmd::BreaklineOptions::default(),
}
}
}
@@ -640,6 +663,10 @@ pub struct Session {
/// The deferred tangent/perpendicular snap under the cursor, if any (from the UI; lets the
/// rubber band show the line it resolves to).
pub cursor_deferred: Option<snap::Deferred>,
/// Whether Ctrl is held right now (from the UI, overwritten every frame). Commands that
/// change behavior on Ctrl — currently only ARC's "hold Ctrl to switch direction" — read
/// this instead of receiving modifiers through `Input`, which stays free of UI state.
pub ctrl_down: bool,
/// Origin of the point currently being fed by `input_snapped`; never reused by typed points.
picked_viewport: Option<snap::ViewportPoint>,
/// The last drawing comparison (COMPARE), drawn over the drawing it was made for.
@@ -702,6 +729,7 @@ impl Session {
point_mods: Vec::new(),
cursor: Vec2::ZERO,
cursor_deferred: None,
ctrl_down: false,
picked_viewport: None,
compare: None,
tracking: snap::tracking::Tracker::default(),
@@ -1083,6 +1111,11 @@ impl Session {
if self.select_mode.is_some() {
return self.select_mode_input(input);
}
// `_L`, `_ALL`, `_W`: the English name of a selection keyword, as scripts write it.
let input = match input {
Input::Text(t) => Input::Text(t.trim().strip_prefix('_').map(str::to_string).unwrap_or(t)),
other => other,
};
if let Input::Text(t) | Input::Keyword(t) = &input
&& let Some(m) = select::SelectMode::from_keyword(t)
{
@@ -1396,15 +1429,21 @@ impl Session {
}
}
/// Rubber-band preview entities for the cursor.
/// Preview entities for the cursor: objects drawn with their own properties
/// ([`Interactive::preview_objects`]), then the rubber band with the current properties, in
/// the grip blue over such objects (so it shows over their fills, whatever their colour).
pub fn preview(&self, cursor: Vec2) -> Vec<Entity> {
let common = self.current_common();
let kinds = match (self.lisp_preview(cursor), &self.running) {
(Some(k), _) => k,
(None, Some(r)) => r.machine.preview(self, cursor),
(None, None) => Vec::new(),
let mut common = self.current_common();
let (kinds, mut out) = match (self.lisp_preview(cursor), &self.running) {
(Some(k), _) => (k, Vec::new()),
(None, Some(r)) => (r.machine.preview(self, cursor), r.machine.preview_objects(self, cursor)),
(None, None) => (Vec::new(), Vec::new()),
};
kinds.into_iter().map(|k| Entity { handle: Handle(0), common: common.clone(), kind: k }).collect()
if !out.is_empty() {
common.color = cadcraft_doc::color::Color::True(cadcraft_doc::color::Rgb(0x2f, 0x6b, 0xff));
}
out.extend(kinds.into_iter().map(|k| Entity { handle: Handle(0), common: common.clone(), kind: k }));
out
}
/// The active command's preview as a transform of existing objects (see
@@ -1526,7 +1565,8 @@ impl Session {
}
// At "Select objects" prompts the selection modes (Previous, Last, ALL) win over a command
// keyword sharing the letter (HATCH's "picK internal point" against `P`).
let selection_mode = prompt.accept.select && matches!(tt.to_ascii_lowercase().as_str(), "all" | "l" | "last" | "p" | "previous");
let selection_mode = prompt.accept.select
&& matches!(tt.strip_prefix('_').unwrap_or(tt).to_ascii_lowercase().as_str(), "all" | "l" | "last" | "p" | "previous");
// A leading underscore asks for a keyword by its English name, whatever the interface
// language (`_M`, `_Yes`): scripts and LISP routines write them that way. (Snap
// overrides such as `_cen` were taken above.)
@@ -1625,7 +1665,7 @@ impl Session {
/// command running, or a command asking for objects. Returns whether a window was opened.
pub fn begin_window(&mut self, corner: Vec2) -> bool {
let during_command = self.running.is_some();
if !corner.is_finite() || (during_command && !self.current_prompt().is_some_and(|p| p.accept.select)) {
if !self.settings.pickdrag || !corner.is_finite() || (during_command && !self.current_prompt().is_some_and(|p| p.accept.select)) {
return false;
}
self.pending_window = Some(PendingWindow { corner, during_command });
@@ -1654,23 +1694,87 @@ impl Session {
}
let ap = self.pixel_size() * self.settings.pickbox.max(1.0) * 1.5;
match select::pick(self.doc()?, &space, p, ap) {
Some(h) => {
// With group selection the object's selectable groups come (or go) with it.
let hs = cmd::groups::expand(self, vec![h]);
let mut sel = if pickadd || shift { self.selection() } else { Vec::new() };
let had = sel.contains(&h);
if shift && (pickadd || had) {
sel.retain(|x| !hs.contains(x));
} else if !had {
sel.extend(hs);
}
self.set_selection(sel);
}
Some(h) => self.idle_pick(h, shift),
None => self.pending_window = Some(PendingWindow { corner: p, during_command: false }),
}
Ok(())
}
/// An idle click that picked `h`, with [`Self::idle_click`]'s PICKADD and Shift rules.
fn idle_pick(&mut self, h: Handle, shift: bool) {
let pickadd = self.settings.pickadd;
// With group selection the object's selectable groups come (or go) with it.
let hs = cmd::groups::expand(self, vec![h]);
let mut sel = if pickadd || shift { self.selection() } else { Vec::new() };
let had = sel.contains(&h);
if shift && (pickadd || had) {
sel.retain(|x| !hs.contains(x));
} else if !had {
sel.extend(hs);
}
self.set_selection(sel);
}
/// Whether a click at `p` selects objects by picking: no command running, or a command at
/// "Select objects" with no selection window or typed mode under way.
pub fn click_picks(&self) -> bool {
self.pending_window.is_none()
&& match self.running.as_ref() {
None => self.lisp_prompt().is_none(),
Some(r) => self.select_mode.is_none() && r.machine.prompt(self).accept.select,
}
}
/// The objects under the pick box at `p` when selection cycling is on and more than one is
/// there: what a click at `p` would choose between.
fn cycling_hits(&self, p: Vec2) -> Vec<Handle> {
if !self.settings.selection_cycling {
return Vec::new();
}
let hs = self.pick_candidates(p);
if hs.len() > 1 { hs } else { Vec::new() }
}
/// Every object under the pick box at `p`, nearest first, when a click there picks objects
/// (empty otherwise): what Shift+Space steps the highlight through.
pub fn pick_candidates(&self, p: Vec2) -> Vec<Handle> {
if !self.click_picks() {
return Vec::new();
}
let ap = self.pixel_size() * self.settings.pickbox.max(1.0) * 1.5;
self.doc().map(|d| select::pick_all(d, &self.space(), p, ap)).unwrap_or_default()
}
/// Whether selection cycling marks `p` with its badge (SELECTIONCYCLING 1 or 2): overlapping
/// objects a click there would choose between.
pub fn cycling_badge(&self, p: Vec2) -> bool {
self.settings.selection_cycling_display >= 1 && !self.cycling_hits(p).is_empty()
}
/// The objects a click at `p` lists to choose from (SELECTIONCYCLING 2), nearest first; empty
/// when the click should pick as usual.
pub fn cycling_candidates(&self, p: Vec2) -> Vec<Handle> {
if self.settings.selection_cycling_display < 2 {
return Vec::new();
}
self.cycling_hits(p)
}
/// Pick `h` from the selection cycling list, as a click on it alone would have: into the
/// command at "Select objects", else into the selection (Shift as for [`Self::idle_click`]).
pub fn pick_cycled(&mut self, h: Handle, shift: bool) -> Result<()> {
// The list may be older than an undo or an erase.
if self.doc()?.entity(h).is_none() {
return Ok(());
}
if self.running.is_some() {
let hs = cmd::groups::expand(self, vec![h]);
return self.input(Input::Pick(hs));
}
self.idle_pick(h, shift);
Ok(())
}
/// Remember the selection used by a command as "Previous".
pub fn remember_selection(&mut self, sel: &[Handle]) {
if let Ok(st) = self.state_mut() {
+153 -27
View File
@@ -307,11 +307,32 @@ fn chain(a: &[Value], ok: fn(std::cmp::Ordering) -> bool) -> R {
}
Ok(Value::t())
}
/// `=` and `/=`: numbers and strings compare as [`compare`] does, and any other atom compares by
/// what it is, as `eq` does. AutoLISP answers `(= (type x) 'LIST)` -- the idiom for a type test,
/// since `type` returns a symbol -- where coercing that symbol to a number failed the whole
/// routine with "bad argument type" instead (#1597). A mismatch is `nil`, not an error, so
/// `(= 1 "1")` and `(= '(1) '(1))` answer too. The ordering comparisons stay numbers and strings
/// only, as AutoLISP has them.
fn eq_chain(a: &[Value], want_equal: bool) -> R {
arity(a, 1, usize::MAX)?;
for w in a.windows(2) {
let [x, y] = w else { continue };
let same = match (x, y) {
(Value::Str(_), Value::Str(_)) => compare(x, y)?.is_eq(),
_ if x.as_number().is_some() && y.as_number().is_some() => compare(x, y)?.is_eq(),
_ => eq(x, y),
};
if same != want_equal {
return Ok(Value::Nil);
}
}
Ok(Value::t())
}
fn num_eq(a: &[Value]) -> R {
chain(a, |o| o.is_eq())
eq_chain(a, true)
}
fn num_ne(a: &[Value]) -> R {
chain(a, |o| o.is_ne())
eq_chain(a, false)
}
fn lt(a: &[Value]) -> R {
chain(a, |o| o.is_lt())
@@ -658,19 +679,47 @@ fn wcmatch(a: &[Value]) -> R {
arity(a, 2, 2)?;
let (s, pat) = (string(&a[0])?, string(&a[1])?);
let text: Vec<char> = s.chars().collect();
Ok(Value::bool(split_patterns(&pat).iter().any(|p| match_pattern(&text, p))))
// Native wildcard matching runs as a single interpreter step, so its internal `*` backtracking
// must not explore exponentially many placements unchecked (CWE-400). Charge every glob step
// against a shared budget and fail the call if it is exhausted.
let mut budget: u64 = WCMATCH_BUDGET;
for p in split_patterns(&pat, &mut budget).map_err(|()| LispError::new("wcmatch: pattern too complex"))? {
match match_pattern(&text, &p, &mut budget) {
Ok(true) => return Ok(Value::bool(true)),
Ok(false) => {}
Err(()) => return Err(LispError::new("wcmatch: pattern too complex")),
}
}
Ok(Value::bool(false))
}
fn split_patterns(pat: &str) -> Vec<Vec<char>> {
/// Shared step budget for one `wcmatch` call's wildcard matching. Large enough for the patterns
/// AutoLISP routines legitimately use (including the pathological-but-bounded cases the tests
/// cover), but finite so truly exponential `*`-heavy patterns are rejected rather than hanging.
const WCMATCH_BUDGET: u64 = 200_000_000;
/// Spend one wildcard work unit, returning an error when the shared budget is exhausted.
fn charge_match(budget: &mut u64) -> Result<(), ()> {
*budget = budget.checked_sub(1).ok_or(())?;
Ok(())
}
/// Split comma-separated wildcard alternatives while preserving escapes and bracket sets.
/// Every consumed character spends from the same budget used for matching.
fn split_patterns(pat: &str, budget: &mut u64) -> Result<Vec<Vec<char>>, ()> {
let mut out = vec![Vec::new()];
let mut chars = pat.chars().peekable();
let mut in_set = false;
while let Some(c) = chars.next() {
charge_match(budget)?;
match c {
'`' => {
if let Some(cur) = out.last_mut() {
cur.push('`');
cur.extend(chars.next());
if let Some(escaped) = chars.next() {
charge_match(budget)?;
cur.push(escaped);
}
}
}
'[' => {
@@ -693,41 +742,96 @@ fn split_patterns(pat: &str) -> Vec<Vec<char>> {
}
}
}
out
Ok(out)
}
fn match_pattern(text: &[char], pat: &[char]) -> bool {
/// Match one wildcard alternative, including leading `~` negation, within the shared budget.
fn match_pattern(text: &[char], pat: &[char], budget: &mut u64) -> Result<bool, ()> {
if let Some(('~', rest)) = pat.split_first().map(|(c, r)| (*c, r))
&& !rest.is_empty()
{
return !glob(text, rest, 0);
return glob(text, rest, 0, budget).map(|m| !m);
}
glob(text, pat, 0)
glob(text, pat, 0, budget)
}
/// Iterative-enough glob with `*` backtracking; `depth` bounds the recursion on `*`.
fn glob(text: &[char], pat: &[char], depth: usize) -> bool {
/// Iterative-enough glob with `*` backtracking; `depth` bounds the recursion on `*`. Every step is
/// charged against `budget`; `Err(())` means the shared budget ran out (treated as too complex).
fn glob(text: &[char], pat: &[char], depth: usize, budget: &mut u64) -> Result<bool, ()> {
glob_cached(text, pat, depth, budget, &mut std::collections::HashSet::new())
}
/// Match suffixes while memoizing failures; both matching work and retained failure entries are bounded.
fn glob_cached(
text: &[char],
pat: &[char],
depth: usize,
budget: &mut u64,
failed: &mut std::collections::HashSet<(usize, usize, usize)>,
) -> Result<bool, ()> {
charge_match(budget)?;
let key = (text.len(), pat.len(), depth);
if failed.contains(&key) {
return Ok(false);
}
let matched = glob_inner(text, pat, depth, budget, failed)?;
// Avoid revisiting the same failed suffix exponentially. Bound the memo table independently
// of the step limit so a very large pattern cannot turn the optimization into an allocation bomb.
if !matched && failed.len() < 100_000 {
failed.insert(key);
}
Ok(matched)
}
/// Evaluate wildcard tokens and backtracking branches under shared work and recursion limits.
fn glob_inner(
text: &[char],
pat: &[char],
depth: usize,
budget: &mut u64,
failed: &mut std::collections::HashSet<(usize, usize, usize)>,
) -> Result<bool, ()> {
if *budget == 0 {
return Err(());
}
*budget -= 1;
if depth > 64 {
return false;
return Ok(false);
}
let (mut t, mut p) = (0usize, 0usize);
while p < pat.len() {
charge_match(budget)?;
match pat[p] {
'*' => {
let rest = &pat[p + 1..];
return (t..=text.len()).any(|i| glob(&text[i..], rest, depth + 1));
for i in t..=text.len() {
if glob_cached(&text[i..], rest, depth + 1, budget, failed)? {
return Ok(true);
}
}
return Ok(false);
}
'`' => {
let Some(&lit) = pat.get(p + 1) else { return false };
let Some(&lit) = pat.get(p + 1) else { return Ok(false) };
if text.get(t) != Some(&lit) {
return false;
return Ok(false);
}
t += 1;
p += 2;
}
'[' => {
let Some(close) = pat[p..].iter().position(|c| *c == ']').map(|i| p + i) else { return false };
let Some(&c) = text.get(t) else { return false };
let mut close = p;
while let Some(c) = pat.get(close) {
charge_match(budget)?;
if *c == ']' {
break;
}
close += 1;
}
if close == pat.len() {
return Ok(false);
}
let Some(&c) = text.get(t) else { return Ok(false) };
let set = &pat[p + 1..close];
let (neg, set) = match set.split_first() {
Some(('~', r)) if !r.is_empty() => (true, r),
@@ -736,6 +840,7 @@ fn glob(text: &[char], pat: &[char], depth: usize) -> bool {
let mut hit = false;
let mut i = 0;
while i < set.len() {
charge_match(budget)?;
if set.get(i + 1) == Some(&'-') && i + 2 < set.len() {
if set[i] <= c && c <= set[i + 2] {
hit = true;
@@ -749,13 +854,13 @@ fn glob(text: &[char], pat: &[char], depth: usize) -> bool {
}
}
if hit == neg {
return false;
return Ok(false);
}
t += 1;
p = close + 1;
}
pc => {
let Some(&c) = text.get(t) else { return false };
let Some(&c) = text.get(t) else { return Ok(false) };
let ok = match pc {
'#' => c.is_ascii_digit(),
'@' => c.is_alphabetic(),
@@ -764,14 +869,14 @@ fn glob(text: &[char], pat: &[char], depth: usize) -> bool {
other => other == c,
};
if !ok {
return false;
return Ok(false);
}
t += 1;
p += 1;
}
}
}
t == text.len()
Ok(t == text.len())
}
// ---------------- geometry ----------------
@@ -961,15 +1066,16 @@ fn findfile(a: &[Value]) -> R {
Ok(if file_exists(&p) { Value::Str(p) } else { Value::Nil })
}
/// Read a regular text file up to 64 MiB, replacing invalid UTF-8 and returning `None` on read failure.
#[cfg(not(target_arch = "wasm32"))]
pub fn read_text_file(path: &str) -> Option<String> {
let bytes = std::fs::read(path).ok()?;
// Cap what a routine can pull into memory.
if bytes.len() > 64 * 1024 * 1024 {
return None;
}
// Read through a byte-limited, regular-file-only reader so a path naming an endless device
// cannot exhaust memory before the 64 MiB cap is enforced (CWE-400).
const MAX: usize = 64 * 1024 * 1024;
let bytes = crate::capped_read::read_capped(std::path::Path::new(path), MAX).ok()?;
Some(String::from_utf8_lossy(&bytes).into_owned())
}
/// Return `None` on the web, where native filesystem paths are unavailable.
#[cfg(target_arch = "wasm32")]
pub fn read_text_file(_path: &str) -> Option<String> {
None
@@ -1482,5 +1588,25 @@ pub fn real(r: f64) -> String {
/// `wcmatch` for other callers (`ssget` filters).
pub fn wild_match(text: &str, pattern: &str) -> bool {
let t: Vec<char> = text.chars().collect();
split_patterns(pattern).iter().any(|p| match_pattern(&t, p))
let mut budget: u64 = WCMATCH_BUDGET;
split_patterns(pattern, &mut budget).is_ok_and(|patterns| patterns.iter().any(|p| match_pattern(&t, p, &mut budget) == Ok(true)))
}
#[cfg(test)]
mod wildcard_budget_tests {
use super::*;
/// Pattern splitting, literal matching, and bracket scans all stop when their shared fuel runs out.
#[test]
fn all_native_scans_consume_fuel() {
assert!(split_patterns("abcdefgh", &mut 4).is_err());
assert!(split_patterns("`a`b`c", &mut 4).is_err());
let text: Vec<_> = "abcdefgh".chars().collect();
assert!(glob(&text, &text, 0, &mut 4).is_err());
let pattern: Vec<_> = "[abcdefgh]".chars().collect();
assert!(glob(&['!'], &pattern, 0, &mut 6).is_err());
// Enough for finding the closing bracket, but not scanning the entire set again.
assert!(glob(&['!'], &pattern, 0, &mut 14).is_err());
assert_eq!(glob(&['a'], &pattern, 0, &mut 100), Ok(true));
}
}
+57 -9
View File
@@ -4,7 +4,8 @@
//! `angtof`).
//!
//! Entity lists use the DXF groups the file reader and writer use (through
//! [`crate::cmd::file::entity_codec`]), so what `entget` shows is what a DXF file holds.
//! [`crate::cmd::file::entity_codec`]), so what `entget` shows is what a DXF file holds, except
//! for angles, which are radians in a list and degrees in a file.
use std::sync::{Arc, Mutex};
@@ -150,16 +151,34 @@ pub fn from_json(v: &serde_json::Value) -> Value {
}
}
/// A LISP value as JSON (for `setvar`).
pub fn to_json(v: &Value) -> serde_json::Value {
match v {
/// A LISP value as JSON (for `setvar`), bounded so a shared cons structure cannot expand into
/// exponentially many owned array elements before the setter can reject it (CWE-400).
///
/// Largest number of elements it may expand, and the deepest nesting it follows.
const TO_JSON_BUDGET: u64 = 2_000_000;
const TO_JSON_MAX_DEPTH: usize = 256;
/// Convert a LISP value to owned JSON within element and nesting limits.
/// Return `None` before a shared cons structure can expand beyond the remaining budget.
fn to_json_bounded(v: &Value, depth: usize, budget: &mut u64) -> Option<serde_json::Value> {
if depth > TO_JSON_MAX_DEPTH || *budget == 0 {
return None;
}
*budget -= 1;
Some(match v {
Value::Nil => serde_json::Value::Null,
Value::Int(i) => serde_json::json!(i),
Value::Real(r) => serde_json::json!(r),
Value::Str(s) => serde_json::json!(&**s),
Value::Cons(_) => serde_json::Value::Array(v.iter().map(|x| to_json(&x)).collect()),
Value::Cons(_) => {
let mut arr = Vec::new();
for x in v.iter() {
arr.push(to_json_bounded(&x, depth + 1, budget)?);
}
serde_json::Value::Array(arr)
}
other => serde_json::json!(to_text(other, false)),
}
})
}
pub fn getvar(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
@@ -168,10 +187,15 @@ pub fn getvar(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
Ok(Flow::Value(crate::sysvars::get(ctx.s, &name).map(|v| from_json(&v)).unwrap_or_default()))
}
/// Set a system variable after bounded JSON conversion, reporting invalid or oversized values as LISP errors.
pub fn setvar(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
arity(&a, 2, 2)?;
let name = string(&a[0])?;
match crate::sysvars::set(ctx.s, &name, &to_json(&a[1])) {
let mut budget: u64 = TO_JSON_BUDGET;
let Some(json) = to_json_bounded(&a[1], 0, &mut budget) else {
return Err(LispError::new(format!("AutoCAD variable setting rejected: {} value too large", to_text(&a[0], true))));
};
match crate::sysvars::set(ctx.s, &name, &json) {
Ok(()) => Ok(Flow::Value(a[1].clone())),
Err(_) => Err(LispError::new(format!("AutoCAD variable setting rejected: {} {}", to_text(&a[0], true), to_text(&a[1], true)))),
}
@@ -185,6 +209,27 @@ fn point_group(code: i32) -> bool {
(10..=18).contains(&code) || (110..=112).contains(&code) || code == 210 || (1010..=1013).contains(&code)
}
/// Groups 50–58 are angles: degrees in a DXF file, radians in entity lists (DXF Reference,
/// group code value types). `f` converts each one; an MTEXT's 50 after its column type (75) is
/// a column height.
fn convert_angles(kind: &str, tags: &mut [Tag], f: fn(f64) -> f64) {
let mtext = kind.eq_ignore_ascii_case("MTEXT");
for t in tags {
if mtext && t.code == 75 {
break;
}
if !(50..=58).contains(&t.code) {
continue;
}
let v = match t.value {
cadcraft_dxf::Value::Real(r) => r,
cadcraft_dxf::Value::Int(i) => i as f64,
_ => continue,
};
t.value = cadcraft_dxf::Value::Real(f(v));
}
}
fn tag_value(t: &Tag) -> Value {
match &t.value {
cadcraft_dxf::Value::Str(s) | cadcraft_dxf::Value::Hex(s) => Value::str(s),
@@ -274,7 +319,8 @@ pub fn entget(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
let h = ename(&a[0])?;
let Ok(d) = ctx.s.doc() else { return Ok(Flow::Value(Value::Nil)) };
let Some(e) = d.entity(h) else { return Ok(Flow::Value(Value::Nil)) };
let (kind, tags) = (codec()?.groups)(d, e);
let (kind, mut tags) = (codec()?.groups)(d, e);
convert_angles(&kind, &mut tags, f64::to_radians);
let layout = match space_of(d, h) {
Some(Space::Paper(name)) => name,
_ => "Model".into(),
@@ -296,6 +342,7 @@ pub fn entget(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
/// The entity a list describes, as the reader makes it; a missing layer (8) is the current one.
fn entity_from_list(ctx: &Ctx<'_>, list: &Value) -> Result<(String, cadcraft_doc::Common, cadcraft_doc::EntityKind), LispError> {
let (kind, mut tags) = list_to_groups(list)?;
convert_angles(&kind, &mut tags, f64::to_degrees);
if !tags.iter().any(|t| t.code == 8) {
let layer = crate::sysvars::get(ctx.s, "CLAYER").and_then(|v| v.as_str().map(str::to_string)).unwrap_or_else(|| "0".into());
tags.insert(0, Tag::s(8, layer));
@@ -616,7 +663,8 @@ fn parse_filter_item(items: &[Value], i: &mut usize, depth: usize) -> Result<Fil
fn matches_filter(d: &cadcraft_doc::Drawing, e: &Entity, f: &Filter) -> bool {
let groups = || match crate::cmd::file::entity_codec() {
Some(c) => {
let (kind, tags) = (c.groups)(d, e);
let (kind, mut tags) = (c.groups)(d, e);
convert_angles(&kind, &mut tags, f64::to_radians);
let mut l = vec![Value::cons(Value::Int(0), Value::str(&kind))];
l.extend(groups_to_list(&tags));
l

Some files were not shown because too many files have changed in this diff Show More