Compare commits

...
Author SHA1 Message Date
Dylan M. Taylor 9e094c2f92 fix(draw2): import machines, distinct_points and ok from cmd 2026-10-25 19:43:00 -04:00
Dylan M. Taylor dd802e0956 fix: keep the imports the explicit lists still need after the rebase 2026-10-25 19:26:26 -04:00
Dylan M. Taylor f4485bbee7 Fix unused and missing imports the batch's CI gate reported
Remove two unused imports and restore the json macro and resolve_alias
in test modules that the explicit import lists had dropped.
2026-10-25 19:26:25 -04:00
Dylan M. Taylor 97d570c421 refactor: replace wildcard imports with explicit ones (S2208, batch_02) 2026-10-25 19:26:25 -04:00
Dylan M. Taylor 1b6861698c refactor: cut test helper and two DXF writer closures to 7 parameters (S107) (#2048) 2026-10-25 19:22:31 -04:00
Dylan M. Taylor f24af47ffb refactor: cut helper signatures to 7 parameters (S107) (#2047)
* refactor: cut four helper signatures to 7 parameters (S107)

* fix: item_json params

* fix: keep the eight-argument dim calls to seven after rebase
2026-10-25 19:21:26 -04:00
Dylan M. Taylor 055b06a2f4 refactor: explicit imports instead of wildcards (S2208, batch_08) (#2045)
* refactor: replace wildcard imports with explicit ones (S2208, batch_08)

* Restore imports that the cadcraft batch dropped so the engine compiles

Explicit import lists missed names the files still use. Restore them and
the test-module globs.
2026-10-25 19:21:22 -04:00
Dylan M. Taylor 660a7881d1 refactor: explicit imports instead of wildcards (S2208, batch_07) (#2044)
* refactor: replace wildcard imports with explicit ones (S2208, batch_07)

* Restore imports that the cadcraft batch dropped so the engine compiles

Explicit import lists missed names the files still use. Restore them and
the test-module globs.
2026-10-25 19:21:17 -04:00
Dylan M. Taylor 3d8301b965 refactor: explicit imports instead of wildcards (S2208, batch_06) (#2043)
* refactor: replace wildcard imports with explicit ones (S2208, batch_06)

* Restore imports that the cadcraft batch dropped so the engine compiles

The batch replaced glob imports with explicit lists that missed names
the files still use, and pointed some sub-module imports at the wrong
level. Restore the required imports and the test module globs.

* Format pedit_vertex imports so the CI fmt gate passes
2026-10-25 19:21:13 -04:00
Dylan M. Taylor cb8b1709e9 refactor: explicit imports instead of wildcards (S2208, batch_05) (#2042)
* refactor: replace wildcard imports with explicit ones (S2208, batch_05)

* refactor: replace wildcard imports with explicit ones (S2208, batch_05)

* Format imports so the CI fmt gate passes
2026-10-25 19:21:08 -04:00
Dylan M. Taylor 66ecce06f5 refactor: explicit imports instead of wildcards (S2208, batch_01) (#2038)
* refactor: replace wildcard imports with explicit ones (S2208, batch_01)

* refactor: replace wildcard imports with explicit ones (S2208, batch_01)

* Format imports so the CI fmt gate passes
2026-10-25 19:19:58 -04:00
Dylan M. Taylor 004287d919 refactor: explicit imports instead of wildcards (S2208, batch_03) (#2040)
* refactor: replace wildcard imports with explicit ones (S2208, batch_03)

* refactor: replace wildcard imports with explicit ones (S2208, batch_03)

* Format imports so the CI fmt gate passes
2026-10-25 18:18:33 -04:00
Dylan M. Taylor 656dd4d09a refactor: explicit imports instead of wildcards (S2208, batch_00) (#2037)
* refactor: replace wildcard imports with explicit ones (S2208, batch_00)

* refactor: replace wildcard imports with explicit ones (S2208, batch_00)

Explicit lists now resolve the names each file uses, and the test
module keeps its glob so it still sees the parent's items.
2026-10-25 18:18:29 -04:00
Dylan M. Taylor 876de8ad2f build: pin craft-update to craft-libs main (495e597) (#2168)
Picks up the startup prompt without the glyphs a font may lack (storytold/craft-libs#343, #344): the close mark is painted and the version pill is ASCII, so apps that panic on a missing glyph no longer take the prompt down.

Part of tracking issue storytold/craft-libs#319
2026-10-25 17:52:38 -04:00
Dylan M. TaylorandCodebuff 4feccf0abd build: pin craft-update to craft-libs main (495e597) (#2163)
Picks up the update prompt fix that draws its close mark and version pill
without glyphs a font may lack, so the prompt no longer takes the app down
when a bundled face has no ✕ or →.

Part of tracking issue storytold/craft-libs#319

🤖 Generated with Codebuff

Co-authored-by: Codebuff <noreply@codebuff.com>
2026-10-25 17:37:01 -04:00
Dylan M. Taylor dfddd13656 build: pin craft-update to craft-libs main (db19c37) (#2161)
Picks up the fix that keeps the close request pending while the app holds unsaved work, the executable-name check in the Windows update helper, and the zip 9 / dirs-free build.

Part of tracking issue storytold/craft-libs#319
2026-10-25 15:33:55 -04:00
Dylan M. Taylor 2f82ecdbf1 feat: startup update prompt modal and direct self-updating (#2141)
* feat: startup update prompt modal and direct self-updating

Ref: storytold/craft-libs#319

* build: pin craft-update to craft-libs main (b9ba47f) and refresh the lock

* refactor(updates): drive the flow with craft-update's Updater

Results are read independent of the prompt, a dropped worker is an error, failures return to the prompt, and no update starts over unsaved work. A manual check during the startup check is honored.
2026-10-25 14:52:54 -04:00
aiongg 7fc9f2756d Every craft-libs crate at craft-libs main a0916808 (#2153)
craft-ui and craft-docpath were pinned at intermediate commits of the egui
0.37 port (craft-libs#268), and the PDF stack at 6e7f285. a0916808 is main
after #268 merged and contains all three.
2026-10-25 14:02:27 -04:00
Dylan M. Taylor 754b609d09 Merge pull request #1992 from storytold/dependabot/cargo/cargo-dependencies-29f2f66057
chore(deps): bump lyon_tessellation from 1.0.22 to 1.0.23 in the cargo-dependencies group across 1 directory
2026-10-25 12:30:03 -04:00
d6e712db41 CLI --save, convert and sample write through a staged file so a failed write keeps the previous drawing (#912) (#982)
* CLI --save, convert and sample write through a staged file so a failed write keeps the previous drawing (#912)

* cli: write through the engine's atomic_write instead of a second copy

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-25 12:25:51 -04:00
MarekSpodBiedry d73e379fd2 Autosave no longer writes the old host as recovery when the Block Editor's changes can't be composed (#918) (#980) 2026-10-25 12:25:48 -04:00
MarekSpodBiedry 8343045863 Fix grid index span overflow with a very large view (#920) (#979) 2026-10-25 12:25:44 -04:00
MarekSpodBiedry 2528671106 Cap HATCH spline edge degree at 10 like the SPLINE reader (#923) (#978) 2026-10-25 12:25:41 -04:00
MarekSpodBiedry 3f92d57b79 Geometry: circle intersection judges tangency against the smaller circle, so a small circle crossing a huge one gives two points (#926) (#976) 2026-10-25 12:25:38 -04:00
MarekSpodBiedry c3c455fbbf Region: test very long segments against every segment so no crossing is missed (#929) (#975) 2026-10-25 12:25:34 -04:00
MarekSpodBiedry d0a1ea5268 Table commands reject a malformed handle instead of using the selected table (#934) (#970) 2026-10-25 12:25:31 -04:00
2994b52e03 Read dynamic block element tags from the entity's own xdata (#942) (#967)
* Read dynamic block element tags from the entity's own xdata (#942)

* dynblock test: an untagged object at index 1 is keyed 1_000_001

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-25 12:25:28 -04:00
0de051049c Keep associative-array parameters when an xref is loaded (#943) (#966)
* Keep associative-array parameters when an xref is loaded (#943)

* xref load test: initialise Ctx's new bytes counter

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-25 12:25:24 -04:00
cddf09beb2 DXF/DWG: read the drawing's WIPEOUTFRAME from its WIPEOUTVARIABLES object (#908)
AutoCAD stores the wipeout frame setting in the drawing's WIPEOUTVARIABLES object (group 70:
0 = frames hidden). CADCraft ignored it and always showed the frames (the default of 1), so
masks such as the diamond inside a numbered circle were outlined. The flag is now read into
the WIPEOUTFRAME setting.

Co-authored-by: simanov <asimfali@mail.ru>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 12:25:21 -04:00
1c85d339c3 Reject negative or out-of-range minutes/seconds in D'" angles (#2116)
Found while testing crates/engine/src/units.rs:333-340 (parse_dms)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-25 12:15:42 -04:00
63a5569970 In-place text editing accepts IME composition and commit (#1905)
* ui: accept IME composition and commit in the command line (#1761)

The command line is a custom buffer with keyboard focus staying on the
drawing, so no egui TextEdit ever owned the IME: egui never published an
IME output, winit never called set_ime_allowed(true), and a Windows IME
(Microsoft Pinyin, Japanese, Korean) could not even switch on. Even when
IME events arrived they were ignored, so in-place single-line TEXT edits
(and every other text prompt) accepted ASCII only.

- Publish an IME output (command-line rect and caret) while a prompt asks
  for text, enabling the OS IME; release it when the prompt no longer
  collects text, so ASCII typing and shortcuts keep their behaviour.
- Handle Event::Ime: Preedit is drawn as an underlined composition after
  the buffer but never saved; Commit inserts the committed string once.
  Newline/Return commits are ignored like egui's own text fields, and
  Enter/Escape/submit clear any composition.

Tests: a headless command-line test asserts the IME is enabled for a
TEXTEDIT prompt, the preedit stays out of the buffer, the commit lands
exactly once and the text entity changes; a kittest double-click test
edits TEXT in place with IME preedit and commit.

* cmdline IME: build the IMEOutput with egui 0.37's constructor and a context generation

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-25 12:13:56 -04:00
Nicholas Gower 5acec7d0be PLOTWINDOW: pick both corners even with ORTHO on (#1893)
* PLOTWINDOW: pick both corners even with ORTHO on

PLOTWINDOW's opposite-corner prompt used Prompt::base, so the canvas bent
the picked point onto the nearest axis with ORTHOMODE on: the second corner
landed above/beside the first, the window had no width or height, and
set_plot_window refused it. The prompt now uses Prompt::corner, the flag
ZOOM Window, RECTANG and ARRAYRECT already use to keep a rectangle's
opposite corner free of ortho. The first corner sets the base as before, so
the rubber band and direct distance entry still work.

Regression test drives the interactive machine through snap_cursor with
ORTHOMODE on, picks two non-axis-aligned corners and checks the stored
window keeps both extents; an ordinary LINE prompt still follows ortho.

Fixes #1641

* docs: document the functions touched by #1641
2026-10-25 12:13:53 -04:00
Jonasandcloudj 8f7421737d fix(ui): keep layout tabs stable when toggling Tool Sets (#2008)
Co-authored-by: cloudj <jonbahnes@gmail.com>
2026-10-25 12:13:49 -04:00
skylar taylor-barrickandskylartaylor 422d42411a fix: omit empty outlines in complex text shaping (#1960)
* fix: filter empty outlines in complex shaping

* docs: describe complex shaping and its empty-glyph regression

---------

Co-authored-by: skylartaylor <26588250+skylartaylor@users.noreply.github.com>
2026-10-25 12:13:45 -04:00
Patrik LermonandClaude Opus 5.5 0c15f7106c i18n(sv): translate 49 new strings (#2120)
Brings the Swedish catalog up to date with upstream main (e86d895).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 12:13:42 -04:00
NetterSilverandClaude Haiku 5.5 31de470926 Fall back to decimal for huge engineering-unit distances (#2114)
Found while testing crates/engine/src/units.rs:18-27 (format_distance, LUNITS 3 branch)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-25 12:13:39 -04:00
Marco Arnulfo 0f60269868 MCP: resend once when the app refuses a token read before --launch opened it (fixes #2087) (#2098) 2026-10-25 12:13:35 -04:00
Marco Arnulfo 3e6e1e534d Constraints: an ellipse solution stored with its axes swapped or mirrored must still meet every constraint (an axis end by index does not) (#2082) 2026-10-25 12:13:31 -04:00
NetterSilverandClaude Haiku 5.5 52aae9c1fb Fix wcmatch returning nil for patterns with many '*' wildcards (#2078)
Found while testing crates/engine/src/lisp/builtins.rs:798-800 (glob_inner depth limit; depth is incremented per '*' at line 808)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-25 12:13:28 -04:00
Marco Arnulfo a89be5f2dd A Fix on a derived point (a line's midpoint, an arc's end) holds the position it had before the edit (#2077) 2026-10-25 12:13:25 -04:00
NetterSilverandClaude Haiku 5.5 a4a3a342a0 Fix format_angle zeroing radians near a full turn at low precision (#2073)
Found while testing crates/engine/src/units.rs:124-127 (wrap_full_turn, used at line 106 for AUNITS 3 and at lines 105 and 120)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-25 12:13:21 -04:00
Marco Arnulfo 36fcc896f7 GETVAR CECOLOR reads back as text, as in AutoCAD (follow-up to #2014) (#2069)
CECOLOR now reads "BYLAYER", "BYBLOCK", the index as text ("1" after
"red") or "RGB:r,g,b" for a true colour, from getvar, LISP and the
SETVAR ? list, where it was the ACI number (256, 0, 1, or the nearest
index of a true colour). The header still keeps the index and
CECOLOR_RGB, so DXF output and new objects are unchanged.

SETVAR CECOLOR takes "RGB:r,g,b" too, so a value saved with getvar
can be set back, true colour included.
2026-10-25 12:13:18 -04:00
NetterSilverandClaude Haiku 5.5 a0a691e1f7 Reject leading '+' in hex handle and colour parsing (#2067)
Found while testing crates/doc/src/entity.rs:21

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-25 12:13:14 -04:00
NetterSilverandClaude Haiku 5.5 097c53bee3 Ignore last vertex bulge in open Polyline::centroid (#2053)
Found while testing crates/geom/src/curve.rs:568-581 (Polyline::centroid); compare area() at 533-538

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-25 12:13:11 -04:00
NetterSilverandClaude Haiku 5.5 5ec3a18aa7 Escape literal \U+ and \M+ backslashes in the DXF writer (#2050)
Found while testing crates/dxf/src/lib.rs:511-524 (escape_unicode) and 226-258 (unescape_unicode, applied in parse_ascii_with_limit at 278)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-25 12:13:07 -04:00
Dylan M. TaylorandClaude Sonnet 5.5 b37b7ceb7b refactor: replace wildcard imports with explicit ones (S2208, batch_09) (#2046)
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 12:13:04 -04:00
aiongg 1d9ddd2406 "Save changes?" and Drawing Recovery appear at once (#2144)
An egui modal showed a frame late (its first frame only measured it) and
then faded in, dim included, over 0.2 s. These alerts answer a key press or
menu choice, so they now skip the fade, and their first show reruns the pass
with the measured size, which draws them in the first frame presented.
2026-10-25 12:07:54 -04:00
aiongg 0a14fc7d3a macOS menu choices and their shortcuts run at once (#2140)
* macOS menu choices and their shortcuts run at once

A menu choice is no window event, so the app ran it only at its next frame
for another reason: File ▸ Close (Cmd+W) on an edited drawing showed
"Save changes?" and dimmed the floating palettes a second or two late. The
menu's event handler now wakes the app.

* cargo fmt after merging main
2026-10-25 12:07:15 -04:00
aiongg bbbd9eec51 craft-pdf and its PDF backends back at craft-libs 6e7f285 (#2147)
The egui 0.37 migration moved craft-pdf, krilla, hayro-syntax and
hayro-jbig2 to 7895f5e, 206 commits older, though none of them depends on
egui. 6e7f285 keeps krilla patch 13 (non-finite link and page-box geometry
refused), craft-libs#282 (URI links only for http(s) and mailto) and
craft-libs#255 (CCITT and JPEG 2000 decode bounds). The embedded ICC
profiles are identical at both revisions.
2026-10-25 12:06:35 -04:00
skylar taylor-barrick f2b35e957e Migrate CADCraft to egui 0.37 (#2119)
* egui037: migrate CADCraft UI and preserve font notices

* M0: clarify screenshot cancellation after issue

* Document migration UI contracts and regression assertions
2026-10-25 10:48:48 -04:00
dependabot[bot] d0e83aefa6 chore(deps): bump lyon_tessellation
Bumps the cargo-dependencies group with 1 update in the / directory: [lyon_tessellation](https://github.com/nical/lyon).


Updates `lyon_tessellation` from 1.0.22 to 1.0.23
- [Release notes](https://github.com/nical/lyon/releases)
- [Commits](https://github.com/nical/lyon/commits)

---
updated-dependencies:
- dependency-name: lyon_tessellation
  dependency-version: 1.0.23
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: cargo-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-23 17:02:24 +00:00
190 changed files with 6696 additions and 906 deletions
+1 -1
View File
@@ -22,7 +22,7 @@ jobs:
uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
with:
# Same craft-fonts commit as release.yml.
craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
timeout-minutes: 90
packages: >-
pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcb1-dev libxcursor-dev
+1 -1
View File
@@ -29,4 +29,4 @@ jobs:
# 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
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
+1 -1
View File
@@ -67,7 +67,7 @@ jobs:
with:
app-name: cadcraft
app-title: CADCraft
craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
version: ${{ inputs.version }}
secrets:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
+5
View File
@@ -23,6 +23,11 @@ Generated-in-code assets are original and have no file to list:
| Asset | Author | Source | Licence | Notes |
|---|---|---|---|---|
| `epaint_default_fonts::EGUI_ICONS`, `epaint_default_fonts::EMOJI_ICON` (dependency font inputs) | John Slegers | [egui 173cc737](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [emoji-icon-font](https://github.com/jslegers/emoji-icon-font) | MIT (Copyright (c) 2014 John Slegers; full upstream text in `NOTICE`) | egui-icons.ttf is the five-glyph subset; emoji-icon-font.ttf is the full monochrome fallback. Registered by the production default font definitions; dependency assets unmodified here, no font file copied into this repository. |
| `krilla 0.8.2 / icc/sGrey-v2-magic.icc` (dependency-embedded) | Clinton Ingram | [craft-libs a091680 vendor/krilla/icc/sGrey-v2-magic.icc](https://github.com/storytold/craft-libs/blob/a0916808f7d78bed8c7b90cdf937176015454a7f/vendor/krilla/icc/sGrey-v2-magic.icc) (Krilla 0.8.2 upstream 3ffdf0588cf98050aad6edba51ca70162e1fb5b5) | CC0-1.0 | Embedded by the pinned PDF writer (`craft-pdf` 0.1.0, Krilla 0.8.2); unmodified dependency asset, no file copied into this repository. SHA-256 `4110169287e5f1b5149ca9f1da1814d32943905bba8030e849d1a3b729a19481`. |
| `krilla 0.8.2 / icc/sGrey-v4.icc` (dependency-embedded) | Clinton Ingram | [craft-libs a091680 vendor/krilla/icc/sGrey-v4.icc](https://github.com/storytold/craft-libs/blob/a0916808f7d78bed8c7b90cdf937176015454a7f/vendor/krilla/icc/sGrey-v4.icc) (Krilla 0.8.2 upstream 3ffdf0588cf98050aad6edba51ca70162e1fb5b5) | CC0-1.0 | Embedded by the pinned PDF writer (`craft-pdf` 0.1.0, Krilla 0.8.2); unmodified dependency asset, no file copied into this repository. SHA-256 `00c0f94e09127520a17dc0e1d9264b5702081d96dbdb1549ee88e3631ce42a9d`. |
| `krilla 0.8.2 / icc/sRGB-v2-magic.icc` (dependency-embedded) | Clinton Ingram | [craft-libs a091680 vendor/krilla/icc/sRGB-v2-magic.icc](https://github.com/storytold/craft-libs/blob/a0916808f7d78bed8c7b90cdf937176015454a7f/vendor/krilla/icc/sRGB-v2-magic.icc) (Krilla 0.8.2 upstream 3ffdf0588cf98050aad6edba51ca70162e1fb5b5) | CC0-1.0 | Embedded by the pinned PDF writer (`craft-pdf` 0.1.0, Krilla 0.8.2); unmodified dependency asset, no file copied into this repository. SHA-256 `af4efe28f6d311799865f325ba39184a2e978b113ca74124d60af7de22b105f4`. |
| `krilla 0.8.2 / icc/sRGB-v4.icc` (dependency-embedded) | Clinton Ingram | [craft-libs a091680 vendor/krilla/icc/sRGB-v4.icc](https://github.com/storytold/craft-libs/blob/a0916808f7d78bed8c7b90cdf937176015454a7f/vendor/krilla/icc/sRGB-v4.icc) (Krilla 0.8.2 upstream 3ffdf0588cf98050aad6edba51ca70162e1fb5b5) | CC0-1.0 | Embedded by the pinned PDF writer (`craft-pdf` 0.1.0, Krilla 0.8.2); unmodified dependency asset, no file copied into this repository. SHA-256 `c56e1685d888f5edb92fe07f2750f387f8fe8e91b32ff8fb0b56bfbbb9458353`. |
| `assets/app-icon/hicolor/128x128/apps/ai.storyteller.cadcraft.png` | CADCraft contributors | generated from `assets/app-icon/cadcraft-1024.png` | MIT OR Apache-2.0 | Linux hicolor icon |
| `assets/app-icon/hicolor/16x16/apps/ai.storyteller.cadcraft.png` | CADCraft contributors | generated from `assets/app-icon/cadcraft-1024.png` | MIT OR Apache-2.0 | Linux hicolor icon |
| `assets/app-icon/hicolor/24x24/apps/ai.storyteller.cadcraft.png` | CADCraft contributors | generated from `assets/app-icon/cadcraft-1024.png` | MIT OR Apache-2.0 | Linux hicolor icon |
Generated
+701 -106
View File
File diff suppressed because it is too large Load Diff
+35 -13
View File
@@ -7,7 +7,7 @@ members = ["crates/*", "apps/*", "xtask"]
version = "0.13.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
rust-version = "1.95"
repository = "https://github.com/storytold/cadcraft"
[workspace.dependencies]
@@ -25,10 +25,10 @@ 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 = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae" }
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f" }
# PDF writing (krilla) for plots, and reading them back in tests. The root patches below pin
# craft-pdf's patched backends to the same revision (Cargo ignores dependency-local patches).
craft-pdf = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae", default-features = false }
craft-pdf = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f", default-features = false }
# foundation
thiserror = "2"
@@ -53,19 +53,17 @@ encoding_rs = "0.8.35"
quick-xml = "0.42"
# ui
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae", features = ["serde"] }
egui = "0.36"
egui_kittest = "0.36"
eframe = { version = "0.36", default-features = false }
egui-wgpu = "0.36"
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f", features = ["serde"] }
egui = { version = "0.37", default-features = false, features = ["default_fonts", "monochrome_emoji_fonts"] }
egui_kittest = "0.37"
eframe = { version = "0.37", default-features = false }
egui-wgpu = "0.37"
# Monotonic time on native and web (performance.now on wasm).
web-time = "1.1"
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", features = ["egui"] }
rfd = "0.17"
arboard = { version = "3.6", default-features = false }
[patch.crates-io]
krilla = { version = "=0.8.2", git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae" }
hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae" }
hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae" }
[workspace.lints.rust]
unsafe_code = "forbid"
@@ -88,3 +86,27 @@ opt-level = 3
lto = "thin"
codegen-units = 16
debug = 0
# PDF backends for craft-pdf: pinned to the same reviewed craft-libs revision as craft-pdf itself
# (Cargo ignores dependency-local patches).
[patch.crates-io]
krilla = { version = "=0.8.2", git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f" }
hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f" }
hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f" }
# Keep the complete egui family and native event bridge on reviewed fork commits.
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" }
+30 -4
View File
@@ -15,10 +15,36 @@ Mozilla Public License 2.0 and is used unmodified as a separate library (crates/
is available from crates.io and the repository above.
None: every bundled asset is original to CADCraft (icons, drafting font, hatch patterns and
linetypes are drawn or defined in code). The per-file list is in ATTRIBUTION.md. Builds made with
the optional craft-fonts input (CRAFT_FONTS_DIR) embed the fonts listed in its ATTRIBUTION.md,
each under the SIL Open Font License 1.1.
CADCraft's icons, drafting font, hatch patterns and linetypes are drawn or defined in code.
The UI also bundles the egui font inputs described below. The per-file list is in ATTRIBUTION.md.
Builds made with the optional craft-fonts input (CRAFT_FONTS_DIR) embed the fonts listed in its
ATTRIBUTION.md, each under the SIL Open Font License 1.1.
CADCraft is an independent project and is not affiliated with, sponsored by or endorsed by
Autodesk, Inc. AutoCAD and DWG are trademarks of Autodesk, Inc.
The PDF writer in the pinned craft-pdf 0.1.0 / Krilla 0.8.2 dependency embeds four Compact ICC
Profiles by Clinton Ingram, under CC0-1.0. They are unmodified dependency assets; no profile file
is copied into this repository. Per-file sources and SHA-256 hashes are in ATTRIBUTION.md.
Source: https://github.com/LaurenzV/krilla/tree/3ffdf0588cf98050aad6edba51ca70162e1fb5b5/crates/krilla/icc
(pinned through https://github.com/storytold/craft-libs/tree/a0916808f7d78bed8c7b90cdf937176015454a7f/vendor/krilla/icc).
CC0 1.0 Universal: https://creativecommons.org/publicdomain/zero/1.0/
Pinned licence text: https://github.com/storytold/craft-libs/blob/a0916808f7d78bed8c7b90cdf937176015454a7f/vendor/krilla/icc/LICENSE
The UI's egui-icons.ttf subset and emoji-icon-font.ttf fallback are provided by epaint_default_fonts
and registered through egui's default font definitions. They derive from emoji-icon-font by
John Slegers, under the MIT License. The subset and full face share the following upstream notice.
Source: https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts
Original: https://github.com/jslegers/emoji-icon-font
The following text is reproduced verbatim from emoji-icon-font-mit-license.txt:
MIT License
Copyright (c) 2014 John Slegers
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+1 -2
View File
@@ -123,8 +123,7 @@ numbers):
## Quick start
Desktop and web UI builds require Rust 1.95 or later. The core workspace's declared minimum
remains Rust 1.90.
CADCraft requires Rust 1.95 or later.
```sh
git clone https://github.com/storytold/cadcraft
+39 -2
View File
@@ -226,6 +226,12 @@ fn info(path: &str) -> Result<(), String> {
Ok(())
}
/// Write `bytes` to `out` through the engine's atomic writer (an exclusively created sibling file
/// renamed over `out`), so a failed or interrupted write leaves the previous file untouched.
fn write_atomic(out: &str, bytes: &[u8]) -> std::io::Result<()> {
cadcraft_engine::atomic_write::write_atomic(std::path::Path::new(out), bytes)
}
fn export(s: &Session, out: &str) -> Result<(), String> {
export_framed(s, out, None)
}
@@ -233,7 +239,7 @@ fn export(s: &Session, out: &str) -> Result<(), String> {
fn export_framed(s: &Session, out: &str, window: Option<Bounds2>) -> Result<(), String> {
let d = s.doc().map_err(|e| e.to_string())?;
let bytes = cadcraft_io::write_framed(d, out, window).map_err(|e| e.to_string())?;
std::fs::write(out, &bytes).map_err(|e| format!("{out}: {e}"))?;
write_atomic(out, &bytes).map_err(|e| format!("{out}: {e}"))?;
eprintln!("wrote {out} ({} bytes)", bytes.len());
if cadcraft_engine::cmd::file::drops_unknown(out) {
let lost = cadcraft_engine::cmd::file::not_saved(d);
@@ -252,7 +258,7 @@ fn sample(args: &[String]) -> Result<(), String> {
other => return Err(format!("unknown sample `{other}` (bracket, floorplan)")),
};
let bytes = cadcraft_io::write(&d, out).map_err(|e| e.to_string())?;
std::fs::write(out, &bytes).map_err(|e| format!("{out}: {e}"))?;
write_atomic(out, &bytes).map_err(|e| format!("{out}: {e}"))?;
eprintln!("wrote {out} ({} bytes)", bytes.len());
Ok(())
}
@@ -862,6 +868,37 @@ fn cli() -> ExitCode {
mod tests {
use super::*;
fn scratch_dir(tag: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("cadcraft-cli-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("create scratch dir");
dir
}
#[test]
fn write_atomic_replaces_file_and_leaves_no_staging_files() {
let dir = scratch_dir("replace");
let out = dir.join("a.dxf");
let out = out.to_str().expect("utf-8 path");
write_atomic(out, b"old").expect("create");
write_atomic(out, b"new").expect("overwrite");
assert_eq!(std::fs::read(out).expect("read"), b"new");
assert_eq!(std::fs::read_dir(&dir).expect("list").count(), 1);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn write_atomic_failure_keeps_destination_and_cleans_up() {
let dir = scratch_dir("fail");
let out = dir.join("target");
std::fs::create_dir(&out).expect("dir as destination");
std::fs::write(out.join("keep"), b"x").expect("seed");
assert!(write_atomic(out.to_str().expect("utf-8 path"), b"new").is_err());
assert_eq!(std::fs::read(out.join("keep")).expect("kept"), b"x");
assert_eq!(std::fs::read_dir(&dir).expect("list").count(), 1);
let _ = std::fs::remove_dir_all(&dir);
}
fn b(x0: f64, y0: f64, x1: f64, y1: f64) -> Bounds2 {
Bounds2::new(Vec2::new(x0, y0), Vec2::new(x1, y1))
}
+2 -1
View File
@@ -4,6 +4,7 @@ description = "CADCraft in the browser (eframe web runner, WebGPU with WebGL2 fa
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version = "1.95"
publish = false
[lints]
@@ -26,7 +27,7 @@ serde_json = { workspace = true }
web-sys = { version = "0.3", features = ["HtmlInputElement", "FileList", "File"] }
[target.'cfg(target_arch = "wasm32")'.dependencies]
eframe = { workspace = true, features = ["persistence", "default_fonts", "wgpu", "accesskit"] }
eframe = { workspace = true, features = ["persistence", "wgpu", "accesskit"] }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Location", "Navigator", "Blob", "BlobPropertyBag", "Url", "MessageEvent", "UrlSearchParams", "MediaQueryList"] }
+2 -1
View File
@@ -95,7 +95,8 @@ impl Bridge {
}
}
/// After each frame: tell the parent when unsaved changes appear or go away.
/// After each logic callback (including a hidden tab's): tell the parent when unsaved changes
/// appear or go away. `DirtyReport` suppresses duplicates, so this is idempotent.
pub fn report_dirty(&self, app: &CadApp, reported: &DirtyReport) {
let dirty = app.session.docs.iter().any(|d| d.is_dirty());
if reported.0.get() != Some(dirty) && self.send(&[("type", "cadcraft:dirty".into()), ("dirty", dirty.into())]) {
+13 -5
View File
@@ -41,18 +41,22 @@ mod web {
/// document title when it changed (so the tab and an embedding page name the open
/// drawing), and reports unsaved changes to that page.
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
self.1.take_dropped(ctx);
// Background reads are consume-based and run on hidden ticks too. Dropped files are
// input and are read from `ui` only.
self.1.drain(&mut self.0);
self.0.system_animations = self.3.as_ref().map(|reduced| !reduced.matches());
self.0.logic(ctx);
let drawing = self.0.session.state().ok();
crate::push_title(&mut self.4, drawing.map(|d| d.title.as_str()), drawing.is_some_and(|d| d.is_dirty()), set_page_title);
// `DirtyReport` suppresses duplicates, so reporting after every logic callback
// (including a hidden tab's) is idempotent and keeps the parent's guard current.
if let Some((bridge, reported)) = &self.2 {
bridge.report_dirty(&self.0, reported);
}
}
/// Forward raw input to the web `CadApp` and capture Tab at the command line.
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
self.0.raw_input_hook(raw);
self.0.raw_input_hook(ctx, raw);
cadcraft_ui_egui::cmdline::capture_tab(ctx, raw);
}
/// Preferences (interface theme and language) survive reloads; egui state is not kept.
@@ -63,11 +67,15 @@ mod web {
fn persist_egui_memory(&self) -> bool {
false
}
/// On the first pass consume files dropped on the page, then delegate drawing to `CadApp::ui`.
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.0.ui(ui);
if let Some((bridge, reported)) = &self.2 {
bridge.report_dirty(&self.0, reported);
let first_pass = ui.ctx().current_pass_index() == 0;
if first_pass {
// `take_dropped` consumes the raw list; a second pass or hidden tick cannot
// re-read the same file.
self.1.take_dropped(ui.ctx());
}
self.0.ui(ui);
}
}
+2 -1
View File
@@ -68,8 +68,9 @@ impl Inbox {
}
/// Read the files dropped on the page this frame (eframe hands them over unread on the web).
/// The list is taken, not cloned: a later pass or hidden tick must not read the same drop.
pub fn take_dropped(&self, ctx: &egui::Context) {
for f in ctx.input(|i| i.raw.dropped_files.clone()) {
for f in ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files)) {
if let Some(file) = f.web_file() {
self.read(file.clone());
}
+3 -1
View File
@@ -4,6 +4,7 @@ description = "CADCraft desktop app"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version = "1.95"
publish = false
default-run = "cadcraft"
@@ -16,7 +17,7 @@ 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"] }
eframe = { workspace = true, features = ["clipboard", "wgpu", "accesskit", "wayland", "x11", "links", "persistence"] }
egui = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
@@ -33,6 +34,7 @@ winit = { version = "0.30", default-features = false, features = ["x11"] }
zbus = { version = "=5.19.0", default-features = false, features = ["async-io", "blocking-api"] }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
craft-update = { workspace = true }
rfd = { workspace = true }
sys-locale = "0.3"
+37 -13
View File
@@ -65,37 +65,47 @@ fn follow_desktop(raw: &mut egui::RawInput, desktop: Option<egui::Theme>) {
}
impl eframe::App for App {
/// eframe logic callback: syncs the OS animation setting and runs `CadApp::logic`; when the pass index is 0 (the first pass or a hidden tick) it also installs/polls the macOS native menu, polls the spacemouse and persists the workspace; a quit request closes the viewport.
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
#[cfg(target_os = "macos")]
{
if self.native_menu.is_none() && std::env::var_os("CADCRAFT_NO_NATIVE_MENU").is_none() {
self.native_menu = Some(native_menu::NativeMenu::install(&mut self.cad));
}
if let Some(m) = &mut self.native_menu {
m.poll(&mut self.cad, ctx);
}
}
self.cad.system_animations = self.system_animations.current();
// Consume control/open/screenshot queues and time-based autosave, including hidden ticks.
self.cad.logic(ctx);
self.spacemouse.poll(&mut self.cad, ctx);
self.persist_workspace(false);
// Host frame work runs here, once per frame, and so also on a hidden logic-only tick (which
// never reaches `App::ui`): native menus stay current, the spacemouse still navigates and a
// workspace changed while hidden is persisted. `current_pass_index()` is zero on the first
// pass and on every hidden tick, and non-zero on later passes of a multi-pass frame.
if ctx.current_pass_index() == 0 {
#[cfg(target_os = "macos")]
{
if self.native_menu.is_none() && std::env::var_os("CADCRAFT_NO_NATIVE_MENU").is_none() {
self.native_menu = Some(native_menu::NativeMenu::install(&mut self.cad, ctx));
}
if let Some(m) = &mut self.native_menu {
m.poll(&mut self.cad, ctx);
}
}
self.spacemouse.poll(&mut self.cad, ctx);
// After `cad.logic`, so a control command's workspace change is saved in the same frame.
self.persist_workspace(false);
}
if std::mem::take(&mut self.cad.quit_requested) {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
}
/// Apply the desktop-theme fallback, forward raw input to `CadApp`, and capture Tab at the command line.
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
follow_desktop(raw, self.desktop_theme.current());
self.cad.raw_input_hook(raw);
self.cad.raw_input_hook(ctx, raw);
cadcraft_ui_egui::cmdline::capture_tab(ctx, raw);
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.cad.ui(ui);
self.persist_workspace(false);
}
/// A normal exit: the workspace is saved, and this run's autosave files are not needed (a
/// crash leaves them for recovery).
fn on_exit(&mut self) {
self.persist_workspace(true);
self.cad.cancel_screenshots();
self.cad.shutdown_autosave();
}
/// Preferences (the interface theme) survive restarts; window and egui state are not kept.
@@ -232,9 +242,19 @@ fn control_flag(current: Option<u16>, value: Option<String>) -> Option<u16> {
current
}
/// Parse CLI options (`--control`, `--sample`, files, `--version`), install logging and I/O hooks, build the services and app, and run eframe.
fn main() -> eframe::Result {
// First, so every start-up record is captured; see `logging`.
let logger = logging::install();
// The update helper relaunches this binary after a self-update; it runs and exits here.
#[cfg(not(target_arch = "wasm32"))]
if let Some(done) = craft_update::self_update::finish(&craft_update::suite::CADCRAFT, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
if let Err(e) = done {
eprintln!("CADCraft update helper failed: {e}");
std::process::exit(1);
}
return Ok(());
}
// lossy, so a value that is not Unicode is reported like any other bad port instead of ignored
let mut control_port =
control_port_from("CADCRAFT_CONTROL_PORT", std::env::var_os("CADCRAFT_CONTROL_PORT").map(|p| p.to_string_lossy().into_owned()));
@@ -280,6 +300,9 @@ fn main() -> eframe::Result {
.with_title_shown(false)
.with_app_id(APP_ID),
persist_window: false,
// CADCraft installs its own capped font chain (`theme::install_fonts`); never run
// system-font discovery as a hidden fallback.
system_font_fallback: false,
// A stencil buffer clips the contents of clipped layout viewports on the GPU.
stencil_buffer: STENCIL_BITS,
..Default::default()
@@ -338,6 +361,7 @@ fn main() -> eframe::Result {
// Autosave files (SAVEFILEPATH) default to `<settings dir>/autosave`.
if let Some(dir) = config_dir() {
app.enable_autosave(&dir.join("autosave"));
app.enable_update_check();
// Startup LISP (acad.lsp, acaddoc.lsp) is read from `<settings>/support`; the
// folder is made so it is there to put them in.
let support = dir.join("support");
+50 -4
View File
@@ -3,6 +3,7 @@
use std::collections::HashMap;
use std::str::FromStr;
use std::sync::mpsc;
use cadcraft_ui_egui::menus::{self, Entry};
use cadcraft_ui_egui::{CadApp, i18n};
@@ -15,10 +16,14 @@ pub struct NativeMenu {
submenus: Vec<(String, Submenu)>,
predefined: Vec<(&'static str, PredefinedMenuItem)>,
language: &'static str,
/// Monotonic time of the last enablement refresh. `NEG_INFINITY` makes the first poll refresh
/// immediately: `now_ms` starts near zero, so the old `0.0` sentinel suppressed the first
/// ~300 ms of menu enablement.
last_refresh: f64,
/// The recent files File ▸ Open Recent lists now (it is rebuilt when they change).
recent: Vec<String>,
counter: usize,
events: mpsc::Receiver<MenuEvent>,
}
/// Accelerator for "Cmd+Shift+S" (modifier-less shortcuts stay in the app so typing works).
@@ -30,7 +35,16 @@ fn accel(sc: &str) -> Option<Accelerator> {
}
impl NativeMenu {
pub fn install(app: &mut CadApp) -> Self {
/// Build and register the macOS menu (app menu, Settings and the translated command tree); returns the state holding item ids and submenus.
pub fn install(app: &mut CadApp, ctx: &egui::Context) -> Self {
// A menu choice (or its shortcut, such as Cmd+W) is no window event, so winit would not
// wake the app for it: the handler does, and the choice runs in the next frame.
let (sender, events) = mpsc::channel();
let wake = ctx.clone();
MenuEvent::set_event_handler(Some(move |event| {
let _ = sender.send(event);
wake.request_repaint();
}));
let menu = Menu::new();
let mut items = HashMap::new();
let language = app.language();
@@ -66,7 +80,17 @@ impl NativeMenu {
submenus.push((title, sub));
}
menu.init_for_nsapp();
Self { _menu: menu, items, submenus, predefined, language, last_refresh: 0.0, recent: app.ui.recent_files.clone(), counter }
Self {
_menu: menu,
items,
submenus,
predefined,
language,
last_refresh: f64::NEG_INFINITY,
recent: app.ui.recent_files.clone(),
counter,
events,
}
}
/// Rebuild File ▸ Open Recent after a drawing was opened or the list was cleared: the native
@@ -87,8 +111,9 @@ impl NativeMenu {
build(&sub, &menus::recent_entries(app), &mut self.items, &mut self.submenus, &mut self.counter, self.language);
}
/// Drain queued menu events into commands (Discord opens externally), rebuild Open Recent, retranslate on a language change and refresh enablement when 300 ms have elapsed.
pub fn poll(&mut self, app: &mut CadApp, ctx: &egui::Context) {
while let Ok(ev) = MenuEvent::receiver().try_recv() {
while let Ok(ev) = self.events.try_recv() {
if let Some((cmd, _, _)) = self.items.get(ev.id.as_ref()) {
if cmd == "ui.discord" {
ctx.open_url(egui::OpenUrl::new_tab("https://discord.gg/artcraft"));
@@ -113,7 +138,7 @@ impl NativeMenu {
}
}
let now = cadcraft_ui_egui::now_ms();
if now - self.last_refresh < 300.0 {
if !refresh_due(now, self.last_refresh) {
return;
}
self.last_refresh = now;
@@ -124,6 +149,13 @@ impl NativeMenu {
}
}
/// The periodic enablement refresh is due every 300 ms. Split out so the first-poll sentinel is
/// testable without a macOS menu: `now_ms` starts near zero, so a `0.0` initial value would
/// suppress the first refresh for 300 ms.
fn refresh_due(now: f64, last_refresh: f64) -> bool {
now - last_refresh >= 300.0
}
fn build(
parent: &Submenu,
entries: &[Entry],
@@ -150,3 +182,17 @@ fn build(
}
}
}
#[cfg(test)]
mod tests {
use super::refresh_due;
/// `refresh_due` treats `NEG_INFINITY` as due at any finite time and `0.0` as due once 300 ms have elapsed; `install` initializes a fresh menu with `last_refresh = NEG_INFINITY`.
#[test]
fn the_first_poll_refreshes_immediately() {
assert!(refresh_due(0.5, f64::NEG_INFINITY), "a fresh menu refreshes on its first poll");
assert!(!refresh_due(0.5, 0.0), "the old 0.0 sentinel delayed the first refresh");
assert!(!refresh_due(299.9, 0.0));
assert!(refresh_due(300.0, 0.0));
}
}
+1 -1
View File
@@ -235,7 +235,7 @@ mod budget_tests {
aux_init: Vec::new(),
aux: 0,
};
let comp = Comp { model, built: vec![endpoint], weights: vec![1.0; 2], tol: 1e-9 };
let comp = Comp { model, built: vec![endpoint], weights: vec![1.0; 2], held: Vec::new(), tol: 1e-9 };
assert!(analyse(&comp).dof.is_none());
}
}
+144 -4
View File
@@ -81,6 +81,12 @@ pub struct SolveOptions {
pub keep: Vec<Handle>,
/// Entities that should take up the change.
pub prefer_move: Vec<Handle>,
/// Points held where they are for this solve, as a Fix would hold them (the grip an edit
/// just put down): see [`edited_points`].
pub hold: Vec<GeomRef>,
/// Where a Fix on a derived point (by constraint id) holds it, instead of where the point is
/// now: see [`moved_anchors`].
pub anchors: Vec<(u32, Vec2)>,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
@@ -205,12 +211,19 @@ pub(crate) struct Comp {
pub(crate) model: Model,
pub(crate) built: Vec<Built>,
weights: Vec<f64>,
/// Parameters of the points held by [`SolveOptions::hold`].
held: Vec<usize>,
tol: f64,
}
impl Comp {
fn locks(&self, skip: Option<usize>) -> Vec<bool> {
let mut locked = vec![false; self.model.x0.len()];
for &j in &self.held {
if let Some(l) = locked.get_mut(j) {
*l = true;
}
}
for (i, b) in self.built.iter().enumerate() {
if Some(i) == skip || b.form != Form::Lock {
continue;
@@ -245,7 +258,30 @@ impl Comp {
return Err(SolveError::TooLarge);
}
let f = |x: &[f64], out: &mut Vec<f64>| self.eval(x, out, skip);
Ok(lm::solve(&f, &self.model.x0, &free, &self.weights, self.tol, MAX_ITER))
let mut o = lm::solve(&f, &self.model.x0, &free, &self.weights, self.tol, MAX_ITER);
// A solution the ellipses would be stored differently from (axes swapped, minor axis
// mirrored) holds only if the constraints still hold on the stored form: a constraint on
// an axis end by index does not.
if o.converged && !self.model.storable(&o.x) {
let mut out = Vec::new();
match self.model.stored_form(&o.x) {
Some(mut xc) => {
// Contact parameters are positions along the curves: they follow the stored form.
for b in self.built.iter().filter(|b| b.form == Form::Contact) {
for (k, r) in b.refs.iter().enumerate() {
if let (Some(&t), Some(slot)) = (o.x.get(b.aux + k), xc.get_mut(b.aux + k)) {
*slot = self.model.stored_t(&o.x, r.handle, t);
}
}
}
self.eval(&xc, &mut out, skip);
o.converged = lm::max_abs(&out) <= self.tol;
o.x = xc;
}
None => o.converged = false,
}
}
Ok(o)
}
/// Constraint ids in conflict (each one whose removal makes the rest solvable).
fn conflicts(&self, x_failed: &[f64]) -> Vec<u32> {
@@ -346,7 +382,14 @@ pub(crate) fn build_budgeted(
for c in group {
let target = if c.kind.is_dimensional() { constraint_value(&mut e, c)? } else { 0.0 };
match interpret(c, &mut model, target) {
Ok(b) => built.push(b),
Ok(mut b) => {
if let Form::Anchor(_) = b.form
&& let Some(&(_, a)) = opts.anchors.iter().find(|(id, _)| *id == c.id)
{
b.form = Form::Anchor(a);
}
built.push(b);
}
Err(msg) => skipped.push((c.id, msg)),
}
}
@@ -393,8 +436,9 @@ pub(crate) fn build_budgeted(
for w in weights.iter_mut().skip(geo) {
*w = 1e-3;
}
let held = opts.hold.iter().filter_map(|r| model.lock_indices(r)).flatten().collect();
let tol = scale_tol(&model.x0);
comps.push(Comp { model, built, weights, tol });
comps.push(Comp { model, built, weights, held, tol });
}
Ok((comps, skipped))
}
@@ -445,6 +489,9 @@ pub fn residuals(d: &Drawing) -> Vec<(u32, Option<f64>)> {
/// 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.
/// An object held by a Fix on a derived point (a line's midpoint, an arc's end) has nothing to
/// lock: when the edit only moved it (MOVE), it is put back whole, so it is not reshaped to bring
/// the point back.
pub fn restore_fixed(d: &mut Drawing, before: &Drawing) -> bool {
let refs: Vec<GeomRef> = d
.constraints
@@ -461,10 +508,21 @@ pub fn restore_fixed(d: &mut Drawing, before: &Drawing) -> bool {
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;
}
let (Some(i), Some(j)) = (now.lock_indices(r), old.lock_indices(r)) else {
if let (Some(a), Some(b)) = (now.slot(r.handle), old.slot(r.handle))
&& translated(&now, &old, r.handle)
&& let (Some(dst), Some(src)) = (x.get_mut(a.off..a.off + a.len), old.x0.get(b.off..b.off + b.len))
{
for (v, &w) in dst.iter_mut().zip(src) {
*v = w;
}
changed = true;
}
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
@@ -480,6 +538,88 @@ pub fn restore_fixed(d: &mut Drawing, before: &Drawing) -> bool {
changed
}
/// The parameters of `h`'s points (see [`Model::point_refs`]) in `m`, as (x, y) pairs.
fn point_params(m: &Model, h: Handle) -> Vec<(usize, usize)> {
m.point_refs(h)
.iter()
.filter_map(|r| m.lock_indices(r))
.flat_map(|ix| ix.as_chunks::<2>().0.iter().map(|&[x, y]| (x, y)).collect::<Vec<_>>())
.collect()
}
/// Is `h` in `now` its shape in `old` moved by one vector, and nothing else?
fn translated(now: &Model, old: &Model, h: Handle) -> bool {
let (Some(a), Some(b)) = (now.slot(h), old.slot(h)) else { return false };
let (Some(xa), Some(xb)) = (now.x0.get(a.off..a.off + a.len), old.x0.get(b.off..b.off + b.len)) else { return false };
let tol = scale_tol(xb) * 10.0;
let pts: Vec<(usize, usize)> =
point_params(old, h).into_iter().filter_map(|(i, j)| Some((i.checked_sub(b.off)?, j.checked_sub(b.off)?))).collect();
let delta = |(i, j): (usize, usize)| Some(Vec2::new(xa.get(i)? - xb.get(i)?, xa.get(j)? - xb.get(j)?));
let Some(v) = pts.first().and_then(|&p| delta(p)) else { return false };
if v.len() <= tol || !pts.iter().all(|&p| delta(p).is_some_and(|d| d.near(v, tol))) {
return false;
}
(0..xb.len())
.filter(|k| !pts.iter().any(|&(i, j)| *k == i || *k == j))
.all(|k| matches!((xa.get(k), xb.get(k)), (Some(p), Some(q)) if (p - q).abs() <= tol))
}
/// The points of `h` (its ends, centre or vertices) the edit from `before` to `d` moved, when it
/// moved some of them but not all: the grip that was dragged, to pass as [`SolveOptions::hold`] so
/// it stays where it was put and the rest of the object takes up the constraints. Empty when the
/// object kept its points or moved them all.
pub fn edited_points(d: &Drawing, before: &Drawing, h: Handle) -> Vec<GeomRef> {
let (now, old) = (Model::build(d, &[h]), Model::build(before, &[h]));
if !matches!((now.slot(h), old.slot(h)), (Some(a), Some(b)) if a.shape == b.shape) {
return Vec::new();
}
let tol = scale_tol(&old.x0) * 10.0;
let refs = now.point_refs(h);
let moved: Vec<GeomRef> = refs
.iter()
.filter(|r| matches!((now.lock_indices(r), old.lock_indices(r)), (Some(i), Some(j)) if i.iter().zip(&j).any(|(&i, &j)| matches!((now.x0.get(i), old.x0.get(j)), (Some(p), Some(q)) if (p - q).abs() > tol))))
.copied()
.collect();
if moved.len() == refs.len() { Vec::new() } else { moved }
}
/// Where `before` had each derived point held by a Fix (a line's midpoint, an arc's end), for the
/// Fixes whose point has moved since, to pass as [`SolveOptions::anchors`].
///
/// A Fix on a derived point anchors it where it is when the system is built, and
/// [`restore_fixed`] has no parameters to put back: without this, an edit that moves the point
/// would leave the Fix satisfied at the new place.
pub fn moved_anchors(d: &Drawing, before: &Drawing) -> Vec<(u32, Vec2)> {
let fixes: Vec<(u32, GeomRef)> = d
.constraints
.iter()
.filter(|c| c.kind == ConstraintKind::Fix && before.constraints.contains(c))
.filter_map(|c| match c.refs.as_slice() {
[r] => Some((c.id, *r)),
_ => None,
})
.collect();
if fixes.is_empty() {
return Vec::new();
}
let handles: Vec<Handle> = fixes.iter().map(|(_, r)| r.handle).collect();
let (now, old) = (Model::build(d, &handles), Model::build(before, &handles));
let tol = scale_tol(&old.x0);
let mut out = Vec::new();
for (id, r) in fixes {
let same_shape = matches!((now.slot(r.handle), old.slot(r.handle)), (Some(a), Some(b)) if a.shape == b.shape);
if !same_shape || now.lock_indices(&r).is_some() {
continue;
}
if let (Some(p), Some(a)) = (now.point(&now.x0, &r), old.point(&old.x0, &r))
&& !p.near(a, tol)
{
out.push((id, a));
}
}
out
}
/// 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();
+68 -2
View File
@@ -32,6 +32,8 @@ pub const MAX_RIGID_POINTS: usize = 256;
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;
/// How far past 1 a solved ellipse ratio may land by rounding and still be stored as 1.
const RATIO_TOL: f64 = 1e-9;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Shape {
@@ -477,6 +479,18 @@ impl Model {
}
}
/// The points of an object a Fix locks directly (ends, centre, vertices, insertion point).
pub fn point_refs(&self, h: Handle) -> Vec<GeomRef> {
let Some(s) = self.slot(h) else { return Vec::new() };
let at = |sub: Sub| GeomRef { handle: h, sub };
match s.shape {
Shape::Line => vec![at(Sub::Start), at(Sub::End)],
Shape::Point => vec![at(Sub::Whole)],
Shape::Circle | Shape::Arc | Shape::Ellipse { .. } | Shape::Insert { .. } => vec![at(Sub::Center)],
Shape::Poly { n, .. } | Shape::Spline { n, .. } => (0..n).filter_map(|i| u32::try_from(i).ok()).map(|i| at(Sub::Vertex(i))).collect(),
}
}
/// Parameters a Fix on this reference locks directly. `None` when the reference is a derived
/// point (fixed through a residual instead).
pub fn lock_indices(&self, r: &GeomRef) -> Option<Vec<usize>> {
@@ -504,6 +518,57 @@ impl Model {
Some(v)
}
/// Can `write_back` store `x` as it is? An ellipse keeps 0 < ratio <= 1, its major axis the
/// longer one: a solution past that is stored with its axes swapped or its minor axis
/// mirrored (see [`Model::stored_form`]), which renumbers the axis ends.
pub fn storable(&self, x: &[f64]) -> bool {
self.slots.iter().all(|s| match s.shape {
Shape::Ellipse { .. } => x.get(s.off + 4).is_some_and(|r| *r > 0.0 && *r <= 1.0 + RATIO_TOL),
_ => true,
})
}
/// `x` with every ellipse re-expressed as [`Model::write_back`] stores it (the same curve with
/// the major axis the longer one, a full ellipse's negative ratio made positive), so the
/// constraints can be checked against what would be stored. `None` when an elliptical arc has
/// a negative ratio or an ellipse a zero one.
pub fn stored_form(&self, x: &[f64]) -> Option<Vec<f64>> {
let mut xc = x.to_vec();
for s in &self.slots {
let Shape::Ellipse { arc } = s.shape else { continue };
let o = s.off;
let mut ratio = *x.get(o + 4)?;
if ratio < 0.0 && !arc {
ratio = -ratio;
}
if ratio <= 0.0 {
return None;
}
if ratio > 1.0 + RATIO_TOL {
let major = p2(x, o + 2)?.perp() * ratio;
*xc.get_mut(o + 2)? = major.x;
*xc.get_mut(o + 3)? = major.y;
ratio = 1.0 / ratio;
if arc {
*xc.get_mut(o + 5)? -= std::f64::consts::FRAC_PI_2;
*xc.get_mut(o + 6)? -= std::f64::consts::FRAC_PI_2;
}
}
*xc.get_mut(o + 4)? = ratio;
}
Some(xc)
}
/// The parameter `stored_form` gives the point at parameter `t` on ellipse `h` (unchanged for
/// any other object): `-t` where a negative ratio was made positive, a quarter turn back where
/// the axes were swapped.
pub fn stored_t(&self, x: &[f64], h: Handle, t: f64) -> f64 {
let Some(s) = self.slot(h) else { return t };
let (Shape::Ellipse { .. }, Some(&ratio)) = (s.shape, x.get(s.off + 4)) else { return t };
let (t, ratio) = if ratio < 0.0 { (-t, -ratio) } else { (t, ratio) };
if ratio > 1.0 + RATIO_TOL { t - std::f64::consts::FRAC_PI_2 } else { t }
}
/// Write solved parameters back. Returns the handles that moved.
pub fn write_back(&self, d: &mut Drawing, x: &[f64]) -> Vec<Handle> {
let mut moved = Vec::new();
@@ -572,8 +637,9 @@ impl Model {
[a, b] if arc => (*a, *b),
_ => (el.start, el.end),
};
// Keep the stored form: the major axis is the longer one.
if ratio > 1.0 {
// Keep the stored form: the major axis is the longer one. `storable` lets a
// solved ratio exceed 1 only by rounding: that is a circle, not a swap.
if ratio > 1.0 + RATIO_TOL {
major = major.perp() * ratio;
ratio = 1.0 / ratio;
if arc {
+2 -2
View File
@@ -305,7 +305,7 @@ fn keep_option_moves_the_other_entity() {
let mut d = Drawing::default();
let a = line(&mut d, (0.0, 0.0), (10.0, 0.0));
let b = line(&mut d, (0.0, 2.0), (10.0, 4.0));
add(&mut d, con(ConstraintKind::Parallel, &[w(a), w(b)]), &SolveOptions { keep: vec![a], prefer_move: vec![b] }).unwrap();
add(&mut d, con(ConstraintKind::Parallel, &[w(a), w(b)]), &SolveOptions { keep: vec![a], prefer_move: vec![b], ..Default::default() }).unwrap();
let (a0, a1) = ln(&d, a);
assert!(a0.near(Vec2::new(0.0, 0.0), 1e-3) && a1.near(Vec2::new(10.0, 0.0), 1e-3), "{a0:?} {a1:?}");
}
@@ -412,7 +412,7 @@ fn glyphs_and_residuals() {
})
.unwrap();
assert!(!all_satisfied(&d));
let r = solve(&mut d, &SolveOptions { keep: vec![], prefer_move: vec![] }).unwrap();
let r = solve(&mut d, &SolveOptions::default()).unwrap();
assert_eq!(r.moved, vec![a]);
assert!(all_satisfied(&d));
}
+80
View File
@@ -382,3 +382,83 @@ fn vertex_constraints_on_large_fit_spline_do_not_charge_reconstruction() {
assert_eq!(report.status, DofStatus::Under);
assert_eq!(report.dof, Some(1000));
}
/// Adds a constraint that the stored ellipse can only hold with its axes renumbered: refused,
/// with the drawing unchanged.
fn refused_on_minor_axis_end(p: (f64, f64)) {
let mut d = Drawing::default();
let e = ellipse(&mut d, (0.0, 0.0), (4.0, 0.0), 0.5, 0.0, std::f64::consts::TAU);
ok(&mut d, con(K::Fix, &[s(e, Sub::Center)]));
let p = point(&mut d, p);
ok(&mut d, con(K::Fix, &[w(p)]));
let before = d.clone();
let r = add(&mut d, con(K::Coincident, &[s(e, Sub::Vertex(1)), w(p)]), &SolveOptions::default());
assert!(matches!(r, Err(SolveError::Conflict { .. })), "{r:?}, ellipse {:?}", ell(&d, e));
assert_eq!(d, before);
}
/// Pulling a minor axis end past the major axis length would need a ratio above 1, which the
/// stored ellipse holds only by swapping its axes (and so renumbering their ends).
#[test]
fn minor_axis_end_pulled_past_the_major_axis_is_refused() {
refused_on_minor_axis_end((0.0, 6.0));
}
/// Pulling a minor axis end through the centre would need a negative ratio, which the stored
/// ellipse cannot hold: storing its absolute value would mirror the minor axis end.
#[test]
fn minor_axis_end_pulled_through_the_centre_is_refused() {
refused_on_minor_axis_end((0.0, -1.0));
}
/// A circle-like ellipse whose solution makes the minor axis the longer one: stored with its axes
/// swapped, which constraints that name no axis end by index do not notice.
fn round_ellipse(d: &mut Drawing, start: f64, end: f64) -> Handle {
let e = ellipse(d, (0.0, 0.0), (4.0, 0.0), 1.0, start, end);
ok(d, con(K::Fix, &[s(e, Sub::Center)]));
e
}
#[test]
fn tangent_that_swaps_a_round_ellipse_axes_is_kept() {
let mut d = Drawing::default();
let e = round_ellipse(&mut d, 0.0, std::f64::consts::TAU);
let l = line(&mut d, (-10.0, 4.5), (10.0, 4.5));
ok(&mut d, con(K::Fix, &[w(l)]));
ok(&mut d, con(K::Tangent, &[w(l), w(e)]));
assert!(ell(&d, e).ratio <= 1.0);
assert!(all_satisfied(&d));
}
#[test]
fn point_on_curve_that_swaps_a_round_ellipse_axes_is_kept() {
let mut d = Drawing::default();
let e = round_ellipse(&mut d, 0.0, std::f64::consts::TAU);
let p = point(&mut d, (0.0, 4.5));
ok(&mut d, con(K::Fix, &[w(p)]));
ok(&mut d, con(K::Coincident, &[w(p), w(e)]));
assert!(ell(&d, e).ratio <= 1.0);
assert!(all_satisfied(&d));
}
#[test]
fn elliptical_arc_start_that_swaps_its_axes_is_kept() {
let mut d = Drawing::default();
let e = round_ellipse(&mut d, std::f64::consts::FRAC_PI_2, std::f64::consts::PI);
let p = point(&mut d, (0.0, 4.5));
ok(&mut d, con(K::Fix, &[w(p)]));
ok(&mut d, con(K::Coincident, &[s(e, Sub::Start), w(p)]));
assert!(ell(&d, e).ratio <= 1.0);
assert!(all_satisfied(&d));
}
#[test]
fn tangent_circle_that_swaps_a_round_ellipse_axes_is_kept() {
let mut d = Drawing::default();
let e = round_ellipse(&mut d, 0.0, std::f64::consts::TAU);
let c = circle(&mut d, (0.0, 6.0), 1.5);
ok(&mut d, con(K::Fix, &[w(c)]));
ok(&mut d, con(K::Tangent, &[w(c), w(e)]));
assert!(ell(&d, e).ratio <= 1.0);
assert!(all_satisfied(&d));
}
+42 -4
View File
@@ -29,16 +29,21 @@ fn tag(code: i32, value: impl Into<String>) -> RawTag {
RawTag { code, value: value.into() }
}
/// The element tag (`AcDbBlockRepETag` 1071) of object `h`, if it has one.
fn etag(d: &Drawing, h: Handle) -> Option<i64> {
let x = &d.foreign.entities.get(&h)?.xdata;
/// The element tag (`AcDbBlockRepETag` 1071) in `x`, if it has one.
fn etag_in(x: &[RawTag]) -> Option<i64> {
let at = x.iter().position(|t| t.code == 1001 && t.value.eq_ignore_ascii_case(ETAG))?;
x.iter().skip(at + 1).take_while(|t| t.code != 1001).find(|t| t.code == 1071).and_then(|t| t.value.trim().parse().ok())
}
/// The element tag of object `e`: its own xdata (where the importer keeps it), else any kept in the
/// foreign data.
fn etag(d: &Drawing, e: &Entity) -> Option<i64> {
etag_in(&e.common.xdata).or_else(|| etag_in(&d.foreign.entities.get(&e.handle)?.xdata))
}
/// Each object of `b` keyed by its element tag, or by its position when it has none.
fn by_tag(d: &Drawing, b: &Block) -> Vec<(i64, Handle)> {
b.entities.iter().enumerate().map(|(i, e)| (etag(d, e.handle).unwrap_or(i as i64 + 1_000_000), e.handle)).collect()
b.entities.iter().enumerate().map(|(i, e)| (etag(d, e).unwrap_or(i as i64 + 1_000_000), e.handle)).collect()
}
/// The file handle of block `name`'s BLOCK_RECORD, as the kept data refers to it.
@@ -542,6 +547,12 @@ fn apply(
let Some(nh) = copies.get(&e.handle).copied() else { continue };
let mut c: Entity = (**e).clone();
c.handle = nh;
// The copy's own xdata names the copy, not its original (as the kept data below does).
for t in &mut c.common.xdata {
if t.code == 1005 && Handle::parse_hex(&t.value) == Some(e.handle) {
t.value = nh.hex();
}
}
if let Some(v) = shown_copy.get(&e.handle) {
c.common.visible = *v;
}
@@ -964,3 +975,30 @@ fn drop_if_unused(d: &mut Drawing, name: &str) {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn tagged(h: u64, tag_value: Option<&str>) -> Entity {
let mut e = Entity::new(Handle(h), EntityKind::Line(crate::Line { a: Vec3::ZERO, b: Vec3::ZERO }));
if let Some(v) = tag_value {
e.common.xdata = vec![tag(1001, ETAG), tag(1071, v)];
}
e
}
#[test]
fn element_tags_are_read_from_the_entitys_own_xdata() {
let d = Drawing::new_imperial();
let mut def = Block::new("DEF");
def.entities.push(tagged(1, Some("10")));
def.entities.push(tagged(2, Some("20")));
let mut anon = Block::new("*U1");
// Reordered, and the first object left out.
anon.entities.push(tagged(5, Some("20")));
anon.entities.push(tagged(6, None));
assert_eq!(by_tag(&d, &def), vec![(10, Handle(1)), (20, Handle(2))]);
assert_eq!(by_tag(&d, &anon), vec![(20, Handle(5)), (1_000_001, Handle(6))]);
}
}
+14 -1
View File
@@ -18,7 +18,11 @@ impl Handle {
format!("{:X}", self.0)
}
pub fn parse_hex(s: &str) -> Option<Handle> {
u64::from_str_radix(s.trim(), 16).ok().map(Handle)
let s = s.trim();
if !s.bytes().all(|b| b.is_ascii_hexdigit()) {
return None;
}
u64::from_str_radix(s, 16).ok().map(Handle)
}
}
@@ -1477,6 +1481,15 @@ mod grip_tests {
use super::*;
use cadcraft_geom::PolyVertex;
#[test]
fn handle_parse_hex_accepts_only_hex_digits() {
assert_eq!(Handle::parse_hex("1a"), Some(Handle(0x1A)));
assert_eq!(Handle::parse_hex(" 1A "), Some(Handle(0x1A)));
for bad in ["+1A", "-1A", "", "+", "1 A"] {
assert_eq!(Handle::parse_hex(bad), None, "{bad}");
}
}
#[test]
fn polyline_grips_are_vertices_and_midpoints() {
// Grips are each vertex plus the midpoint of each segment; the segment vector is computed
+23 -11
View File
@@ -413,8 +413,7 @@ pub fn write_ascii_as(tags: &[Tag], utf8: bool) -> String {
for t in tags {
let _ = write!(out, "{:>3}\r\n", t.code);
match &t.value {
Value::Str(s) if utf8 => out.extend(s.chars().map(|c| if matches!(c, '\r' | '\n') { ' ' } else { c })),
Value::Str(s) => escape_unicode(s, &mut out),
Value::Str(s) => escape_unicode(s, utf8, &mut out),
Value::Real(r) => out.push_str(&format_real(*r)),
Value::Int(i) => out.push_str(&i.to_string()),
Value::Bool(b) => out.push_str(&i64::from(*b).to_string()),
@@ -446,11 +445,7 @@ pub fn write_binary(tags: &[Tag], utf8: bool) -> Vec<u8> {
v => v.as_str(),
};
let mut text = String::with_capacity(s.len());
if utf8 {
text.extend(s.chars().map(|c| if matches!(c, '\r' | '\n') { ' ' } else { c }));
} else {
escape_unicode(&s, &mut text);
}
escape_unicode(&s, utf8, &mut text);
out.extend(text.bytes().filter(|&b| b != 0));
out.push(0);
}
@@ -507,12 +502,16 @@ pub fn is_binary(bytes: &[u8]) -> bool {
/// Escape a string for a pre-R2007 DXF, whose text is in the `$DWGCODEPAGE` code page: ASCII
/// stays as it is and every other character becomes `\U+XXXX` (a surrogate pair of escapes
/// above U+FFFF), so the bytes are valid in any code page. Line breaks become spaces.
fn escape_unicode(s: &str, out: &mut String) {
for c in s.chars() {
/// above U+FFFF), so the bytes are valid in any code page; with `utf8` they stay as they are.
/// A backslash that would start a `\U+` or `\M+` sequence is written as `\U+005C` so the reader
/// gives back the literal text. Line breaks become spaces.
fn escape_unicode(s: &str, utf8: bool, out: &mut String) {
let chars: Vec<char> = s.chars().collect();
for (i, &c) in chars.iter().enumerate() {
match c {
'\r' | '\n' => out.push(' '),
c if c.is_ascii() => out.push(c),
'\\' if matches!(chars.get(i + 1), Some('U' | 'M')) && chars.get(i + 2) == Some(&'+') => out.push_str("\\U+005C"),
c if utf8 || c.is_ascii() => out.push(c),
c => {
let mut units = [0u16; 2];
for u in c.encode_utf16(&mut units) {
@@ -569,6 +568,19 @@ pub fn records(tags: &[Tag]) -> Vec<(String, Vec<Tag>)> {
mod tests {
use super::*;
#[test]
fn literal_escape_text_roundtrips() {
for utf8 in [false, true] {
for text in ["\\U+00E9", "\\M+183A4F", "a\\nb \\P", "\\\\U+00E9", "\\U+005C"] {
let tags = vec![Tag::s(8, text)];
let back = parse_ascii(&write_ascii_as(&tags, utf8)).unwrap();
assert_eq!(back, tags, "utf8={utf8} {text}");
let back = parse_binary(&write_binary(&tags, utf8)).unwrap();
assert_eq!(back[1..], tags[..], "binary utf8={utf8} {text}");
}
}
}
#[test]
fn ascii_roundtrip() {
let tags = vec![
+7 -2
View File
@@ -208,6 +208,12 @@ impl Autosaver {
if minutes == 0 || dir.trim().is_empty() || (!urgent && now - since < f64::from(minutes) * 60.0) {
continue;
}
// In the Block Editor: the drawing with the editor's unsaved changes in the block. When
// they can't be put there, nothing is written and nothing is marked as covered, so the
// next pass tries again instead of keeping the host without them.
let Ok(doc) = st.recovery_drawing() else {
continue;
};
let path = t
.file
.clone()
@@ -216,8 +222,7 @@ impl Autosaver {
t.written = Some(Arc::downgrade(&st.doc));
t.host = Some(Arc::downgrade(st.drawing()));
t.since = None;
// In the Block Editor: the drawing with the editor's unsaved changes in the block.
jobs.push(Job::Write { uid: st.uid, path, doc: st.recovery_drawing() });
jobs.push(Job::Write { uid: st.uid, path, doc });
}
jobs
}
+6 -5
View File
@@ -195,12 +195,13 @@ impl DocState {
self.block_edit.as_ref().is_some_and(|b| !Arc::ptr_eq(&b.saved, &self.doc))
}
/// The drawing to keep for recovery (autosave): with the Block Editor's unsaved changes in the
/// block, so a crash doesn't lose them.
pub fn recovery_drawing(&self) -> Arc<Drawing> {
/// block, so a crash doesn't lose them. An error when the editor's changes can't be put in the
/// block (it would contain itself): the host drawing alone would lose them.
pub fn recovery_drawing(&self) -> Result<Arc<Drawing>> {
match &self.block_edit {
Some(be) if self.block_unsaved() => be.compose(&self.doc, &be.name).map(Arc::new).unwrap_or_else(|_| be.host.doc.clone()),
Some(be) => be.host.doc.clone(),
None => self.doc.clone(),
Some(be) if self.block_unsaved() => be.compose(&self.doc, &be.name).map(Arc::new),
Some(be) => Ok(be.host.doc.clone()),
None => Ok(self.doc.clone()),
}
}
+2 -1
View File
@@ -7,7 +7,8 @@ use cadcraft_doc::{AnnoScale, Drawing, EntityKind, Handle, MAX_SCALES, is_annota
use serde_json::{Value, json};
use super::machines::{SelOutcome, SelectPhase, SelectRun};
use super::*;
use super::{CommandSpec, bad, f64_req, targets};
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
pub fn specs() -> Vec<CommandSpec> {
+25 -25
View File
@@ -9,7 +9,8 @@ use serde_json::{Value, json};
use super::helpers::v3;
use super::machines::{SelOutcome, SelectPhase, SelectRun};
use super::*;
use super::{CommandSpec, bad, point_param, point_req, points_param, str_param, targets};
use crate::{Accept, EngineError, Input, Interactive, LeaderOrder, MLeaderOptions, Prompt, Result, Session, Step};
pub fn specs() -> Vec<CommandSpec> {
@@ -119,7 +120,7 @@ fn style(s: &Session) -> String {
s.doc().map(|d| d.header.str("DIMSTYLE", "Standard")).unwrap_or_else(|_| "Standard".into())
}
fn dim(s: &Session, kind: DimKind, defpt: Vec2, p13: Vec2, p14: Vec2, p15: Vec2, p16: Vec2, text: &str) -> EntityKind {
fn dim(s: &Session, kind: DimKind, defpt: Vec2, p13: Vec2, p14: Vec2, p15: Vec2, text: &str) -> EntityKind {
EntityKind::Dimension(Dimension {
kind,
defpt: v3(defpt),
@@ -127,7 +128,7 @@ fn dim(s: &Session, kind: DimKind, defpt: Vec2, p13: Vec2, p14: Vec2, p15: Vec2,
p13: v3(p13),
p14: v3(p14),
p15: v3(p15),
p16: v3(p16),
p16: v3(Vec2::ZERO),
text: text.into(),
style: style(s),
measurement: 0.0,
@@ -204,7 +205,10 @@ fn angular_from_lines(s: &Session, h1: Handle, h2: Handle, at: Vec2) -> Option<E
// The picked leg end goes last in each pair, so the renderer measures between p14 and p15.
let (o1, t1) = if s1 == AssocSnap::Start { (a2, AssocSnap::End) } else { (a1, AssocSnap::Start) };
let (o2, t2) = if s2 == AssocSnap::Start { (b2, AssocSnap::End) } else { (b1, AssocSnap::Start) };
let k = dim(s, DimKind::Angular, o2, o1, p1, p2, at, "");
let mut k = dim(s, DimKind::Angular, o2, o1, p1, p2, "");
if let EntityKind::Dimension(d) = &mut k {
d.p16 = v3(at);
}
let mut assoc = Vec::new();
if assoc_enabled(s) {
assoc = vec![
@@ -224,7 +228,7 @@ fn run_linear(s: &mut Session, p: &Value) -> Result<Value> {
};
let at = point_req("dimlinear", p, "at")?;
let rot = p.get("rotation").and_then(Value::as_f64).map(f64::to_radians);
let k = dim(s, linear_kind(a, b, at, rot, false), at, a, b, Vec2::ZERO, Vec2::ZERO, str_param(p, "text").unwrap_or(""));
let k = dim(s, linear_kind(a, b, at, rot, false), at, a, b, Vec2::ZERO, str_param(p, "text").unwrap_or(""));
Ok(json!({ "handle": add_dim(s, k)?.hex() }))
}
@@ -232,7 +236,7 @@ fn run_aligned(s: &mut Session, p: &Value) -> Result<Value> {
let a = point_req("dimaligned", p, "p1")?;
let b = point_req("dimaligned", p, "p2")?;
let at = point_req("dimaligned", p, "at")?;
let k = dim(s, DimKind::Aligned, at, a, b, Vec2::ZERO, Vec2::ZERO, str_param(p, "text").unwrap_or(""));
let k = dim(s, DimKind::Aligned, at, a, b, Vec2::ZERO, str_param(p, "text").unwrap_or(""));
Ok(json!({ "handle": add_dim(s, k)?.hex() }))
}
@@ -260,9 +264,9 @@ fn radial(s: &mut Session, p: &Value, diameter: bool) -> Result<Value> {
return Err(bad(id, "`point` must differ from `center`"));
}
let k = if diameter {
dim(s, DimKind::Diameter, c + (c - pt), Vec2::ZERO, Vec2::ZERO, pt, Vec2::ZERO, "")
dim(s, DimKind::Diameter, c + (c - pt), Vec2::ZERO, Vec2::ZERO, pt, "")
} else {
dim(s, DimKind::Radius, c, Vec2::ZERO, Vec2::ZERO, pt, Vec2::ZERO, "")
dim(s, DimKind::Radius, c, Vec2::ZERO, Vec2::ZERO, pt, "")
};
let k = match at {
Some(at) => text_at(k, at),
@@ -307,7 +311,7 @@ fn run_angular(s: &mut Session, p: &Value) -> Result<Value> {
_ => return Err(bad("dimangular", "object is not an arc")),
};
let g = cadcraft_geom::Arc::new(a.center.xy(), a.radius, a.start, a.end);
let k = dim(s, DimKind::Angular3P, at, g.start_point(), g.end_point(), g.center, Vec2::ZERO, "");
let k = dim(s, DimKind::Angular3P, at, g.start_point(), g.end_point(), g.center, "");
return Ok(json!({ "handle": add_dim(s, k)?.hex() }));
}
let v = point_req("dimangular", p, "vertex")?;
@@ -317,7 +321,7 @@ fn run_angular(s: &mut Session, p: &Value) -> Result<Value> {
if v.near(a, 1e-12) || v.near(b, 1e-12) {
return Err(bad("dimangular", "`p1` and `p2` must differ from `vertex`"));
}
let k = dim(s, DimKind::Angular3P, at, a, b, v, Vec2::ZERO, "");
let k = dim(s, DimKind::Angular3P, at, a, b, v, "");
Ok(json!({ "handle": add_dim(s, k)?.hex() }))
}
@@ -329,7 +333,7 @@ fn run_arclen(s: &mut Session, p: &Value) -> Result<Value> {
_ => return Err(bad("dimarc", "object is not an arc")),
};
let ga = cadcraft_geom::Arc::new(a.center.xy(), a.radius, a.start, a.end);
let k = dim(s, DimKind::ArcLength, at, ga.start_point(), ga.end_point(), ga.center, Vec2::ZERO, "");
let k = dim(s, DimKind::ArcLength, at, ga.start_point(), ga.end_point(), ga.center, "");
Ok(json!({ "handle": add_dim(s, k)?.hex() }))
}
@@ -337,7 +341,7 @@ fn run_ordinate(s: &mut Session, p: &Value) -> Result<Value> {
let f = point_req("dimordinate", p, "feature")?;
let l = point_req("dimordinate", p, "leader")?;
let xtype = p.get("xtype").and_then(Value::as_bool).unwrap_or((l - f).y.abs() > (l - f).x.abs());
let k = dim(s, DimKind::Ordinate { x_type: xtype }, Vec2::ZERO, f, l, Vec2::ZERO, Vec2::ZERO, "");
let k = dim(s, DimKind::Ordinate { x_type: xtype }, Vec2::ZERO, f, l, Vec2::ZERO, "");
Ok(json!({ "handle": add_dim(s, k)?.hex() }))
}
@@ -1059,7 +1063,7 @@ impl Interactive for LinearM {
}
(_, Input::Point(at)) => {
let (Some(&a), Some(&b)) = (self.pts.first(), self.pts.get(1)) else { return Ok(Step::Continue) };
let k = self.text.apply(dim(s, linear_kind(a, b, at, self.rotation, self.aligned), at, a, b, Vec2::ZERO, Vec2::ZERO, ""));
let k = self.text.apply(dim(s, linear_kind(a, b, at, self.rotation, self.aligned), at, a, b, Vec2::ZERO, ""));
add_dim(s, k)?;
if let Some(EntityKind::Dimension(d)) = s.last_dim.and_then(|h| s.doc().ok()?.entity(h).map(|e| e.kind.clone())) {
let st = s.doc()?.dim_style(&d.style).cloned().unwrap_or_default();
@@ -1086,7 +1090,7 @@ impl Interactive for LinearM {
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
match self.pts.as_slice() {
[a] => vec![super::helpers::line(*a, c)],
[a, b] => vec![self.text.apply(dim(s, linear_kind(*a, *b, c, self.rotation, self.aligned), c, *a, *b, Vec2::ZERO, Vec2::ZERO, ""))],
[a, b] => vec![self.text.apply(dim(s, linear_kind(*a, *b, c, self.rotation, self.aligned), c, *a, *b, Vec2::ZERO, ""))],
_ => Vec::new(),
}
}
@@ -1105,9 +1109,9 @@ impl RadialM {
fn kind(&self, s: &Session, c: Vec2, r: f64, at: Vec2) -> EntityKind {
let pt = c + (at - c).normalized() * r;
let k = self.text.apply(if self.diameter {
dim(s, DimKind::Diameter, c + (c - pt), Vec2::ZERO, Vec2::ZERO, pt, Vec2::ZERO, "")
dim(s, DimKind::Diameter, c + (c - pt), Vec2::ZERO, Vec2::ZERO, pt, "")
} else {
dim(s, DimKind::Radius, c, Vec2::ZERO, Vec2::ZERO, pt, Vec2::ZERO, "")
dim(s, DimKind::Radius, c, Vec2::ZERO, Vec2::ZERO, pt, "")
});
text_at(k, at)
}
@@ -1240,7 +1244,7 @@ impl Interactive for AngularM {
([h1, h2], None) => angular_from_lines(s, *h1, *h2, at),
_ => None,
};
let k = k.unwrap_or_else(|| dim(s, DimKind::Angular3P, at, a, b, v, Vec2::ZERO, ""));
let k = k.unwrap_or_else(|| dim(s, DimKind::Angular3P, at, a, b, v, ""));
add_dim(s, self.text.apply(k))?;
Ok(Step::Done)
}
@@ -1306,9 +1310,7 @@ impl Interactive for AngularM {
}
}
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
self.geometry()
.map(|(v, a, b)| vec![self.text.apply(dim(s, DimKind::Angular3P, self.located(v, c), a, b, v, Vec2::ZERO, ""))])
.unwrap_or_default()
self.geometry().map(|(v, a, b)| vec![self.text.apply(dim(s, DimKind::Angular3P, self.located(v, c), a, b, v, ""))]).unwrap_or_default()
}
}
@@ -1384,7 +1386,7 @@ impl Interactive for ArcLenM {
Ok(Step::Continue)
}
(Some(g), Input::Point(at)) => {
add_dim(s, self.text.apply(dim(s, DimKind::ArcLength, at, g.start_point(), g.end_point(), g.center, Vec2::ZERO, "")))?;
add_dim(s, self.text.apply(dim(s, DimKind::ArcLength, at, g.start_point(), g.end_point(), g.center, "")))?;
Ok(Step::Done)
}
(Some(_), Input::Keyword(k)) => {
@@ -1403,9 +1405,7 @@ impl Interactive for ArcLenM {
if self.partial.is_some() {
return Vec::new();
}
self.arc
.map(|g| vec![self.text.apply(dim(s, DimKind::ArcLength, c, g.start_point(), g.end_point(), g.center, Vec2::ZERO, ""))])
.unwrap_or_default()
self.arc.map(|g| vec![self.text.apply(dim(s, DimKind::ArcLength, c, g.start_point(), g.end_point(), g.center, ""))]).unwrap_or_default()
}
}
@@ -1420,7 +1420,7 @@ struct OrdinateM {
impl OrdinateM {
fn kind(&self, s: &Session, f: Vec2, l: Vec2) -> EntityKind {
let x = self.xtype.unwrap_or((l - f).y.abs() > (l - f).x.abs());
self.text.apply(dim(s, DimKind::Ordinate { x_type: x }, Vec2::ZERO, f, l, Vec2::ZERO, Vec2::ZERO, ""))
self.text.apply(dim(s, DimKind::Ordinate { x_type: x }, Vec2::ZERO, f, l, Vec2::ZERO, ""))
}
}
+6 -3
View File
@@ -5,8 +5,11 @@ use cadcraft_doc::{DimBreak, DimKind, Dimension, EntityKind, Handle, Inspection,
use cadcraft_geom::{Segment, Vec2};
use serde_json::{Value, json};
use super::machines::{SelOutcome, SelectPhase};
use super::*;
use super::super::helpers::v3;
use super::super::machines::{SelOutcome, SelectPhase};
use super::super::{CommandSpec, bad, bool_or, point_param, point_req, size_param, str_param, targets};
use super::{DimText, add_dim, circle_of, dim, dims_of, pick_at};
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
/// Most objects DIMBREAK Auto looks at, and most breaks it adds to one dimension.
@@ -280,7 +283,7 @@ fn jogged_kind(s: &Session, c: Vec2, r: f64, center: Vec2, at: Vec2, jog: Option
}
let tip = c + dir * r;
let jog = jog.unwrap_or_else(|| center.mid(tip));
let mut k = dim(s, DimKind::Radius, c, Vec2::ZERO, Vec2::ZERO, tip, Vec2::ZERO, text);
let mut k = dim(s, DimKind::Radius, c, Vec2::ZERO, Vec2::ZERO, tip, text);
if let EntityKind::Dimension(dm) = &mut k {
dm.marks.jogged = Some(JoggedRadius { center: v3(center), jog: v3(jog) });
if at.dist(c) > r + 1e-9 {
+8 -7
View File
@@ -6,8 +6,10 @@ use cadcraft_doc::{DimKind, EntityKind, Handle, Prim};
use cadcraft_geom::{Segment, Vec2};
use serde_json::{Value, json};
use super::machines::{SelOutcome, SelectPhase};
use super::*;
use super::super::machines::{SelOutcome, SelectPhase};
use super::super::{bad, point_param, point_req, points_param, str_param, targets};
use super::{MAX_CHAIN, assoc_enabled, dim};
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
pub(super) const PARAMS: &str = "{handles?, at: [x,y] (dimension line position), mode?: continuous|staggered|baseline|ordinate|radius|diameter (default continuous), vertical?: bool (default: from `at`), datum?: [x,y] (baseline and ordinate; default the first point, or the origin for ordinate), add?: [[x,y]], remove?: [[x,y]] (dimension points; removes those within `tolerance`)}";
@@ -94,9 +96,9 @@ fn plan(
let dir = if dir == Vec2::ZERO || !dir.is_finite() { Vec2::from_angle(std::f64::consts::FRAC_PI_4) } else { dir };
let p = *c + dir * *r;
out.push(if mode == Mode::Diameter {
dim(s, DimKind::Diameter, *c - dir * *r, Vec2::ZERO, Vec2::ZERO, p, Vec2::ZERO, "")
dim(s, DimKind::Diameter, *c - dir * *r, Vec2::ZERO, Vec2::ZERO, p, "")
} else {
dim(s, DimKind::Radius, *c, Vec2::ZERO, Vec2::ZERO, p, Vec2::ZERO, "")
dim(s, DimKind::Radius, *c, Vec2::ZERO, Vec2::ZERO, p, "")
});
}
if out.is_empty() {
@@ -129,7 +131,7 @@ fn plan(
.iter()
.map(|p| {
let end = if vertical { Vec2::new(at.x, p.y) } else { Vec2::new(p.x, at.y) };
dim(s, DimKind::Ordinate { x_type: !vertical }, origin, *p, end, Vec2::ZERO, Vec2::ZERO, "")
dim(s, DimKind::Ordinate { x_type: !vertical }, origin, *p, end, Vec2::ZERO, "")
})
.collect());
}
@@ -145,8 +147,7 @@ fn plan(
let mid = bounds.center();
let out_dir =
if vertical { Vec2::new(if at.x >= mid.x { 1.0 } else { -1.0 }, 0.0) } else { Vec2::new(0.0, if at.y >= mid.y { 1.0 } else { -1.0 }) };
let lin =
|a: Vec2, b: Vec2, k: usize| dim(s, DimKind::Linear { rotation: rot }, at + out_dir * (step * k as f64), a, b, Vec2::ZERO, Vec2::ZERO, "");
let lin = |a: Vec2, b: Vec2, k: usize| dim(s, DimKind::Linear { rotation: rot }, at + out_dir * (step * k as f64), a, b, Vec2::ZERO, "");
Ok(match mode {
Mode::Baseline => {
// From the datum (the point nearest it, by default the first) to every other point.
+2 -1
View File
@@ -17,7 +17,8 @@ use serde_json::{Value, json};
use super::helpers::{line, v3};
use super::machines::{SelOutcome, SelectPhase, number};
use super::modify::transform_entities;
use super::*;
use super::{CommandSpec, bad, bool_or, f64_or, find_command, point_param, point_req, str_param, targets};
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
mod edit;
+2 -1
View File
@@ -16,7 +16,8 @@ use serde_json::{Value, json};
use super::super::helpers::v3;
use super::super::machines::{SelOutcome, SelectPhase, number};
use super::super::*;
use super::super::{CommandSpec, bad, bool_or, point_param, targets};
use super::{KEYWORDS, angle, check_size, count, fmt, nesting, other, yes, yes_no};
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
+3 -1
View File
@@ -5,7 +5,9 @@ use std::sync::Arc;
use serde_json::{Value, json};
use super::*;
use super::{CommandSpec, bad, bool_or, file, str_param};
use super::always;
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
pub fn specs() -> Vec<CommandSpec> {
+3 -1
View File
@@ -12,7 +12,9 @@ use serde_json::{Value, json};
use super::helpers::v3;
use super::machines::{SelOutcome, SelectPhase, number};
use super::*;
use super::{CommandSpec, bad, bool_or, f64_or, find_command, ok, point_param, point_req, point_value, str_param, targets};
use super::has_doc;
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
mod attedit;
+42
View File
@@ -420,6 +420,17 @@ fn import(d: &mut Drawing, key: &str, x: &Drawing, cx: &mut Ctx) -> Result<(), S
nb.anonymous = b.anonymous;
nb.explodable = b.explodable;
nb.units = b.units;
// Array parameters keep their kind and spacing; block names follow the block renaming, and
// a source edit in progress in the referenced file is not carried over.
nb.array = b.array.as_ref().map(|a| {
let mut a = a.clone();
a.source = dep(Sym::Block, &a.source).unwrap_or(a.source);
for r in &mut a.replaced {
r.block = dep(Sym::Block, &r.block).unwrap_or_else(|| r.block.clone());
}
a.editing = None;
a
});
for e in b.entities.iter() {
let e = adopt(d, e);
nb.entities.push(e);
@@ -532,4 +543,35 @@ mod tests {
assert!(!has_prefix("SITE", "site|"));
assert!(!has_prefix("é", "ab"));
}
#[test]
fn imported_array_blocks_keep_their_parameters() {
use cadcraft_doc::{ArrayKind, ArrayParams, Block, RectArray, ReplacedItem};
let rect = RectArray { cols: 3, rows: 2, col_spacing: 5.0, row_spacing: 4.0, axis_angle: 0.0, row_elevation: 0.0 };
let params = ArrayParams {
source: "*U1".into(),
kind: ArrayKind::Rect(rect),
levels: 1,
level_spacing: 0.0,
replaced: vec![ReplacedItem { i: 1, j: 0, level: 0, block: "ALT".into() }],
editing: Some(Default::default()),
};
let mut arr = Block::new("*U2");
arr.array = Some(params);
let mut x = Drawing::default();
x.blocks.insert("*U2".into(), Arc::new(arr));
let mut d = Drawing::default();
d.blocks.insert("SITE".into(), Arc::new(Block::new("SITE")));
let mut cx = Ctx { chain: Vec::new(), objects: 0, files: 0, bytes: 0, notes: Vec::new() };
assert!(import(&mut d, "SITE", &x, &mut cx).is_ok());
let got = d.blocks.get("SITE|*U2").and_then(|b| b.array.clone());
let Some(got) = got else { panic!("array parameters were dropped") };
assert_eq!(got.source, "SITE|*U1");
assert_eq!(got.replaced[0].block, "SITE|ALT");
assert!(got.editing.is_none());
let ArrayKind::Rect(r) = got.kind else { panic!("not a rectangular array") };
assert_eq!((r.cols, r.rows), (3, 2));
}
}
+25 -7
View File
@@ -14,7 +14,8 @@ use cadcraft_geom::{Line, Vec2};
use serde_json::{Value, json};
use super::machines::{SelOutcome, SelectPhase};
use super::*;
use super::{CommandSpec, bad, bool_or, f64_or, point_value, str_param, targets};
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
macro_rules! gc {
@@ -327,7 +328,7 @@ fn add_constraint(s: &mut Session, c: Constraint) -> Result<u32> {
let keep: Vec<Handle> = c.refs.first().map(|r| r.handle).into_iter().collect();
let prefer_move: Vec<Handle> = c.refs.iter().skip(1).map(|r| r.handle).filter(|h| !keep.contains(h)).collect();
let mut d = s.doc()?.clone();
let id = cs::add(&mut d, c, &cs::SolveOptions { keep, prefer_move }).map_err(err)?;
let id = cs::add(&mut d, c, &cs::SolveOptions { keep, prefer_move, ..Default::default() }).map_err(err)?;
*s.doc_mut()? = d;
Ok(id)
}
@@ -1119,7 +1120,7 @@ fn infer_after(s: &mut Session, before: &Drawing) {
return;
}
let mut nd = d.clone();
let opts = cs::SolveOptions { keep: others, prefer_move: placed };
let opts = cs::SolveOptions { keep: others, prefer_move: placed, ..Default::default() };
let added = cands.into_iter().take(200).filter(|c| cs::add(&mut nd, c.clone(), &opts).is_ok()).count();
if added > 0
&& let Ok(st) = s.state_mut()
@@ -1148,7 +1149,8 @@ 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);
let anchors = cs::moved_anchors(&d, before);
let satisfied = anchors.is_empty() && cs::all_satisfied(&d);
if purged.is_empty() && !restored && satisfied {
return;
}
@@ -1167,9 +1169,25 @@ pub fn after_command(s: &mut Session, before: Option<&Arc<Drawing>>, ok: bool) {
keep.push(h);
}
}
let mut trial = d.clone();
match cs::solve(&mut trial, &cs::SolveOptions { keep, prefer_move: Vec::new() }) {
Ok(_) => d = trial,
// A grip drag moved some of an object's points: they stay where they were put, and the
// edited objects still move least. When that cannot be solved, the edited objects are
// only kept as a whole, as for any other edit.
let hold: Vec<GeomRef> = keep.iter().flat_map(|&h| cs::edited_points(&d, before, h)).collect();
let mut solved = None;
if !hold.is_empty() {
let mut trial = d.clone();
let opts = cs::SolveOptions { keep: keep.clone(), prefer_move: Vec::new(), hold, anchors: anchors.clone() };
solved = cs::solve(&mut trial, &opts).ok().map(|_| trial);
}
let solved = match solved {
Some(t) => Ok(t),
None => {
let mut trial = d.clone();
cs::solve(&mut trial, &cs::SolveOptions { keep, anchors, ..Default::default() }).map(|_| trial)
}
};
match solved {
Ok(t) => d = t,
Err(e) => msg.push(format!("Constraints could not be satisfied: {e}")),
}
}
+7 -2
View File
@@ -5,10 +5,15 @@ use cadcraft_doc::{EntityKind, HAlign, MText, Point, RayLine, Text, VAlign};
use cadcraft_geom::{Arc, Circle, Ellipse, PolyVertex, Polyline, Spline, Vec2, arc_to_bulge, bulge_to_arc};
use serde_json::{Value, json};
use super::helpers::*;
use super::helpers::{arc, circle, line, lwpoly, polygon_from_edge, polygon_typed_radius_point, polygon_vertices, v3};
use super::machines::number;
use super::pline_opts::{Outcome, PlineAsk};
use super::*;
use super::{
CommandSpec, bad, bool_or, distinct_points, f64_or, f64_req, find_command, point_param, point_req, point_value, points_param, size_param,
str_param,
};
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step, snap};
/// Every draw command's spec: JSON parameters, menu path, aliases and the interactive prompt
+9 -6
View File
@@ -7,9 +7,11 @@ use cadcraft_geom::{Arc, Circle, Ellipse, Line, PolyVertex, Polyline, Spline, TA
use serde_json::{Value, json};
use super::curves::{self, Chain, MAX_GEN, TanObj};
use super::helpers::*;
use super::helpers::{arc, circle, line, lwpoly, rect_vertices, rect_with_corners, v3};
use super::machines::number;
use super::*;
use super::{CommandSpec, bad, bool_or, f64_or, f64_req, point_param, point_req, point_value, points_param, size_param, str_param, targets};
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
pub(crate) mod breakline;
@@ -1572,7 +1574,7 @@ impl Interactive for XlineM2 {
// RECTANG with Area / Dimensions / Rotation / Width / Elevation / Thickness
// =====================================================================================
fn rect_kind(first: Vec2, w: f64, h: f64, rot: f64, fillet: f64, chamfer: (f64, f64), width: f64, elevation: f64) -> EntityKind {
fn rect_kind(first: Vec2, (w, h): (f64, f64), rot: f64, fillet: f64, chamfer: (f64, f64), width: f64, elevation: f64) -> EntityKind {
let local = rect_with_corners(Vec2::ZERO, Vec2::new(w, h), fillet, chamfer);
let vs = local.into_iter().map(|v| PolyVertex { p: first + v.p.rotate(rot), ..v }).collect();
let mut k = lwpoly(vs, true);
@@ -1631,7 +1633,7 @@ pub(crate) fn run_rectang2(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad("rectang", "rectangle has no area"));
}
let first = if centered { a - Vec2::new(w / 2.0, h / 2.0).rotate(rot) } else { a };
let k = rect_kind(first, w, h, rot, fillet, ch, width, f64_or(p, "elevation", 0.0));
let k = rect_kind(first, (w, h), rot, fillet, ch, width, f64_or(p, "elevation", 0.0));
let hd = s.add_entity(k)?;
// A rectangle has its own thickness option and does not take THICKNESS.
let th = f64_or(p, "thickness", 0.0);
@@ -1683,7 +1685,8 @@ impl RectM2 {
return Err(other("The rectangle has no area."));
}
let first = self.origin(w, h).unwrap_or(a);
let hd = s.add_entity(rect_kind(first, w, h, self.rotation, self.fillet, self.chamfer, self.width, self.elevation))?;
let hd = s.add_entity(rect_kind(first, (w, h), self.rotation, self.fillet, self.chamfer, self.width, self.elevation))?;
// A rectangle has its own thickness option and does not take THICKNESS.
let t = self.thickness;
s.doc_mut()?.modify_entity(hd, |e| e.common.thickness = t)?;
Ok(Step::Done)
@@ -1861,7 +1864,7 @@ impl Interactive for RectM2 {
}
match self.other_corner(c).and_then(|(w, h)| self.origin(w, h).map(|a| (a, w, h))) {
Some((a, w, h)) if w.abs() > 1e-12 && h.abs() > 1e-12 => {
vec![rect_kind(a, w, h, self.rotation, self.fillet, self.chamfer, self.width, self.elevation)]
vec![rect_kind(a, (w, h), self.rotation, self.fillet, self.chamfer, self.width, self.elevation)]
}
_ => Vec::new(),
}
+3 -2
View File
@@ -9,10 +9,11 @@ use cadcraft_doc::{Drawing, EntityKind};
use cadcraft_geom::{PolyVertex, Vec2};
use serde_json::Value;
use super::super::machines::number;
use super::added;
use super::curves::MAX_GEN;
use super::machines::number;
use super::*;
use super::{CommandSpec, line, lwpoly, point_param, point_req, size_param, str_param};
use crate::cmd::modify2::as_lwpoly;
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
+4 -2
View File
@@ -9,9 +9,11 @@ use cadcraft_doc::{
use cadcraft_geom::{Line, Vec2, line_line_infinite};
use serde_json::{Value, json};
use super::super::machines::number;
use super::super::{distinct_points, ok};
use super::curves::{self, MAX_GEN};
use super::machines::number;
use super::*;
use super::{CommandSpec, bad, bool_or, f64_or, point_req, points_param, str_param};
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
pub fn specs() -> Vec<CommandSpec> {
+2 -1
View File
@@ -8,7 +8,8 @@ use cadcraft_doc::{EntityKind, Handle};
use serde_json::{Value, json};
use super::machines::{SelOutcome, SelectPhase, SelectRun};
use super::*;
use super::{CommandSpec, EngineError, bad, ok, targets};
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
pub fn specs() -> Vec<CommandSpec> {
+2 -1
View File
@@ -5,7 +5,8 @@ use cadcraft_geom::{Bounds2, Mat3, Vec2};
use serde_json::{Value, json};
use super::machines::{SelOutcome, SelectPhase, SelectRun};
use super::*;
use super::{CommandSpec, EngineError, bad, bool_or, clipboard, f64_or, has_selection, point_param, point_req, points_param, str_param, targets};
use crate::undoctl::Control;
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
+5 -1
View File
@@ -18,7 +18,8 @@ use cadcraft_render::raster::{RasterOptions, View};
use serde_json::{Value, json};
use super::file::{base64_decode, base64_encode, io};
use super::*;
use super::{CommandSpec, bad, point_value, str_param};
use crate::{EngineError, Result, Session};
/// Formats EXPORT writes (the io layer picks the writer from the extension).
@@ -557,7 +558,10 @@ mod tests {
use cadcraft_geom::Vec3;
use super::super::file::{IoHooks, set_io};
use super::super::resolve_alias;
use super::*;
use serde_json::json;
static DELIVERED: std::sync::Mutex<Vec<(String, String, usize)>> = std::sync::Mutex::new(Vec::new());
+13 -1
View File
@@ -11,7 +11,8 @@ use cadcraft_doc::Drawing;
pub use cadcraft_dxf::Version;
use serde_json::{Value, json};
use super::*;
use super::{CommandSpec, EngineError, Interactive, always, bad, bool_or, find_command, ok, str_param};
use crate::{Accept, Input, Prompt, Result, Session, Step};
/// The rules the Command Aliases dialog checks before it sends an alias to ALIASEDIT.
@@ -362,6 +363,7 @@ fn run_open(s: &mut Session, p: &Value) -> Result<Value> {
Ok(opened(s, i, false))
}
/// `close [index]`: cancel the running command, remove the drawing (default: active), keep the active index valid, and bump the document epoch; an unknown index is an error.
fn run_close(s: &mut Session, p: &Value) -> Result<Value> {
let i = p.get("index").and_then(Value::as_u64).map(|i| i as usize).unwrap_or(s.active);
if i >= s.docs.len() {
@@ -376,13 +378,16 @@ fn run_close(s: &mut Session, p: &Value) -> Result<Value> {
if s.active >= s.docs.len() {
s.active = s.docs.len().saturating_sub(1);
}
s.bump_doc_epoch();
ok()
}
/// `closeall`: cancel the running command, remove every drawing, reset the active index to 0 and bump the document epoch.
fn run_closeall(s: &mut Session, _p: &Value) -> Result<Value> {
s.cancel();
s.docs.clear();
s.active = 0;
s.bump_doc_epoch();
ok()
}
@@ -487,6 +492,7 @@ fn drawing_version(s: &Session, path: &str) -> Version {
if is_dxf(path) { v } else { v.max(Version::R2004) }
}
/// `document.switch index`: require the index, cancel the running command, activate that drawing and bump the epoch even if it was already active (screenshot identity).
fn run_switch(s: &mut Session, p: &Value) -> Result<Value> {
let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("document.switch", "`index` is required"))? as usize;
if i >= s.docs.len() {
@@ -494,6 +500,11 @@ fn run_switch(s: &mut Session, p: &Value) -> Result<Value> {
}
s.cancel();
s.active = i;
// Bump even when `i` is already active. This is intentionally conservative: the command
// cancels the active command, and a screenshot client can only see observed state, not
// whether the switch was a no-op. Rejecting an outstanding capture is safer than returning
// an image whose queueing identity the client cannot distinguish from a real switch.
s.bump_doc_epoch();
ok()
}
@@ -721,6 +732,7 @@ mod tests {
#[cfg(test)]
mod pr193_tests {
use super::*;
use crate::Session;
#[test]
fn failed_store_keeps_unsaved_changes_and_delivered_save_clears_them() {
+2 -1
View File
@@ -12,7 +12,8 @@ use cadcraft_doc::{Drawing, Entity, EntityKind, Handle, MAX_BLOCK_DEPTH, Space,
use cadcraft_geom::{Bounds2, Vec2};
use serde_json::{Value, json};
use super::super::*;
use super::super::{CommandSpec, bad, str_param};
use super::{base64_decode, io};
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
+2 -1
View File
@@ -12,8 +12,9 @@ use std::sync::{Mutex, MutexGuard, OnceLock, PoisonError};
use serde_json::{Value, json};
use super::super::always;
use super::super::helpers::support_text;
use super::*;
use super::{CommandSpec, Result, Session, bad, bool_or, find_command};
/// Most user aliases held.
pub const MAX_ALIASES: usize = 10_000;
+1 -1
View File
@@ -10,7 +10,7 @@ use std::sync::{Mutex, MutexGuard, OnceLock, PoisonError};
use cadcraft_doc::Drawing;
use serde_json::{Value, json};
use super::*;
use super::{Result, Session, bad, io};
/// The default template (profile setting, process-wide like FONTALT): empty = none.
fn default_template() -> MutexGuard<'static, String> {
+2 -1
View File
@@ -5,7 +5,8 @@ use cadcraft_geom::{Mat3, Vec2};
use serde_json::{Value, json};
use super::curves;
use super::*;
use super::{CommandSpec, bad, bool_or, point_param, point_req, str_param, targets};
use crate::grips::{GripAction, GripMode, grip_action, mode_matrix, stretch_grip};
use crate::{EngineError, Result, Session};
+2 -1
View File
@@ -11,7 +11,8 @@ use cadcraft_doc::{Drawing, Group, Handle};
use serde_json::{Value, json};
use super::machines::{SelOutcome, SelectPhase};
use super::*;
use super::{CommandSpec, bad, bool_or, str_param, targets};
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
mod dash;
+2 -1
View File
@@ -7,7 +7,8 @@ use serde_json::{Value, json};
use super::super::machines::{SelOutcome, SelectPhase, number};
use super::super::qselect::wildcard;
use super::super::*;
use super::super::{CommandSpec, bad, bool_or, str_param, targets};
use super::{add_members, create, echo_list, find, remove_members, rename, ungroup, valid_name};
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
+2 -1
View File
@@ -11,7 +11,8 @@ use cadcraft_geom::{Line, Vec2, line_line, point_in_polygon};
use serde_json::{Value, json};
use super::super::machines::{SelOutcome, SelectPhase, SelectRun};
use super::super::*;
use super::super::{CommandSpec, bad, targets};
use super::SelfCheck;
use crate::{EngineError, Input, Interactive, Prompt, Result, Session, Step};
+3 -1
View File
@@ -6,7 +6,9 @@ use cadcraft_doc::{Entity, EntityKind};
use cadcraft_geom::{Bounds2, Polyline, Vec2, intersect_segments};
use serde_json::{Value, json};
use super::super::*;
use super::super::bad;
use super::super::point_value;
use super::{SelfCheck, area_of, object_area};
use crate::{Result, Session};
+8 -9
View File
@@ -8,8 +8,9 @@ use std::collections::{HashMap, HashSet};
use cadcraft_doc::{Drawing, Entity, EntityKind};
use serde_json::{Value, json};
use super::super::bad;
use super::super::blocks::purge;
use super::super::*;
use crate::{Result, Session};
const CMD: &str = "usage";
@@ -280,11 +281,9 @@ impl Unused {
fn item_json(
c: Category,
name: &str,
entities: usize,
block_entities: usize,
(entities, block_entities): (usize, usize),
references: usize,
used: bool,
empty: bool,
(used, empty): (bool, bool),
current: Option<bool>,
unused: &Unused,
) -> Value {
@@ -314,13 +313,13 @@ fn items(scan: &Scan, unused: &Unused, d: &Drawing) -> Vec<Value> {
let (e, b) = scan.layers.get(&l.name.to_ascii_lowercase()).copied().unwrap_or_default();
let uses = e.saturating_add(b);
let cur = l.name.eq_ignore_ascii_case(&current_layer);
out.push(item_json(Category::Layer, &l.name, e, b, 0, uses > 0, uses == 0, Some(cur), unused));
out.push(item_json(Category::Layer, &l.name, (e, b), 0, (uses > 0, uses == 0), Some(cur), unused));
}
for b in d.blocks.values() {
let (e, n) = scan.blocks.get(&b.name.to_ascii_lowercase()).copied().unwrap_or_default();
// A definition is used when a reference to it is reachable, as PURGE counts it.
let used = scan.used_blocks.contains(&b.name.to_ascii_uppercase());
out.push(item_json(Category::Block, &b.name, e, n, 0, used, e.saturating_add(n) == 0, None, unused));
out.push(item_json(Category::Block, &b.name, (e, n), 0, (used, e.saturating_add(n) == 0), None, unused));
}
let current_style = d.header.str("TEXTSTYLE", "Standard");
for t in &d.text_styles {
@@ -328,7 +327,7 @@ fn items(scan: &Scan, unused: &Unused, d: &Drawing) -> Vec<Value> {
let r = scan.style_refs.get(&t.name.to_ascii_lowercase()).copied().unwrap_or(0);
let uses = e.saturating_add(b).saturating_add(r);
let cur = t.name.eq_ignore_ascii_case(&current_style);
out.push(item_json(Category::TextStyle, &t.name, e, b, r, uses > 0, uses == 0, Some(cur), unused));
out.push(item_json(Category::TextStyle, &t.name, (e, b), r, (uses > 0, uses == 0), Some(cur), unused));
}
let current_linetype = d.header.str("CELTYPE", "ByLayer");
for t in &d.linetypes {
@@ -336,7 +335,7 @@ fn items(scan: &Scan, unused: &Unused, d: &Drawing) -> Vec<Value> {
let r = scan.linetype_refs.get(&t.name.to_ascii_lowercase()).copied().unwrap_or(0);
let uses = e.saturating_add(b).saturating_add(r);
let cur = t.name.eq_ignore_ascii_case(&current_linetype);
out.push(item_json(Category::Linetype, &t.name, e, b, r, uses > 0, uses == 0, Some(cur), unused));
out.push(item_json(Category::Linetype, &t.name, (e, b), r, (uses > 0, uses == 0), Some(cur), unused));
}
out
}
+2 -1
View File
@@ -7,7 +7,8 @@ use cadcraft_doc::{Layer, Lineweight};
use serde_json::{Value, json};
use super::machines::SelectRun;
use super::*;
use super::{CommandSpec, bad, bool_or, ok, str_param, targets};
use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> {
+1
View File
@@ -10,6 +10,7 @@ use serde_json::{Value, json};
use super::file::{base64_encode, io};
use super::*;
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
use viewports::MviewM;
+2 -1
View File
@@ -8,7 +8,8 @@
use cadcraft_doc::{Drawing, PlotStyle, PlotStyleKind, PlotStyleTable};
use serde_json::{Value, json};
use super::*;
use super::super::targets;
use super::{CommandSpec, Result, Session, bad, str_param};
pub fn specs() -> Vec<CommandSpec> {
vec![
+2 -1
View File
@@ -9,7 +9,8 @@ use serde_json::{Value, json};
use super::helpers::v3;
use super::machines::{SelOutcome, SelectPhase};
use super::*;
use super::{CommandSpec, bad, point_req, points_param, str_param, targets};
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
/// Most arrowheads added by one call, and most multileaders aligned / collected by one call.
+1 -1
View File
@@ -33,7 +33,7 @@ pub mod plotters;
mod props;
pub use draw2::breakline::BreaklineOptions;
pub use props::MatchProps;
pub(crate) use props::set_current_color_value;
pub(crate) use props::{current_color_value, set_current_color_value};
mod qselect;
mod selecttools;
mod settings;
+4 -2
View File
@@ -7,10 +7,12 @@ use cadcraft_geom::{
};
use serde_json::{Value, json};
use super::helpers::*;
use super::helpers::{arc, circle, line, lwpoly, v3};
use super::machines::{SelOutcome, SelectPhase, number};
use super::trimextend::{self, TrimM};
use super::*;
use super::{CommandSpec, bad, bool_or, f64_or, f64_req, point_param, point_req, points_param, str_param, targets};
use crate::select::{SelectKind, SelectMode};
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
+4 -2
View File
@@ -9,9 +9,11 @@ use cadcraft_geom::{
use serde_json::{Value, json};
use super::curves::{self, Chain, MAX_GEN, TanObj};
use super::helpers::*;
use super::helpers::{arc, line, lwpoly, v3};
use super::machines::{SelOutcome, SelectPhase, number};
use super::*;
use super::{CommandSpec, bad, bool_or, f64_or, f64_req, point_param, point_req, str_param, targets};
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
mod curve_edits;
@@ -80,8 +80,7 @@ fn same_polyline(
s: &mut Session,
h: Handle,
pl: &LwPolyline,
p1: Vec2,
p2: Vec2,
(p1, p2): (Vec2, Vec2),
r: f64,
chamfer: Option<ChamferDistances>,
trim: bool,
@@ -223,7 +222,7 @@ pub(crate) fn fillet_pline(
_ => None,
};
let made = match (&e1.kind, &e2.kind) {
(EntityKind::LwPolyline(pl), _) if h1 == h2 => same_polyline(s, h1, pl, p1, p2, r, chamfer, trim)?,
(EntityKind::LwPolyline(pl), _) if h1 == h2 => same_polyline(s, h1, pl, (p1, p2), r, chamfer, trim)?,
(EntityKind::LwPolyline(pl), k) if as_line(k).is_some() => {
let ln = as_line(k).ok_or_else(|| other("Select a line."))?;
polyline_and_line(s, (h1, pl, p1), (h2, ln, p2), true, r, chamfer, trim)?
@@ -1,7 +1,10 @@
//! PEDIT Edit vertex: Break and Straighten between two marked vertices, Tangent directions
//! that Fit honours, and Regen.
use super::*;
use super::super::point_value;
use super::{
EntityKind, Handle, Input, LwPolyline, PPhase, PeditM, PolyVertex, Result, Session, Value, Vec2, bad, curves, json, other, push_entity, set_kind,
};
fn ordered(pl: &LwPolyline, i: usize, j: usize) -> Result<(usize, usize)> {
let n = pl.vertices.len();
+5 -1
View File
@@ -6,7 +6,11 @@
//! parametrisation, end tangents and fit tolerance. Control-point edits change the curve
//! directly and drop the fit data, as the curve no longer follows it.
use super::*;
use super::super::{point_value, points_param};
use super::{
Accept, Entity, EntityKind, Handle, Input, Interactive, LwPolyline, MAX_GEN, PolyVertex, Prompt, Result, SelOutcome, SelectPhase, Session, Space,
Spline, Step, Value, Vec2, bad, bool_or, curves, f64_req, json, line, number, other, point_req, set_kind, str_param, targets,
};
/// The spline after a JSON SPLINEDIT option (`option` plus that option's parameters).
pub(super) fn run_splinedit(s: &mut Session, p: &Value) -> Result<Value> {
+2 -1
View File
@@ -13,7 +13,8 @@ use cadcraft_geom::{TAU, Vec2, Vec3, ccw_sweep, norm_angle};
use serde_json::{Value, json};
use super::machines::{SelOutcome, SelectPhase, number};
use super::*;
use super::{CommandSpec, bad, targets};
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
pub fn specs() -> Vec<CommandSpec> {
+4 -1
View File
@@ -14,7 +14,8 @@ use cadcraft_doc::{DevicePaper, Orientation, PageSetup, PlotOutput, PlotterConfi
use cadcraft_geom::{Bounds2, Vec2};
use serde_json::{Value, json};
use super::*;
use super::{CommandSpec, Result, always, bad, has_doc, point_req, str_param};
use crate::{Accept, EngineError, Input, Interactive, Prompt, Session, Step};
/// Where plotter configuration files live. Names are file names ending in
@@ -362,6 +363,8 @@ impl Interactive for PlotWindowM {
fn name(&self) -> &'static str {
"PLOTWINDOW"
}
/// Ask for the first corner, then for the opposite one marked as a rectangle corner
/// (`Prompt::corner`), so ORTHO does not bend the pick and the window keeps its area.
fn prompt(&self, _s: &Session) -> Prompt {
match self.first {
None => Prompt::new("Specify first corner", Accept::POINT),
+22 -4
View File
@@ -10,7 +10,8 @@ use cadcraft_doc::{EntityKind, Lineweight, Transparency};
use cadcraft_geom::Vec3;
use serde_json::{Value, json};
use super::*;
use super::{CommandSpec, always, bad, bool_or, f64_req, find_command, ok, point_req, point_value, str_param, targets};
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
pub fn specs() -> Vec<CommandSpec> {
@@ -431,7 +432,11 @@ fn run_color(s: &mut Session, p: &Value) -> Result<Value> {
pub(crate) fn set_current_color_value(s: &mut Session, v: &Value) -> Option<()> {
// An out-of-range index is refused here, where `color` folds it to ByLayer.
let c = match v {
Value::String(t) => Color::parse(t),
// "RGB:r,g,b" is how `getvar` reads a true colour back, so a saved value can be restored.
Value::String(t) => match t.trim().get(..4).filter(|p| p.eq_ignore_ascii_case("rgb:")).and_then(|_| t.trim().get(4..)) {
Some(triple) => Color::parse(triple).filter(|c| matches!(c, Color::True(_))),
None => Color::parse(t),
},
Value::Number(n) => n.as_i64().and_then(|i| Color::parse(&i.to_string())),
_ => None,
}?;
@@ -439,6 +444,20 @@ pub(crate) fn set_current_color_value(s: &mut Session, v: &Value) -> Option<()>
Some(())
}
/// `getvar CECOLOR` as AutoCAD gives it, always text: "BYLAYER", "BYBLOCK", the index ("1" for
/// red) or "RGB:r,g,b".
pub(crate) fn current_color_value(d: &cadcraft_doc::Drawing) -> String {
if let Some(rgb) = d.header.get("CECOLOR_RGB").and_then(|v| v.as_i64()).and_then(|v| u32::try_from(v).ok()) {
let cadcraft_color::Rgb(r, g, b) = cadcraft_color::Rgb::from_u32(rgb);
return format!("RGB:{r},{g},{b}");
}
match Color::from_aci(d.header.i64("CECOLOR", 256) as i16) {
Color::ByLayer => "BYLAYER".into(),
Color::ByBlock => "BYBLOCK".into(),
other => other.to_aci().to_string(),
}
}
/// A true colour keeps its RGB in CECOLOR_RGB (which wins for new objects); any other colour must
/// clear a stale one.
fn set_current_color(d: &mut cadcraft_doc::Drawing, c: Color) {
@@ -1159,10 +1178,9 @@ fn run_rename(s: &mut Session, p: &Value) -> Result<Value> {
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use crate::Session;
#[test]
fn properties_set_rejects_unloaded_linetype() {
let mut s = Session::new();
+2 -1
View File
@@ -7,7 +7,8 @@ use serde_json::{Value, json};
use super::super::helpers::support_text;
use super::super::qselect::wildcard;
use super::*;
use super::{Result, Session, bad, bool_or, str_param};
use crate::{Accept, Input, Interactive, Prompt, Step};
/// Names no file may define: they are not linetype definitions.
+2 -1
View File
@@ -6,7 +6,8 @@
use cadcraft_doc::{Entity, EntityKind, Handle};
use serde_json::{Value, json};
use super::super::*;
use super::super::{bad, targets};
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
/// Which properties MATCHPROP copies (its Settings).
+3 -3
View File
@@ -5,7 +5,8 @@ use cadcraft_color::Color;
use cadcraft_doc::{Drawing, Entity, Handle};
use serde_json::{Value, json};
use super::*;
use super::{CommandSpec, bad, str_param};
use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> {
@@ -320,9 +321,8 @@ fn run_info(s: &mut Session, p: &Value) -> Result<Value> {
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
use serde_json::json;
fn session() -> Session {
let mut s = Session::new();
+2 -1
View File
@@ -9,7 +9,8 @@ use cadcraft_doc::{Drawing, Entity, EntityKind, Handle};
use serde_json::{Value, json};
use super::machines::{SelOutcome, SelectPhase, SelectRun};
use super::*;
use super::{CommandSpec, bad, targets};
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
/// Most objects one call looks at (a hostile drawing can be huge).
+2 -1
View File
@@ -2,7 +2,8 @@
use serde_json::{Value, json};
use super::*;
use super::{CommandSpec, always, bad, point_param, str_param};
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
pub(super) mod iso;
+21 -7
View File
@@ -12,7 +12,8 @@ use cadcraft_geom::Vec2;
use serde_json::{Value, json};
use super::helpers::v3;
use super::*;
use super::{CommandSpec, bad, point_req, size_param, str_param};
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
/// Caps for hostile parameters.
@@ -141,15 +142,28 @@ fn run_table(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "handle": h.hex() }))
}
/// Modify the table `handle` with `f`.
fn edit(s: &mut Session, p: &Value, id: &str, f: impl FnOnce(&mut Table) -> std::result::Result<(), String>) -> Result<Value> {
let h = p
.get("handle")
.and_then(|v| v.as_str().and_then(Handle::parse_hex).or_else(|| v.as_u64().map(Handle)))
/// The table `handle`, or else the selected table. A supplied `handle` that can't be read is an
/// error, never a silent fall back to the selection (like `targets`).
fn table_handle(s: &Session, p: &Value, id: &str) -> Result<Handle> {
let explicit = match p.get("handle") {
None | Some(Value::Null) => None,
Some(v) => Some(
v.as_str()
.and_then(Handle::parse_hex)
.or_else(|| v.as_u64().map(Handle))
.ok_or_else(|| bad(id, format!("`handle`: {v} is not an object handle (a hex string such as \"1A2\")")))?,
),
};
explicit
.or_else(|| {
s.selection().into_iter().find(|h| s.doc().ok().and_then(|d| d.entity(*h)).is_some_and(|e| matches!(e.kind, EntityKind::Table(_))))
})
.ok_or_else(|| bad(id, "`handle` of a table is required"))?;
.ok_or_else(|| bad(id, "`handle` of a table is required"))
}
/// Modify the table `handle` with `f`.
fn edit(s: &mut Session, p: &Value, id: &str, f: impl FnOnce(&mut Table) -> std::result::Result<(), String>) -> Result<Value> {
let h = table_handle(s, p, id)?;
let mut t = match s.doc()?.entity(h).map(|e| &e.kind) {
Some(EntityKind::Table(t)) => t.clone(),
_ => return Err(bad(id, "object is not a table")),
+1 -10
View File
@@ -6,7 +6,7 @@ use cadcraft_geom::Vec2;
use serde_json::{Value, json};
use super::formula::{self, cell_name, parse_cell_name};
use super::{MAX_COLS, MAX_ROWS, edit, usize_param};
use super::{MAX_COLS, MAX_ROWS, edit, table_handle, usize_param};
use crate::cmd::{CommandSpec, bad, bool_or, str_param};
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
@@ -524,15 +524,6 @@ fn write_text(path: &str, text: &str) -> Result<()> {
}
}
fn table_handle(s: &Session, p: &Value, id: &str) -> Result<Handle> {
p.get("handle")
.and_then(|v| v.as_str().and_then(Handle::parse_hex).or_else(|| v.as_u64().map(Handle)))
.or_else(|| {
s.selection().into_iter().find(|h| s.doc().ok().and_then(|d| d.entity(*h)).is_some_and(|e| matches!(e.kind, EntityKind::Table(_))))
})
.ok_or_else(|| bad(id, "`handle` of a table is required"))
}
fn run_export(s: &mut Session, p: &Value) -> Result<Value> {
let id = "tableexport";
let h = table_handle(s, p, id)?;
+2 -1
View File
@@ -5,7 +5,8 @@ use cadcraft_geom::{PolyVertex, Vec2};
use serde_json::{Value, json};
use super::helpers::{line, lwpoly, v3};
use super::*;
use super::{CommandSpec, bad, bool_or, f64_or, point_req, str_param};
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
pub fn specs() -> Vec<CommandSpec> {
+2 -1
View File
@@ -4,7 +4,8 @@ use cadcraft_doc::{EntityKind, Handle};
use cadcraft_geom::{Segment, Vec2};
use serde_json::{Value, json};
use super::*;
use super::{CommandSpec, always, bad, bool_or, point_param, points_param, str_param, targets};
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
pub fn specs() -> Vec<CommandSpec> {
+2 -1
View File
@@ -6,7 +6,8 @@ use cadcraft_geom::{Bounds2, Vec2};
use serde_json::{Value, json};
use super::machines::{SelectRun, number};
use super::*;
use super::{CommandSpec, bad, bool_or, f64_or, f64_req, ok, point_param, point_req, str_param, targets};
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step, View};
mod named_views;
+2 -1
View File
@@ -10,7 +10,8 @@ use cadcraft_geom::{Bounds2, Vec2};
use serde_json::{Value, json};
use super::super::qselect::wildcard;
use super::super::*;
use super::super::{CommandSpec, bad, point_param, str_param};
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step, View};
/// Most names one Delete or list takes.
+2 -1
View File
@@ -15,7 +15,8 @@ use cadcraft_geom::Vec2;
use serde_json::{Value, json};
use super::super::qselect::wildcard;
use super::super::*;
use super::super::{CommandSpec, bad, find_command, str_param};
use crate::{Accept, DocState, EngineError, Input, Interactive, ModelTile, ModelTiles, Prompt, Result, Session, Step, View};
/// Most tiles on screen at once.
+14
View File
@@ -647,6 +647,9 @@ impl Default for MLeaderOptions {
pub struct Session {
pub docs: Vec<DocState>,
pub active: usize,
/// Monotonic count of changes to the open drawings and the active one. A screenshot queued
/// across a switch is rejected even when a later switch returns to the same drawing.
pub doc_epoch: u64,
pub settings: Settings,
pub running: Option<Running>,
/// A script is running ([`Session::script`]); a SCRIPT command inside it is refused.
@@ -727,6 +730,7 @@ impl Session {
Session {
docs: Vec::new(),
active: 0,
doc_epoch: 0,
settings: Settings::default(),
running: None,
in_script: false,
@@ -759,6 +763,7 @@ impl Session {
hatch_picks: None,
}
}
/// Append a new untitled drawing (`DrawingN`, metric or imperial default view), end a running command, make it active, bump the document epoch and return its index.
pub fn new_drawing(&mut self, metric: bool) -> usize {
self.untitled_counter += 1;
let d = if metric { Drawing::new_metric() } else { Drawing::new_imperial() };
@@ -767,8 +772,10 @@ impl Session {
self.end_running();
self.docs.push(st);
self.active = self.docs.len() - 1;
self.bump_doc_epoch();
self.active
}
/// Append a loaded drawing, fit the view to its model extents (aspect-corrected, minimum height 1e-6) when non-empty, end a running command, make it active, bump the epoch and return its index.
pub fn open_drawing(&mut self, d: Drawing, title: &str, path: Option<String>) -> usize {
let mut st = DocState::new(d, title, path);
let ext = st.doc.extents(&Space::Model);
@@ -781,8 +788,14 @@ impl Session {
self.end_running();
self.docs.push(st);
self.active = self.docs.len() - 1;
self.bump_doc_epoch();
self.active
}
/// Record that which drawing is open or active changed (screenshot binding).
pub fn bump_doc_epoch(&mut self) {
self.doc_epoch = self.doc_epoch.wrapping_add(1);
}
/// A command still waiting for input belongs to the drawing it started in: end it there
/// before another drawing becomes current, so its undo step lands in that drawing.
fn end_running(&mut self) {
@@ -800,6 +813,7 @@ impl Session {
Err(_) => {
self.docs.truncate(n);
self.active = self.active.min(self.docs.len().saturating_sub(1));
self.bump_doc_epoch();
Err(EngineError::Internal("open".into(), "panic".into()))
}
}
+2 -2
View File
@@ -797,8 +797,8 @@ fn glob_inner(
return Err(());
}
*budget -= 1;
if depth > 64 {
return Ok(false);
if depth > 256 {
return Err(());
}
let (mut t, mut p) = (0usize, 0usize);
while p < pat.len() {
+13
View File
@@ -136,6 +136,10 @@ fn lists_and_strings() {
assert_eq!(ok(&mut s, "(wcmatch \"abc\" \"x*,a?c\")"), "T");
assert_eq!(ok(&mut s, "(wcmatch \"abc\" \"~a*\")"), "nil");
assert_eq!(ok(&mut s, "(wcmatch \"b\" \"[a-c]\")"), "T");
let many = format!("(wcmatch \"{}\" \"{}\")", "a".repeat(80), "*a".repeat(80));
assert_eq!(ok(&mut s, &many), "T");
let too_deep = format!("(wcmatch \"{}\" \"{}\")", "a".repeat(400), "*a".repeat(400));
assert!(eval(&mut s, &too_deep).is_err());
assert_eq!(ok(&mut s, "(vl-string-subst \"X\" \"b\" \"abcb\")"), "\"aXcb\"");
assert_eq!(ok(&mut s, "(vl-string-search \"c\" \"abc\")"), "2");
assert_eq!(ok(&mut s, "(read \"(1 2)\")"), "(1 2)");
@@ -455,6 +459,15 @@ fn getvar_drawing_and_session_variables() {
assert_eq!(ok(&mut s, "(getvar \"DWGTITLED\")"), "1");
}
/// CECOLOR is a string in AutoCAD, whatever it was set from (#1999).
#[test]
fn getvar_cecolor_is_a_string() {
let mut s = session();
assert_eq!(ok(&mut s, "(list (getvar \"CECOLOR\") (type (getvar \"CECOLOR\")))"), "(\"BYLAYER\" STR)");
assert_eq!(ok(&mut s, "(setvar \"CECOLOR\" \"red\") (getvar \"CECOLOR\")"), "\"1\"");
assert_eq!(ok(&mut s, "(setvar \"CECOLOR\" \"10,20,30\") (getvar \"CECOLOR\")"), "\"RGB:10,20,30\"");
}
#[test]
fn input_functions_take_supplied_answers() {
let mut s = session();
+2 -2
View File
@@ -41,8 +41,8 @@ pub struct Prompt {
/// prompt (a point with no base yet, e.g. the first point of a line). Elsewhere a deferred
/// pick arrives as the plain point it was picked at.
pub deferred: bool,
/// The base is a rectangle's first corner (ZOOM Window, RECTANG, ARRAYRECT's unit cell): ortho
/// does not bend the opposite corner, so the rectangle keeps its area.
/// The base is a rectangle's first corner (ZOOM Window, PLOTWINDOW, RECTANG, ARRAYRECT's unit
/// cell): ortho does not bend the opposite corner, so the rectangle keeps its area.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub corner: bool,
/// The grip this prompt stretches (one hot grip at the STRETCH prompt), as (object, grip
+3 -1
View File
@@ -146,6 +146,8 @@ pub fn get(s: &Session, name: &str) -> Option<Value> {
// Held in radians (`$ANGBASE` in DXF is in degrees); reported in degrees, like POLARANG.
"ANGBASE" => json!(s.doc().ok()?.header.f64("ANGBASE", 0.0).to_degrees()),
"CONSTRAINTINFER" => json!(i32::from(s.doc().ok()?.parametric.settings.infer)),
// Text, as AutoCAD reads it back; the header keeps the index (and CECOLOR_RGB) for DXF.
"CECOLOR" => json!(crate::cmd::current_color_value(s.doc().ok()?)),
// Process-wide font substitution (profile settings, not saved in the drawing).
"FONTALT" => json!(cadcraft_fonts::ttf::font_alt()),
"FONTFALLBACK" => json!(cadcraft_fonts::ttf::fallback_fonts()),
@@ -447,7 +449,7 @@ pub fn list(s: &Session) -> Value {
}
if let Ok(d) = s.doc() {
for (k, v) in &d.header.vars {
if matches!(k.as_str(), "ANGBASE" | "SNAPUNIT" | "GRIDUNIT") {
if matches!(k.as_str(), "ANGBASE" | "SNAPUNIT" | "GRIDUNIT" | "CECOLOR") {
m.insert(k.clone(), get(s, k).unwrap_or(Value::Null));
} else if !k.starts_with("CADCRAFT_") {
m.insert(k.clone(), serde_json::to_value(v).unwrap_or(Value::Null));
+38 -4
View File
@@ -22,6 +22,9 @@ pub fn format_distance(v: f64, lunits: i64, luprec: i64) -> String {
let rounded = (v.abs() * scale).round() / scale;
let a = if rounded.is_finite() { rounded } else { v.abs() };
let ft = (a / 12.0).floor();
if ft >= I64_SAFE_MAX {
return format!("{:.*}", prec, v);
}
let inch = a - ft * 12.0;
format!("{}{}'-{:.*}\"", if neg { "-" } else { "" }, ft as i64, prec, inch)
}
@@ -136,9 +139,13 @@ pub fn format_angle(a: f64, aunits: i64, auprec: i64) -> String {
}
}
/// `v` (in `[0, full)`), or 0 when it rounds at `prec` decimals to the same value as a full turn.
/// `v` (in `[0, full)`), or 0 when it rounds at `prec` decimals to the printed full-turn value (only when that value is exact).
fn wrap_full_turn(v: f64, full: f64, prec: usize) -> f64 {
if format!("{v:.prec$}") == format!("{full:.prec$}") { 0.0 } else { v }
let full_text = format!("{full:.prec$}");
// A full turn that cannot be printed exactly (2π) only wraps when `v` is numerically that value.
let exact = full_text.parse::<f64>().is_ok_and(|f| f == full);
let reached = exact || full - v < 1e-6;
if reached && format!("{v:.prec$}") == full_text { 0.0 } else { v }
}
/// Parse a distance typed by the user: decimal, `1'6"`, `1'-6 1/2"`, `6 1/2`, `3/4`, `1e3`.
@@ -320,6 +327,12 @@ fn bearing(t: &str) -> Option<f64> {
Some(deg.to_radians())
}
/// Minutes or seconds of a D'" angle: a number in `0..=60`.
fn dms_part(t: &str) -> Option<f64> {
let v: f64 = t.trim().parse().ok()?;
(0.0..=60.0).contains(&v).then_some(v)
}
/// Degrees, `45d`, `45d30'` or `45d30'15"` (a leading `-` negates the whole angle).
fn parse_dms(t: &str) -> Option<f64> {
let (neg, t) = match t.trim().strip_prefix('-') {
@@ -332,11 +345,11 @@ fn parse_dms(t: &str) -> Option<f64> {
if !rest.is_empty() {
let (m, rest2) = rest.split_once('\'').unwrap_or((rest, ""));
if !m.trim().is_empty() {
deg += m.trim().parse::<f64>().ok()? / 60.0;
deg += dms_part(m)? / 60.0;
}
let s = rest2.trim().trim_end_matches('"');
if !s.is_empty() {
deg += s.parse::<f64>().ok()? / 3600.0;
deg += dms_part(s)? / 3600.0;
}
}
let deg = if neg { -deg } else { deg };
@@ -356,6 +369,20 @@ pub const DEG: f64 = PI / 180.0;
mod tests {
use super::*;
#[test]
fn dms_minutes_and_seconds_must_be_within_0_to_60() {
for t in ["45d-30'", "45d30'-15\"", "45d75'", "45d30'75\"", "n45d-30'e"] {
assert_eq!(parse_angle(t), None, "{t}");
}
assert!(parse_angle("45d30'15\"").is_some());
}
#[test]
fn engineering_huge_distance_falls_back_to_decimal() {
assert_eq!(format_distance(1e30, 3, 2), format!("{:.2}", 1e30));
assert_eq!(format_distance(-1e300, 3, 8), format!("{:.8}", -1e300));
}
#[test]
fn mixed_distance_needs_a_positive_fraction_after_the_whole_number() {
assert_eq!(parse_distance("6 1/2"), Some(6.5));
@@ -507,4 +534,11 @@ mod tests {
assert_eq!(format_angle(angle, 4, 0), "S 74d E");
assert_eq!(format_angle(angle, 4, 6), "S 73d41'51.00\" E");
}
#[test]
fn angle_radians_low_precision_not_wrapped() {
assert_eq!(format_angle(6.2, 3, 0), "6r");
assert_eq!(format_angle(6.26, 3, 1), "6.3r");
assert_eq!(format_angle(0.0, 3, 1), "0.0r");
}
}
+38
View File
@@ -322,3 +322,41 @@ fn hooks() {
plot: None,
});
}
#[test]
fn a_block_editor_that_cannot_be_composed_is_not_autosaved_as_the_old_host() {
use cadcraft_engine::doc::{Entity, EntityKind, Insert};
use cadcraft_engine::geom::Vec3;
let mut s = Session::new();
let l = s.execute("line", &json!({ "points": [[10, 10], [12, 10]] })).unwrap()["handles"][0].clone();
s.execute("block", &json!({ "name": "Door", "base": [10, 10], "handles": [l] })).unwrap();
s.execute("bedit", &json!({ "name": "Door" })).unwrap();
// An unsaved change in the editor: the block would contain a reference to itself.
let d = s.doc_mut().unwrap();
let h = d.new_handle();
let kind = EntityKind::Insert(Insert {
block: "Door".into(),
insert: Vec3::ZERO,
scale: Vec3::new(1.0, 1.0, 1.0),
rotation: 0.0,
attribs: vec![],
cols: 1,
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
});
d.model.push(Entity::new(h, kind));
assert!(s.state().unwrap().recovery_drawing().is_err());
let mut a = Autosaver::new("55-b");
a.plan(&s, 0.0, 1, "/auto");
assert!(a.plan(&s, 60.0, 1, "/auto").is_empty(), "no recovery file without the editor's changes");
assert!(a.plan(&s, 120.0, 1, "/auto").is_empty());
assert_eq!(a.next_due(120.0, 1), Some(0.0), "still waiting to be written");
// Once the reference is gone, the next autosave writes the editor's changes.
let d = s.doc_mut().unwrap();
d.model.remove(h);
assert_eq!(writes(&a.plan(&s, 180.0, 1, "/auto")).len(), 1);
}
+1 -1
View File
@@ -217,7 +217,7 @@ fn a_file_save_writes_the_drawing_outside_the_editor() {
s.execute("bedit", &json!({ "name": "Door" })).unwrap();
line(&mut s, [0.0, 0.0], [0.0, 3.0]);
// Autosave keeps the editor's unsaved change in the block.
let rec = s.state().unwrap().recovery_drawing();
let rec = s.state().unwrap().recovery_drawing().unwrap();
assert_eq!(rec.block("Door").unwrap().entities.len(), 2);
assert_eq!(rec.model.len(), 2);
+2
View File
@@ -86,8 +86,10 @@ const GRAY: Color = Color::Index(8);
fn exports_both_drawings_as_colored_groups_in_a_new_drawing() {
let mut s = compared();
let before = s.docs.len();
let before_epoch = s.doc_epoch;
let source = s.doc().unwrap().clone();
let r = s.execute("compareexport", &json!({})).unwrap();
assert!(s.doc_epoch > before_epoch, "opening the exported drawing invalidates queued captures");
assert_eq!(s.docs.len(), before + 1, "{r}");
assert_eq!(r["title"], "compare_B vs Drawing1.dwg", "{r}");
assert_eq!(s.state().unwrap().title, "compare_B vs Drawing1.dwg");
+48
View File
@@ -43,3 +43,51 @@ fn dragging_the_grip_of_a_fixed_end_leaves_it_in_place() {
assert!(start.near(Vec2::new(0.0, 0.0), 1e-6), "{start:?}");
assert!(end.near(Vec2::new(10.0, 5.0), 1e-6), "{end:?}");
}
#[test]
fn a_fixed_midpoint_stays_in_place_when_an_end_grip_is_dragged() {
let mut s = Session::new();
let a = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
s.execute("gcfix", &json!({ "h1": a.hex(), "p1": "mid" })).unwrap();
grip_drag(&mut s, a, Vec2::new(10.0, 0.0), "10,5");
let (start, end) = ends(&s, a);
assert!(start.mid(end).near(Vec2::new(5.0, 0.0), 1e-6), "the fixed midpoint moved to {:?} ({start:?}, {end:?})", start.mid(end));
// The dragged end lands where it was put; the line turns about its fixed midpoint.
assert!(end.near(Vec2::new(10.0, 5.0), 1e-6), "{end:?}");
assert!(start.near(Vec2::new(0.0, -5.0), 1e-6), "{start:?}");
}
#[test]
fn moving_an_arc_with_a_fixed_end_leaves_it_whole_and_in_place() {
let mut s = Session::new();
let r = s.execute("arc", &json!({ "center": [0, 0], "radius": 5, "start": 0, "end": 90 })).unwrap();
let a = Handle::parse_hex(r["handle"].as_str().unwrap()).unwrap();
s.execute("gcfix", &json!({ "h1": a.hex(), "p1": "end" })).unwrap();
s.execute("move", &json!({ "handles": [a.hex()], "delta": [1, 1] })).unwrap();
match &s.doc().unwrap().entity(a).unwrap().kind {
EntityKind::Arc(c) => {
assert!(c.center.xy().near(Vec2::new(0.0, 0.0), 1e-6), "{:?}", c.center);
assert!((c.radius - 5.0).abs() < 1e-6, "radius {}", c.radius);
assert!((c.end - std::f64::consts::FRAC_PI_2).abs() < 1e-6, "end at {}", c.end);
}
k => panic!("not an arc: {k:?}"),
}
}
#[test]
fn dragging_an_end_of_one_of_two_parallel_lines_leaves_its_other_end_in_place() {
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, 2.0], [10.0, 2.0]);
s.execute("gcparallel", &json!({ "h1": a.hex(), "h2": b.hex() })).unwrap();
grip_drag(&mut s, a, Vec2::new(10.0, 0.0), "10,5");
let (start, end) = ends(&s, a);
assert!(end.near(Vec2::new(10.0, 5.0), 1e-6), "{end:?}");
assert!(start.near(Vec2::new(0.0, 0.0), 1e-3), "the end that was not dragged moved to {start:?}");
// The other line took up the change.
let (b0, b1) = ends(&s, b);
assert!((b1 - b0).cross(end - start).abs() < 1e-6, "{b0:?} {b1:?}");
}
+51 -6
View File
@@ -1,5 +1,6 @@
//! `setvar CECOLOR` takes what the `color` command takes: "ByLayer", "ByBlock", a colour name or an
//! index (#1999).
//! index (#1999). `getvar CECOLOR` reads it back as AutoCAD does, as text: "BYLAYER", "BYBLOCK",
//! the index ("1" for red) or "RGB:r,g,b".
use cadcraft_engine::Session;
use cadcraft_engine::doc::color::{Color, Rgb};
@@ -14,22 +15,66 @@ fn new_line_color(s: &mut Session) -> Color {
s.doc().unwrap().model.last().unwrap().common.color
}
#[test]
fn getvar_cecolor_of_a_new_drawing_is_bylayer() {
let mut s = Session::new();
assert_eq!(cecolor(&mut s), json!("BYLAYER"));
}
#[test]
fn setvar_cecolor_accepts_names() {
for (value, aci) in [("BYLAYER", 256), ("ByLayer", 256), ("BYBLOCK", 0), ("red", 1), ("250", 250)] {
for (value, shown) in [
("BYLAYER", "BYLAYER"),
("ByLayer", "BYLAYER"),
("256", "BYLAYER"),
("BYBLOCK", "BYBLOCK"),
("0", "BYBLOCK"),
("red", "1"),
("7", "7"),
("250", "250"),
("RGB:10,20,30", "RGB:10,20,30"),
("10,20,30", "RGB:10,20,30"),
] {
let mut s = Session::new();
s.execute("setvar", &json!({ "name": "CECOLOR", "value": value })).unwrap_or_else(|e| panic!("{value}: {e}"));
assert_eq!(cecolor(&mut s), json!(aci), "{value}");
assert_eq!(cecolor(&mut s), json!(shown), "{value}");
}
}
#[test]
fn getvar_cecolor_after_the_color_command() {
let mut s = Session::new();
s.execute("color", &json!({ "color": "ByBlock" })).unwrap();
assert_eq!(cecolor(&mut s), json!("BYBLOCK"));
s.execute("color", &json!({ "color": "10,20,30" })).unwrap();
assert_eq!(cecolor(&mut s), json!("RGB:10,20,30"));
}
/// The value `getvar` gives back is one `setvar` takes, so saving and restoring keeps a true colour.
#[test]
fn getvar_cecolor_restores_a_true_colour() {
let mut s = Session::new();
s.execute("color", &json!({ "color": "10,20,30" })).unwrap();
let saved = cecolor(&mut s);
s.execute("setvar", &json!({ "name": "CECOLOR", "value": "250" })).unwrap();
s.execute("setvar", &json!({ "name": "CECOLOR", "value": saved })).unwrap();
assert_eq!(new_line_color(&mut s), Color::True(Rgb(10, 20, 30)));
}
#[test]
fn setvar_cecolor_lists_cecolor_as_text() {
let mut s = Session::new();
s.execute("setvar", &json!({ "name": "CECOLOR", "value": "red" })).unwrap();
assert_eq!(cadcraft_engine::sysvars::list(&s)["CECOLOR"], json!("1"));
}
#[test]
fn setvar_cecolor_name_replaces_a_true_colour() {
let mut s = Session::new();
s.execute("color", &json!({ "color": "10,20,30" })).unwrap();
assert_eq!(new_line_color(&mut s), Color::True(Rgb(10, 20, 30)));
s.execute("setvar", &json!({ "name": "CECOLOR", "value": "ByLayer" })).unwrap();
assert_eq!(cecolor(&mut s), json!(256));
assert_eq!(cecolor(&mut s), json!("BYLAYER"));
assert_eq!(new_line_color(&mut s), Color::ByLayer);
}
@@ -44,8 +89,8 @@ fn setvar_cecolor_number_replaces_a_true_colour() {
#[test]
fn setvar_cecolor_refuses_what_is_not_a_colour() {
let mut s = Session::new();
for bad in [json!("chartreuse"), json!(257), json!(-1), json!(true)] {
for bad in [json!("chartreuse"), json!(257), json!(-1), json!(true), json!("RGB:"), json!("RGB:300,0,0")] {
assert!(s.execute("setvar", &json!({ "name": "CECOLOR", "value": bad })).is_err(), "{bad}");
}
assert_eq!(cecolor(&mut s), json!(256));
assert_eq!(cecolor(&mut s), json!("BYLAYER"));
}
+49
View File
@@ -6,6 +6,8 @@ use cadcraft_doc::{Drawing, Space};
use cadcraft_engine::Session;
use cadcraft_engine::cmd::file::{IoHooks, base64_decode, set_io};
use cadcraft_engine::cmd::plotters::{PlotterStore, set_plotter_store};
use cadcraft_engine::snap::tracking::CursorQuery;
use cadcraft_engine::{Input, geom::Vec2};
use serde_json::{Value, json};
/// The plot hook echoes the options it gets.
@@ -13,6 +15,7 @@ fn echo_plot(_: &Drawing, _: &Space, opts: &Value) -> Result<Vec<u8>, String> {
Ok(opts.to_string().into_bytes())
}
/// A session with in-memory plotters and I/O hooks, holding one line so layouts have content.
fn session() -> Session {
set_plotter_store(PlotterStore::memory());
set_io(IoHooks { read: |_, _| Err("no reader in this test".into()), write: |_, _| Err("no writer in this test".into()), plot: Some(echo_plot) });
@@ -22,6 +25,7 @@ fn session() -> Session {
s
}
/// The device names the PLOTTERMANAGER report lists, in the order it lists them.
fn names(s: &mut Session) -> Vec<String> {
let r = s.execute("plottermanager", &json!({})).unwrap();
r["devices"].as_array().unwrap().iter().map(|d| d["name"].as_str().unwrap().to_string()).collect()
@@ -34,6 +38,8 @@ fn plotted(r: Value) -> Value {
}
#[test]
/// PLOTTERMANAGER creates, edits, saves, loads and deletes user devices; built-ins are fixed,
/// duplicate names and papers with margins larger than the page are refused.
fn manage_devices() {
let mut s = session();
let n = names(&mut s);
@@ -73,6 +79,8 @@ fn manage_devices() {
}
#[test]
/// PAGESETUP copies the device's papers and margins into the layout's page, keeps a paper the new
/// device also has, and falls back to the device default when it does not.
fn page_setup_takes_a_device_and_its_papers() {
let mut s = session();
s.execute("plottermanager", &json!({"action": "new", "name": "Plan Printer", "output": "pdf"})).unwrap();
@@ -110,6 +118,8 @@ fn page_setup_takes_a_device_and_its_papers() {
}
#[test]
/// PLOT and EXPORTPDF hand the hook the device's output, resolution, paper and margins, and a
/// layout plots with its own page setup; unknown devices and papers are refused.
fn plot_uses_the_device() {
let mut s = session();
s.execute("plottermanager", &json!({"action": "new", "name": "Sketch PNG", "output": "png", "resolution": 96})).unwrap();
@@ -136,6 +146,7 @@ fn plot_uses_the_device() {
}
#[test]
/// The typed -PLOTTERMANAGER prompt machine creates a device, adds a paper to it and deletes it.
fn typed_plotter_manager() {
let mut s = session();
for line in ["-plottermanager", "New", "Typed Plotter", "Svg"] {
@@ -155,6 +166,8 @@ fn typed_plotter_manager() {
}
#[test]
/// Hostile names and values (empty or path-like names, unknown output, huge resolution, negative
/// or enormous paper sizes, oversized loads) are refused instead of panicking.
fn hostile_names_and_values_are_refused() {
let mut s = session();
let r = s.execute("plottermanager", &json!({"action": "new", "name": "../../etc/passwd"})).unwrap();
@@ -174,6 +187,8 @@ fn hostile_names_and_values_are_refused() {
}
#[test]
/// PLOTWINDOW keeps both picked corners for the plot dialog, previews the rubber-band rectangle
/// after the first pick, and refuses a window with no area (JSON form included).
fn plotwindow_keeps_the_picked_window_for_the_plot_dialog() {
let mut s = Session::new();
// Typed: two corners, with the window's rectangle following the cursor after the first.
@@ -190,3 +205,37 @@ fn plotwindow_keeps_the_picked_window_for_the_plot_dialog() {
assert_eq!(r["window"], json!([[0.0, 0.0], [3.0, 4.0]]));
assert!(s.execute("plotwindow", &json!({"p1": [0, 0], "p2": [3, 0]})).is_err());
}
/// Snap `raw` at the current prompt the way the canvas does, so a test sees the pick ORTHO and
/// object snap would really produce.
fn hover(s: &mut Session, raw: Vec2) -> Vec2 {
let p = s.current_prompt().expect("a prompt");
let q = CursorQuery { base: p.base, deferred: p.deferred, corner: p.corner, aperture: 1e-6, tol: 1e-6, now: 0.0, shift: false };
s.snap_cursor(raw, &q).point
}
#[test]
/// Regression (#1641): with ORTHO on, PLOTWINDOW's opposite corner stays where the cursor is so
/// the window keeps both extents, while an ordinary point prompt still follows ortho.
fn plotwindow_picks_both_corners_with_ortho_on() {
let mut s = Session::new();
s.execute("setvar", &json!({"name": "OSMODE", "value": 0})).unwrap();
s.execute("setvar", &json!({"name": "ORTHOMODE", "value": 1})).unwrap();
s.cmdline("plotwindow").unwrap();
assert_eq!(s.current_prompt().unwrap().message, "Specify first corner");
let a = hover(&mut s, Vec2::new(10.0, 20.0));
s.input(Input::Point(a)).unwrap();
assert_eq!(s.current_prompt().unwrap().message, "Specify opposite corner");
let c = hover(&mut s, Vec2::new(40.0, 5.0));
assert!(c.near(Vec2::new(40.0, 5.0), 1e-9), "the opposite corner is where the cursor is: {c:?}");
s.input(Input::Point(c)).unwrap();
assert!(s.running.is_none());
let w = s.plot_window.expect("the picked window");
assert!(w.width() > 0.0 && w.height() > 0.0, "the window has both extents: {w:?}");
assert_eq!((w.min.x, w.min.y, w.max.x, w.max.y), (10.0, 5.0, 40.0, 20.0));
// An ordinary point prompt still follows ortho: a line's next point stays on its axis.
s.cmdline("line 0,0").unwrap();
let c = hover(&mut s, Vec2::new(10.0, 4.0));
assert!(c.near(Vec2::new(10.0, 0.0), 1e-9), "ortho keeps the line horizontal: {c:?}");
}
@@ -0,0 +1,40 @@
//! A malformed `handle` on a table edit command or TABLEEXPORT is an error. It never falls back
//! to the selected table (#934, follow-up to #330).
use cadcraft_engine::Session;
use cadcraft_engine::doc::{EntityKind, Handle};
use serde_json::json;
fn cell_text(s: &Session, h: &str) -> String {
let h = Handle::parse_hex(h).unwrap();
match &s.doc().unwrap().entity(h).unwrap().kind {
EntityKind::Table(t) => t.cells[0][0].text.clone(),
_ => String::new(),
}
}
#[test]
fn malformed_table_handle_does_not_edit_the_selected_table() {
let mut s = Session::new();
let h = s.execute("table", &json!({ "at": [0, 0], "cells": [["a", "b"]], "rowHeight": 1.0, "colWidth": 3.0 })).unwrap()["handle"]
.as_str()
.unwrap()
.to_string();
s.set_selection(vec![Handle::parse_hex(&h).unwrap()]);
for bad in [json!("zzz"), json!(""), json!("#1"), json!(-1), json!(true), json!([1])] {
let e = s.execute("table.set", &json!({ "handle": bad, "row": 0, "col": 0, "text": "X" })).expect_err(&format!("handle {bad}"));
assert!(e.to_string().contains("is not an object handle"), "handle {bad}: {e}");
assert_eq!(cell_text(&s, &h), "a", "handle {bad} must change nothing");
// TABLEEXPORT resolves its table the same way: no CSV of the selected table either.
let e = s.execute("tableexport", &json!({ "handle": bad })).expect_err(&format!("tableexport handle {bad}"));
assert!(e.to_string().contains("is not an object handle"), "tableexport handle {bad}: {e}");
}
// An explicit valid handle and an omitted handle (selection) still work.
s.execute("table.set", &json!({ "handle": h, "row": 0, "col": 0, "text": "Y" })).unwrap();
assert_eq!(cell_text(&s, &h), "Y");
s.execute("table.set", &json!({ "row": 0, "col": 0, "text": "Z" })).unwrap();
assert_eq!(cell_text(&s, &h), "Z");
assert_eq!(s.execute("tableexport", &json!({})).unwrap()["csv"], "Z,b\r\n");
}
+58 -7
View File
@@ -8,7 +8,6 @@
use std::ops::Range;
use std::panic::{AssertUnwindSafe, catch_unwind};
use std::sync::Arc;
use cadcraft_geom::Vec2;
@@ -109,7 +108,7 @@ fn shape_chars(primary: Option<&Face>, chars: &[(char, bool, bool)], height: f64
for p in contours.iter_mut().flatten() {
*p += Vec2::new(dy * shear, dy);
}
out.glyphs.push(contours);
out.push_glyph(contours, None);
}
let adv = g.advance * k;
let (u, o) = flags(g.cluster);
@@ -183,9 +182,9 @@ struct Glyph {
/// Shape characters `r` of `cs` (one direction, one face) into glyphs in display order.
fn shape_run(face: &Face, cs: &[char], r: Range<usize>, rtl: bool) -> Vec<Glyph> {
let run = cs.get(r.clone()).unwrap_or_default();
// Shares the face's bytes instead of copying them.
let bytes: Arc<dyn AsRef<[u8]> + Send + Sync> = face.bytes.clone();
let Some(font) = harfrust::Font::new(bytes, face.index) else { return Vec::new() };
// Keep the shared font bytes: shaping a run must not clone the entire font file.
let source = harfrust::font::Blob::Shared(face.bytes.clone());
let Some(font) = harfrust::Font::new(source, face.index) else { return Vec::new() };
let shaper = harfrust::ShaperFont::new(&font);
let mut buf = harfrust::Buffer::new();
let arabic = run.iter().any(|c| matches!(*c, '\u{0600}'..='\u{08FF}'));
@@ -200,11 +199,10 @@ fn shape_run(face: &Face, cs: &[char], r: Range<usize>, rtl: bool) -> Vec<Glyph>
}
buf.set_direction(if rtl { harfrust::Direction::RightToLeft } else { harfrust::Direction::LeftToRight });
buf.guess_segment_properties();
// Without a plan, shaping fails only on an already shaped buffer or an unset direction.
if harfrust::shape(&shaper, &mut buf, harfrust::ShapeOptions::new()).is_err() {
return Vec::new();
}
let k = 1000.0 / f64::from(font.units_per_em().max(1));
let k = 1000.0 / f64::from(shaper.units_per_em().max(1));
buf.glyph_infos()
.iter()
.zip(buf.glyph_positions())
@@ -222,6 +220,59 @@ fn shape_run(face: &Face, cs: &[char], r: Range<usize>, rtl: bool) -> Vec<Glyph>
mod tests {
use super::*;
/// A single empty glyph with a positive advance, without relying on installed fonts.
fn empty_glyph_face() -> Face {
let mut head = vec![0_u8; 54];
head[..4].copy_from_slice(&0x0001_0000_u32.to_be_bytes());
head[18..20].copy_from_slice(&1000_u16.to_be_bytes());
let mut hhea = vec![0_u8; 36];
hhea[..4].copy_from_slice(&0x0001_0000_u32.to_be_bytes());
hhea[4..6].copy_from_slice(&800_i16.to_be_bytes());
hhea[6..8].copy_from_slice(&(-200_i16).to_be_bytes());
hhea[34..36].copy_from_slice(&1_u16.to_be_bytes());
let tables = [
(*b"head", head),
(*b"hhea", hhea),
(*b"hmtx", vec![1, 244, 0, 0]), // Advance 500, left side bearing 0.
(*b"maxp", vec![0, 0, 80, 0, 0, 1]), // Version 0.5, one glyph.
];
let mut bytes = vec![0_u8; 12 + tables.len() * 16];
bytes[..4].copy_from_slice(&0x0001_0000_u32.to_be_bytes());
bytes[4..6].copy_from_slice(&4_u16.to_be_bytes());
bytes[6..8].copy_from_slice(&64_u16.to_be_bytes());
bytes[8..10].copy_from_slice(&2_u16.to_be_bytes());
for (i, (tag, data)) in tables.into_iter().enumerate() {
while !bytes.len().is_multiple_of(4) {
bytes.push(0);
}
let record = 12 + i * 16;
let offset = u32::try_from(bytes.len()).unwrap();
let len = u32::try_from(data.len()).unwrap();
bytes[record..record + 4].copy_from_slice(&tag);
bytes[record + 8..record + 12].copy_from_slice(&offset.to_be_bytes());
bytes[record + 12..record + 16].copy_from_slice(&len.to_be_bytes());
bytes.extend(data);
}
Face::new(std::sync::Arc::new(bytes), 0).expect("synthetic font parses")
}
/// Empty complex glyphs retain spacing and underlines without adding geometry or text slots.
#[test]
fn empty_complex_glyph_keeps_advance_without_geometry() {
let face = empty_glyph_face();
let shaped = shape(Some(&face), " ", 1.0, 1.0, 0.0).expect("shapes empty glyph");
assert!(shaped.width > 0.0, "the font's advance is retained");
assert!(shaped.is_empty());
assert!(shaped.glyphs.is_empty());
assert!(shaped.text.is_empty());
let underlined = shape(Some(&face), "%%u ", 1.0, 1.0, 0.0).expect("shapes decorated empty glyph");
assert_eq!(underlined.width, shaped.width);
assert!(underlined.glyphs.is_empty());
assert!(underlined.text.is_empty());
assert!(!underlined.strokes.is_empty(), "decoration spans survive an empty outline");
}
#[test]
fn stroke_font_rtl_lines_keep_latin_and_digits_in_the_stroke_font() {
// With or without installed fonts: the Persian words are fallback glyphs or
+3 -1
View File
@@ -569,7 +569,9 @@ impl Polyline {
let (Some(a), Some(b)) = (self.vertices.get(i), self.vertices.get((i + 1) % n)) else { continue };
let (pa, pb) = (a.p - origin, b.p - origin);
add(pa.cross(pb) / 2.0, (pa + pb) * (1.0 / 3.0));
if let Some((arc, ccw)) = bulge_to_arc(a.p, b.p, a.bulge) {
if (i + 1 < n || self.closed)
&& let Some((arc, ccw)) = bulge_to_arc(a.p, b.p, a.bulge)
{
let t = arc.sweep();
let seg = arc.radius * arc.radius * (t - t.sin()) / 2.0;
if seg > 0.0 {
+15 -7
View File
@@ -62,22 +62,29 @@ pub fn circle_circle(c1: &Circle, c2: &Circle) -> Vec<Vec2> {
// Work at the scale of the two circles: fixed absolute tolerances classify
// small secants as tangencies, and raw radius squares overflow on large drawings.
// Measure the chord from the smaller circle: from the larger one the half-chord is a
// difference of nearly equal numbers, so a tiny circle crossing a huge one is lost.
let swapped = r2 < r1;
let (from, dir) = if swapped { (c2.center, -(delta / d)) } else { (c1.center, delta / d) };
let scale = d.max(r1).max(r2);
let (ds, r1s, r2s) = (d / scale, r1 / scale, r2 / scale);
if ds > r1s + r2s || ds < (r1s - r2s).abs() {
return Vec::new();
}
let a = ((r1s - r2s) * (r1s + r2s) + ds * ds) / (2.0 * ds);
let h2 = (r1s - a) * (r1s + a);
if h2 < -1e-12 {
let (rs, ro) = if swapped { (r2s, r1s) } else { (r1s, r2s) };
let a = ((rs - ro) * (rs + ro) + ds * ds) / (2.0 * ds);
let h2 = (rs - a) * (rs + a);
// Tangency is judged against the smaller circle, which sets the size of the chord, with
// room for the rounding in `a` (a few ulps of the unit scale over ds, times 2a in h2).
let tol = (1e-12 * rs * rs).max(8.0 * f64::EPSILON * rs / ds);
if h2 < -tol {
return Vec::new();
}
let dir = delta / d;
let p = c1.center + dir * (a * scale);
let p = from + dir * (a * scale);
if !p.is_finite() {
return Vec::new();
}
if h2 <= 1e-12 {
if h2 <= tol {
return vec![p];
}
let step = dir.perp() * (h2.sqrt() * scale);
@@ -85,7 +92,8 @@ pub fn circle_circle(c1: &Circle, c2: &Circle) -> Vec<Vec2> {
if !p1.is_finite() || !p2.is_finite() {
return Vec::new();
}
vec![p1, p2]
// Keep the result order independent of which circle was used as the base.
if swapped { vec![p2, p1] } else { vec![p1, p2] }
}
fn on_arc(a: &Arc, p: Vec2) -> bool {
+3 -1
View File
@@ -4,7 +4,9 @@
//! runs a few thousand cheap cases; the known-answer part of the suite works at 1e6–1e7
//! coordinates, where double precision starts to show (geometry-parity.md §Tests).
use crate::*;
use crate::{
Arc, Bounds2, Circle, Line, Segment, Vec2, circle_circle, intersect_ext, intersect_segments, line_circle, line_line, line_line_infinite,
};
// ---------- generator ----------
+44 -17
View File
@@ -60,6 +60,21 @@ struct Graph {
edge_src: HashMap<(usize, usize), usize>,
}
/// Records the crossing of segments `i` and `j`, if any, as split parameters on both.
fn split_pair(segs: &[(Vec2, Vec2)], splits: &mut [Vec<f64>], i: usize, j: usize) {
let (Some(si), Some(sj)) = (segs.get(i), segs.get(j)) else { return };
let li = Line::new(si.0, si.1);
let lj = Line::new(sj.0, sj.1);
if let Some(x) = line_line(&li, &lj) {
if let Some(v) = splits.get_mut(i) {
v.push(li.param_of(x).clamp(0.0, 1.0));
}
if let Some(v) = splits.get_mut(j) {
v.push(lj.param_of(x).clamp(0.0, 1.0));
}
}
}
fn build(polys: &[Vec<Vec2>], tol: f64) -> Option<Graph> {
let mut segs: Vec<(Vec2, Vec2)> = Vec::new();
let mut owner: Vec<usize> = Vec::new();
@@ -94,16 +109,13 @@ fn build(polys: &[Vec<Vec2>], tol: f64) -> Option<Graph> {
)
};
let mut grid: HashMap<(usize, usize), Vec<usize>> = HashMap::new();
let mut long: Vec<usize> = Vec::new();
for (i, (a, b)) in segs.iter().enumerate() {
let (x0, y0) = cell(a.min(*b));
let (x1, y1) = cell(a.max(*b));
if (x1 - x0 + 1) * (y1 - y0 + 1) > 4096 {
// Very long segment: register in a coarse band.
for gx in (x0..=x1).step_by(((x1 - x0) / 64).max(1)) {
for gy in (y0..=y1).step_by(((y1 - y0) / 64).max(1)) {
grid.entry((gx, gy)).or_default().push(i);
}
}
// Very long segment: too many cells to register in; it is tested against every segment below.
long.push(i);
continue;
}
for gx in x0..=x1 {
@@ -130,23 +142,24 @@ fn build(polys: &[Vec<Vec2>], tol: f64) -> Option<Graph> {
if i == j || !tested.insert((i.min(j), i.max(j))) {
continue;
}
let (Some(si), Some(sj)) = (segs.get(i), segs.get(j)) else { continue };
let li = Line::new(si.0, si.1);
let lj = Line::new(sj.0, sj.1);
if let Some(x) = line_line(&li, &lj) {
if let Some(v) = splits.get_mut(i) {
v.push(li.param_of(x).clamp(0.0, 1.0));
}
if let Some(v) = splits.get_mut(j) {
v.push(lj.param_of(x).clamp(0.0, 1.0));
}
}
split_pair(&segs, &mut splits, i, j);
if tested.len() > 20_000_000 {
return None;
}
}
}
}
for &i in &long {
for j in 0..n {
if i == j || !tested.insert((i.min(j), i.max(j))) {
continue;
}
split_pair(&segs, &mut splits, i, j);
if tested.len() > 20_000_000 {
return None;
}
}
}
let mut g = Graph { pts: Vec::new(), adj: Vec::new(), src: Vec::with_capacity(n), edge_src: HashMap::new() };
let mut index: HashMap<(i64, i64), usize> = HashMap::new();
let mut vid = |p: Vec2, g: &mut Graph| -> usize {
@@ -609,6 +622,20 @@ mod tests {
vec![Vec2::new(x0, y0), Vec2::new(x0 + s, y0), Vec2::new(x0 + s, y0 + s), Vec2::new(x0, y0 + s), Vec2::new(x0, y0)]
}
#[test]
fn long_segment_crossing_in_unsampled_cell_is_split() {
// A diagonal long enough to skip the grid, and a short segment crossing it in a cell the old coarse sampling missed.
let mut polys = vec![vec![Vec2::new(0.0, 0.0), Vec2::new(1000.0, 1000.0)], vec![Vec2::new(11.0, 12.0), Vec2::new(12.0, 11.0)]];
for i in 0..17_000usize {
let x = 600.0 + (i % 130) as f64 * 2.0;
let y = 100.0 + (i / 130) as f64 * 2.0;
polys.push(vec![Vec2::new(x, y), Vec2::new(x + 1.0, y)]);
}
let g = build(&polys, 1e-6).unwrap();
let hit = g.pts.iter().position(|p| p.near(Vec2::new(11.5, 11.5), 1e-6)).unwrap();
assert_eq!(g.adj[hit].len(), 4);
}
#[test]
fn finds_square_around_point() {
let l = enclosing_loop(&[square(0.0, 0.0, 10.0)], Vec2::new(5.0, 5.0), 1e-9).unwrap();
+33
View File
@@ -119,6 +119,29 @@ fn circle_circle_hits() {
assert!(h.iter().all(|p| close(p.x, 4.0) && close(p.y.abs(), 3.0)));
}
#[test]
fn small_circle_crossing_a_huge_circle_gives_two_hits() {
// Radii 1e7 and 1 with centres 1e7 apart: the small circle crosses the large one.
let big = Circle::new(Vec2::ZERO, 1e7);
let small = Circle::new(Vec2::new(1e7, 0.0), 1.0);
for hits in [circle_circle(&big, &small), circle_circle(&small, &big)] {
assert_eq!(hits.len(), 2, "{hits:?}");
for p in &hits {
assert!((p.dist(small.center) - 1.0).abs() < 1e-6, "{p:?} is off the small circle");
assert!((p.dist(big.center) - 1e7).abs() < 1e-6, "{p:?} is off the big circle");
}
assert!(hits[0].dist(hits[1]) > 1.0, "{hits:?}");
}
// Touching from outside and from inside is still one tangent point, in both orders.
for (x, touch) in [(1e7 + 1.0, 1e7), (1e7 - 1.0, 1e7)] {
let small = Circle::new(Vec2::new(x, 0.0), 1.0);
for hits in [circle_circle(&big, &small), circle_circle(&small, &big)] {
assert_eq!(hits.len(), 1, "{hits:?}");
assert!(hits[0].dist(Vec2::new(touch, 0.0)) < 1e-6, "{hits:?}");
}
}
}
#[test]
fn tiny_circle_secants_and_separated_circles() {
let first = Circle::new(Vec2::ZERO, 1e-9);
@@ -331,3 +354,13 @@ fn long_secant_keeps_both_intersections() {
assert!(hits[0].0.near(Vec2::new(-2.0, 0.0), 1e-8), "{hits:?}");
assert!(hits[1].0.near(Vec2::new(2.0, 0.0), 1e-8), "{hits:?}");
}
#[test]
fn open_polyline_centroid_ignores_last_bulge() {
let pts = [Vec2::new(0.0, 0.0), Vec2::new(10.0, 0.0), Vec2::new(10.0, 10.0)];
let mut pl = Polyline::from_points(&pts, false);
let before = pl.centroid().unwrap();
pl.vertices[2].bulge = 1.0;
let after = pl.centroid().unwrap();
assert!(before.near(after, 1e-9), "{before:?} -> {after:?}");
}
+7 -1
View File
@@ -734,7 +734,7 @@ fn hatch(tags: &[Tag], t: &T) -> Option<EntityKind> {
while let Some(x) = get(i).filter(|x| matches!(x.code, 94 | 73 | 74 | 95 | 96)) {
let n = x.i64().clamp(0, 100_000) as usize;
match x.code {
94 => degree = n,
94 => degree = n.clamp(1, 10), // same cap as the SPLINE reader
95 => nk = n,
96 => nc = n,
_ => {}
@@ -1984,6 +1984,8 @@ struct Objects {
layout_views: Vec<(String, (Vec2, f64))>,
/// SORTENTSTABLE objects: (owning block record handle, entity handle → sort handle).
sortents: Vec<(String, HashMap<Handle, u64>)>,
/// WIPEOUTVARIABLES: the drawing's WIPEOUTFRAME (0 = frames hidden, 1 = shown).
wipeout_frame: Option<i64>,
}
const MAX_OBJECTS: usize = 1_000_000;
@@ -2022,6 +2024,7 @@ impl Objects {
"DIMASSOC" if self.dimassocs.len() < MAX_OBJECTS => self.dimassocs.push(tags.to_vec()),
"MLEADERSTYLE" if self.mleader_styles.len() < MAX_OBJECTS => self.mleader_styles.push((h, tags.to_vec())),
"GROUP" if self.groups.len() < MAX_OBJECTS => self.groups.push((h, tags.to_vec())),
"WIPEOUTVARIABLES" => self.wipeout_frame = T(tags).i(70).filter(|v| (0..=2).contains(v)),
"SORTENTSTABLE" if self.sortents.len() < MAX_OBJECTS => {
if let Some(t) = sortents_table(tags) {
self.sortents.push(t);
@@ -2052,6 +2055,9 @@ impl Objects {
}
fn apply(self, d: &mut Drawing, rx: &Rx) {
if let Some(v) = self.wipeout_frame {
d.header.set_i64("WIPEOUTFRAME", v);
}
if let Some(style) = self.xrecord_text(crate::dxf_ext::MLEADER_STYLE_KEY).filter(|s| !s.trim().is_empty()) {
d.header.set_str("CMLEADERSTYLE", &style);
}
+11 -11
View File
@@ -2373,7 +2373,7 @@ fn tags_for(d: &Drawing, version: Version, target: Version) -> Vec<Tag> {
// ---------------- BLOCKS ----------------
w.s(0, "SECTION");
w.s(2, "BLOCKS");
let block = |w: &mut W, name: &str, desc: &str, brh: &str, base: Vec3, flags: i64, ents: &mut dyn Iterator<Item = &Entity>, paper: bool| {
let block = |w: &mut W, (name, desc): (&str, &str), brh: &str, base: Vec3, flags: i64, ents: &mut dyn Iterator<Item = &Entity>, paper: bool| {
let bh = w.h();
w.s(0, "BLOCK");
w.s(5, bh);
@@ -2406,16 +2406,16 @@ fn tags_for(d: &Drawing, version: Version, target: Version) -> Vec<Tag> {
w.s(8, "0");
w.s(100, "AcDbBlockEnd");
};
block(&mut w, "*Model_Space", "", &ms_br, Vec3::ZERO, 0, &mut std::iter::empty(), false);
block(&mut w, ("*Model_Space", ""), &ms_br, Vec3::ZERO, 0, &mut std::iter::empty(), false);
for (i, (n, h, l)) in ps_brs.iter().enumerate() {
if i == 0 {
block(&mut w, n, "", h, Vec3::ZERO, 0, &mut std::iter::empty(), true);
block(&mut w, (n, ""), h, Vec3::ZERO, 0, &mut std::iter::empty(), true);
} else {
block(&mut w, n, "", h, Vec3::ZERO, 0, &mut sheet_first(l).into_iter(), true);
block(&mut w, (n, ""), h, Vec3::ZERO, 0, &mut sheet_first(l).into_iter(), true);
}
}
for (n, h, b) in &user_blocks {
block(&mut w, n, &b.description, h, b.base, if b.anonymous { 1 } else { 0 }, &mut b.entities.iter().map(|e| e.as_ref()), false);
block(&mut w, (n, &b.description), h, b.base, if b.anonymous { 1 } else { 0 }, &mut b.entities.iter().map(|e| e.as_ref()), false);
}
// Generated geometry entities (dimension, arrowhead and table blocks) need handles.
for (_, _, ents) in dim_defs.iter_mut().chain(arrow_defs.iter_mut()).chain(table_defs.iter_mut()) {
@@ -2425,13 +2425,13 @@ fn tags_for(d: &Drawing, version: Version, target: Version) -> Vec<Tag> {
}
}
for (n, h, ents) in &dim_defs {
block(&mut w, n, "", h, Vec3::ZERO, 1, &mut ents.iter(), false);
block(&mut w, (n, ""), h, Vec3::ZERO, 1, &mut ents.iter(), false);
}
for (n, h, ents) in &arrow_defs {
block(&mut w, n, "", h, Vec3::ZERO, 0, &mut ents.iter(), false);
block(&mut w, (n, ""), h, Vec3::ZERO, 0, &mut ents.iter(), false);
}
for (n, h, ents) in &table_defs {
block(&mut w, n, "", h, Vec3::ZERO, 1, &mut ents.iter(), false);
block(&mut w, (n, ""), h, Vec3::ZERO, 1, &mut ents.iter(), false);
}
w.s(0, "ENDSEC");
@@ -2612,7 +2612,7 @@ fn tags_for(d: &Drawing, version: Version, target: Version) -> Vec<Tag> {
w.s(3, &l.name);
w.s(350, layout_handles.get(i).cloned().unwrap_or_default());
}
let layout_obj = |w: &mut W, h: &str, name: &str, tab: i64, brh: &str, flags: i64, page: &PageSetup, view: Option<(Vec2, f64)>| {
let layout_obj = |w: &mut W, h: &str, (name, tab): (&str, i64), brh: &str, flags: i64, page: &PageSetup, view: Option<(Vec2, f64)>| {
// Plot scale: a standard scale when fitting or 1:1, otherwise custom (142 paper units
// per 143 drawing units).
let scale = if page.scale.is_finite() && page.scale > 0.0 { page.scale } else { 1.0 };
@@ -2697,10 +2697,10 @@ fn tags_for(d: &Drawing, version: Version, target: Version) -> Vec<Tag> {
// plot type 5, "Layout information" (DXF Reference, PLOTSETTINGS group 74), which means
// nothing for model space: another program's Plot dialog then shows no plot area or scale.
let model_page = PageSetup { plot_area: "extents".into(), scale_to_fit: true, ..PageSetup::default() };
layout_obj(&mut w, &model_layout, "Model", 0, &ms_br, 1, &model_page, None);
layout_obj(&mut w, &model_layout, ("Model", 0), &ms_br, 1, &model_page, None);
for (i, (_, brh, l)) in ps_brs.iter().enumerate() {
let h = layout_handles.get(i).cloned().unwrap_or_default();
layout_obj(&mut w, &h, &l.name, i64::from(l.tab_order.max(1)), brh, 1, &l.page, l.view);
layout_obj(&mut w, &h, (&l.name, i64::from(l.tab_order.max(1))), brh, 1, &l.page, l.view);
}
// Raster image definitions.
w.t.extend(crate::dxf_image::objects(&cx.images, &root_dict));
+24
View File
@@ -2139,6 +2139,30 @@ fn hatch_edges_keep_full_circles_splines_and_clockwise_ellipses() {
near(hatch_edge_extents(&ellipse(1)), [-5.0, 0.0, 5.0, 2.5]);
}
/// Issue #923: a HATCH spline edge caps its degree at 10 like the SPLINE reader does, both when the
/// knots are valid for the stated degree and when the reader falls back to the control polygon.
#[test]
fn hatch_spline_edge_caps_degree_like_spline() {
let control: Vec<Vec2> = (0..13).map(|i| Vec2::new(f64::from(i), f64::from(i % 2) * 10.0)).collect();
let points: String = control.iter().map(|c| format!("10\n{}\n20\n{}\n", c.x, c.y)).collect();
let close = "72\n1\n10\n12\n20\n0\n11\n0\n21\n0\n";
// The degree-10 curve over the same controls, which is what the capped edge must produce.
let want = cadcraft_geom::Spline::from_control(control, 10).tessellate(1e-3);
let (mut lo, mut hi) = (Vec2::new(f64::MAX, f64::MAX), Vec2::new(f64::MIN, f64::MIN));
for p in &want {
(lo, hi) = (Vec2::new(lo.x.min(p.x), lo.y.min(p.y)), Vec2::new(hi.x.max(p.x), hi.y.max(p.y)));
}
// Degree 12 with 13 controls: a valid knot vector has 26 knots; the second edge gives none.
let knots = format!("{}{}", "40\n0\n".repeat(13), "40\n1\n".repeat(13));
for (nk, knots) in [(26, knots.as_str()), (0, "")] {
let edge = format!("72\n4\n94\n12\n73\n0\n74\n0\n95\n{nk}\n96\n13\n{knots}{points}97\n0\n{close}");
let b = hatch_edge_extents(&edge);
for (got, want) in [(b.min.x, lo.x), (b.min.y, lo.y), (b.max.x, hi.x), (b.max.y, hi.y)] {
assert!((got - want).abs() < 1e-6, "extents {b:?}, want {lo:?} {hi:?} (knots {nk})");
}
}
}
#[test]
fn mirrored_text_extrusion_keeps_its_footprint() {
// TEXT at OCS (-10,5) under extrusion (0,0,-1) starts at WCS (10,5) and runs towards -x (with

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