Compare commits

..
Author SHA1 Message Date
Dylan M. Taylor ac62d32aea Format imports so the CI fmt gate passes 2026-10-25 18:13:04 -04:00
Dylan M. Taylor 466071ffbc refactor: replace wildcard imports with explicit ones (S2208, batch_03) 2026-10-25 18:13:04 -04:00
Dylan M. Taylor 86f3982171 refactor: replace wildcard imports with explicit ones (S2208, batch_03) 2026-10-25 18:13:04 -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
151 changed files with 6516 additions and 783 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()),
}
}
+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));
}
}
+23 -6
View File
@@ -327,7 +327,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 +1119,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 +1148,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 +1168,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}")),
}
}
+10
View File
@@ -362,6 +362,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 +377,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 +491,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 +499,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()
}
+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};
+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;
+2
View File
@@ -362,6 +362,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),
+19 -1
View File
@@ -431,7 +431,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 +443,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) {
+19 -6
View File
@@ -141,15 +141,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
@@ -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);
}
+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
+21
View File
@@ -0,0 +1,21 @@
//! The drawing's WIPEOUTVARIABLES object sets WIPEOUTFRAME: AutoCAD files with hidden wipeout
//! frames (group 70 = 0) must not show them.
fn dxf(frame: Option<i32>) -> String {
let mut s = String::from(" 0\nSECTION\n 2\nENTITIES\n 0\nENDSEC\n 0\nSECTION\n 2\nOBJECTS\n");
if let Some(f) = frame {
s.push_str(&format!(" 0\nWIPEOUTVARIABLES\n 5\nA1\n100\nAcDbWipeoutVariables\n 70\n{f}\n"));
}
s.push_str(" 0\nENDSEC\n 0\nEOF\n");
s
}
#[test]
fn wipeoutvariables_sets_the_frame_flag() {
let frame = |f| cadcraft_io::read_dxf(dxf(f).as_bytes()).unwrap().header.i64("WIPEOUTFRAME", 1);
assert_eq!(frame(Some(0)), 0, "frames hidden");
assert_eq!(frame(Some(1)), 1);
assert_eq!(frame(Some(2)), 2);
assert_eq!(frame(None), 1, "no object: the default shows frames");
assert_eq!(frame(Some(7)), 1, "an out-of-range flag is ignored");
}
+120 -17
View File
@@ -184,17 +184,20 @@ fn is_read_only(method: &str) -> bool {
)
}
impl Backend for Remote {
/// Send a control request with the token when available, refreshing it before a safe retry.
/// A mutation that may have reached the server is never resent automatically.
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
let id = self.next_id;
self.next_id += 1;
impl Remote {
/// Put the current token (if any) on `req`, replacing the one it carries.
fn set_token(&mut self, req: &mut Value) {
let token = self.token().map(str::to_string);
let mut req = json!({"id": id, "method": method, "params": params});
if let (Some(obj), Some(token)) = (req.as_object_mut(), token) {
obj.insert("token".into(), Value::String(token));
if let Some(obj) = req.as_object_mut() {
obj.remove("token");
if let Some(token) = token {
obj.insert("token".into(), Value::String(token));
}
}
}
/// Send `req` and parse the reply, refreshing the token before a safe retry after a transport
/// error. A mutation that may have reached the server is never resent automatically.
fn exchange(&mut self, method: &str, req: &mut Value) -> Result<Value, String> {
let reply = match self.roundtrip(&req.to_string()) {
Ok(r) => r,
Err((e, sent)) => {
@@ -208,12 +211,7 @@ impl Backend for Remote {
));
}
// A restarted server may have rotated its token before dropping the old socket.
if let Some(obj) = req.as_object_mut() {
obj.remove("token");
if let Some(token) = self.token() {
obj.insert("token".into(), Value::String(token.to_owned()));
}
}
self.set_token(req);
self.roundtrip(&req.to_string()).map_err(|(e, _)| {
self.token = None;
self.conn = None;
@@ -221,12 +219,37 @@ impl Backend for Remote {
})?
}
};
let v: Value = serde_json::from_str(&reply).map_err(|e| format!("bad reply: {e}"))?;
serde_json::from_str(&reply).map_err(|e| format!("bad reply: {e}"))
}
}
/// An `unauthorized` reply (the app also closes the connection).
fn unauthorized(v: &Value) -> bool {
v.get("error").and_then(Value::as_str).is_some_and(|e| e.starts_with("unauthorized"))
}
impl Backend for Remote {
/// Send a control request with the token when available. A refused token is read again and the
/// request resent once, only when the app says it did not run it.
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
let id = self.next_id;
self.next_id += 1;
let mut req = json!({"id": id, "method": method, "params": params});
self.set_token(&mut req);
let mut v = self.exchange(method, &mut req)?;
// The token is read before `--launch` opens the app, which then writes a new one (#2087); the
// file can also be left over from an earlier session. Once, so a wrong token can't loop.
if unauthorized(&v) && v.get("state").and_then(Value::as_str) == Some("not-run") {
self.token = None;
self.conn = None;
self.set_token(&mut req);
v = self.exchange(method, &mut req)?;
}
if v.get("ok").and_then(Value::as_bool) == Some(true) {
Ok(v.get("result").cloned().unwrap_or(Value::Null))
} else {
let e = v.get("error").and_then(Value::as_str).unwrap_or("error");
if e.starts_with("unauthorized") {
if unauthorized(&v) {
self.token = None;
self.conn = None;
}
@@ -405,6 +428,7 @@ impl Backend for Headless {
mod tests {
use super::*;
use std::net::TcpListener;
use std::sync::Arc;
/// A server that reads one request per connection and closes without replying must not see the request twice.
#[test]
@@ -479,6 +503,85 @@ mod tests {
assert_eq!(remote.call("ping", json!({})).unwrap(), json!("pong"));
server.join().unwrap();
}
/// Answer one request per connection like the app's control server: `reply` sees the request
/// and the connection number and says what to send back; the server closes after each reply.
/// Returns how many requests arrived, waiting a short while for each next connection.
fn fake_app(listener: TcpListener, reply: impl Fn(&Value, usize) -> Value + Send + 'static) -> std::thread::JoinHandle<usize> {
std::thread::spawn(move || {
listener.set_nonblocking(true).unwrap();
let mut seen = 0;
loop {
let start = std::time::Instant::now();
let conn = loop {
match listener.accept() {
Ok((conn, _)) => break Some(conn),
Err(_) if start.elapsed() < Duration::from_millis(500) => std::thread::sleep(Duration::from_millis(10)),
Err(_) => break None,
}
};
let Some(conn) = conn else { return seen };
conn.set_nonblocking(false).unwrap();
let mut line = String::new();
BufReader::new(conn.try_clone().unwrap()).read_line(&mut line).unwrap();
let mut out = conn;
writeln!(out, "{}", reply(&serde_json::from_str(&line).unwrap(), seen)).unwrap();
seen += 1;
}
})
}
/// `--launch` reads the token before it opens the app, so the first request carries the token
/// of an earlier session (or none); the app refuses it without running it, and the call is
/// resent once with the token the app has just written (#2087).
#[test]
fn unauthorized_not_run_is_resent_once_with_the_new_token() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let old = crate::control_token::issue(addr.port()).unwrap();
let fresh = Arc::new(std::sync::Mutex::new(String::new()));
let f = fresh.clone();
let server = fake_app(listener, move |req, n| {
if n == 0 {
assert_eq!(req["token"].as_str(), Some(old.as_str()));
// The app starts and writes its own session's token.
*f.lock().unwrap() = crate::control_token::issue(addr.port()).unwrap();
json!({"ok": false, "error": "unauthorized: missing or invalid control token", "state": "not-run"})
} else if req["token"].as_str() == Some(f.lock().unwrap().as_str()) {
json!({"ok": true, "result": "drawing"})
} else {
json!({"ok": false, "error": "unauthorized: missing or invalid control token", "state": "not-run"})
}
});
let mut remote = Remote::lazy(&addr.to_string(), false);
let first = remote.call("engine.execute", json!({"command": "circle", "params": {}}));
let requests = server.join().unwrap();
crate::control_token::revoke(addr.port());
assert_eq!(first, Ok(json!("drawing")));
assert_eq!(requests, 2);
assert_eq!(remote.token.as_deref(), Some(fresh.lock().unwrap().as_str()));
}
/// A token the app keeps refusing is resent once, not in a loop, and a refusal that doesn't
/// say the request never ran is not resent at all.
#[test]
fn unauthorized_is_resent_at_most_once_and_only_when_not_run() {
for (state, connections) in [(Some("not-run"), 2), (Some("may-have-run"), 1), (None, 1)] {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap().to_string();
let server = fake_app(listener, move |_, _| {
let mut r = json!({"ok": false, "error": "unauthorized: missing or invalid control token"});
if let (Some(o), Some(state)) = (r.as_object_mut(), state) {
o.insert("state".into(), json!(state));
}
r
});
let mut remote = Remote::lazy(&addr, false);
let err = remote.call("engine.execute", json!({"command": "circle", "params": {}})).unwrap_err();
assert!(err.starts_with("unauthorized"), "{err}");
assert_eq!(server.join().unwrap(), connections, "{state:?}");
assert!(remote.token.is_none() && remote.conn.is_none());
}
}
/// A failed mutation write reports uncertain delivery and does not open a retry connection.
#[test]
fn failed_mutation_write_is_not_resent() {
+6
View File
@@ -25,8 +25,14 @@ serde_json = { workspace = true }
log = { workspace = true }
chrono = { workspace = true }
image = { workspace = true }
web-time = { workspace = true }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
craft-update = { workspace = true }
[dev-dependencies]
# The workspace `egui` dependency enables `default_fonts` and `monochrome_emoji_fonts` in
# production too; the tests exercise the same installed chain as the app.
egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] }
# Validates the canvas WGSL and its GLSL ES 3.0 (WebGL2) translation in tests (same naga wgpu uses).
naga = { version = "30", features = ["wgsl-in", "glsl-out"] }
+8 -7
View File
@@ -143,6 +143,7 @@ pub fn apply(app: &mut CadApp) -> bool {
}
}
/// Command Aliases window that edits the in-memory alias set in `app.alias_dlg`; OK/Apply send the differences to ALIASEDIT, read the resulting aliases and source file back, and show any error.
pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
if app.alias_dlg.is_none() {
crate::aliasdlg::open(app);
@@ -151,7 +152,7 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
let warn = Tokens::get().warn;
let (mut ok, mut cancel, mut apply_clicked) = (false, false, false);
let Some(st) = app.alias_dlg.as_mut() else { return };
let id = egui::Id::new("aliasedit_dialog");
let id = egui::Id::unique("aliasedit_dialog");
if std::mem::take(&mut st.raise) {
ctx.move_to_top(egui::LayerId::new(egui::Order::Middle, id));
}
@@ -161,8 +162,8 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
None => ui.label(crate::tl!("No alias file loaded. REINIT replaces these aliases with a file's.")),
};
ui.horizontal(|ui| {
ui.label(crate::tl!("Filter"));
ui.text_edit_singleline(&mut st.filter);
let label = ui.label(crate::tl!("Filter"));
ui.text_edit_singleline(&mut st.filter).labelled_by(label.id);
});
let f = st.filter.trim().to_ascii_lowercase();
let mut names: Vec<&String> = st.user.keys().chain(built.keys().filter(|a| !st.user.contains_key(*a))).collect();
@@ -226,11 +227,11 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
if let Some(form) = st.form.as_mut() {
ui.group(|ui| {
egui::Grid::new("alias_form").num_columns(2).show(ui, |ui| {
ui.label(crate::tl!("Alias"));
ui.text_edit_singleline(&mut form.alias);
let alias_label = ui.label(crate::tl!("Alias"));
ui.text_edit_singleline(&mut form.alias).labelled_by(alias_label.id);
ui.end_row();
ui.label(crate::tl!("Command"));
ui.text_edit_singleline(&mut form.command);
let command_label = ui.label(crate::tl!("Command"));
ui.text_edit_singleline(&mut form.command).labelled_by(command_label.id);
ui.end_row();
});
// A few commands starting with what was typed, to pick from.
+9 -3
View File
@@ -75,7 +75,7 @@ struct Wheel {
fn wheel(events: &[egui::Event], opts: &egui::InputOptions, page: f32) -> Wheel {
let (mut scroll, mut zoom_points) = (egui::Vec2::ZERO, 0.0);
for e in events {
let egui::Event::MouseWheel { unit, delta, phase: egui::TouchPhase::Move, modifiers } = e else { continue };
let egui::Event::MouseWheel { unit, delta, phase: egui::TouchPhase::Move, modifiers, .. } = e else { continue };
let per = match unit {
egui::MouseWheelUnit::Point => 1.0,
egui::MouseWheelUnit::Line => opts.line_scroll_speed,
@@ -142,7 +142,7 @@ mod tests {
use super::*;
fn notch(unit: egui::MouseWheelUnit, delta: egui::Vec2, modifiers: egui::Modifiers) -> egui::Event {
egui::Event::MouseWheel { unit, delta, phase: egui::TouchPhase::Move, modifiers }
egui::Event::MouseWheel { unit, delta, phase: egui::TouchPhase::Move, source: egui::MouseWheelSource::Unknown, modifiers }
}
#[test]
@@ -194,7 +194,13 @@ mod tests {
assert_eq!(zoomed.scroll, egui::Vec2::ZERO);
assert!((zoomed.zoom - (o.scroll_zoom_speed * o.line_scroll_speed).exp()).abs() < 1e-6 && zoomed.zoom > 1.0);
// A touchpad's begin and end markers carry no scroll.
let ended = egui::Event::MouseWheel { unit: MouseWheelUnit::Point, delta: vec2(0.0, 9.0), phase: egui::TouchPhase::End, modifiers: none };
let ended = egui::Event::MouseWheel {
unit: MouseWheelUnit::Point,
delta: vec2(0.0, 9.0),
phase: egui::TouchPhase::End,
source: egui::MouseWheelSource::Unknown,
modifiers: none,
};
assert_eq!(wheel(&[ended], &o, 500.0), Wheel { scroll: egui::Vec2::ZERO, zoom: 1.0 });
// Hostile deltas scroll and zoom nothing.
for bad in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
+2 -2
View File
@@ -150,7 +150,7 @@ pub fn show(app: &mut CadApp, ctx: &egui::Context) {
let mut open = None;
let mut discard = None;
let mut later = false;
egui::Modal::new(egui::Id::new("cc_drawing_recovery")).show(ctx, |ui| {
crate::modal::instant(ctx, egui::Id::unique("cc_drawing_recovery")).show(ctx, |ui| {
ui.set_width(460.0);
ui.label(RichText::new("Drawing Recovery").strong());
ui.add_space(4.0);
@@ -204,7 +204,7 @@ pub fn gpu_notice(app: &mut CadApp, ctx: &egui::Context) {
let t = Tokens::get();
let mut dismiss = false;
egui::Window::new("Graphics error")
.id(egui::Id::new("cc_gpu_notice"))
.id(egui::Id::unique("cc_gpu_notice"))
.collapsible(false)
.resizable(false)
.anchor(egui::Align2::CENTER_TOP, vec2(0.0, 48.0))
+4 -4
View File
@@ -51,7 +51,7 @@ pub fn authoring_dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool)
.unwrap_or_else(|| crate::i18n::t("This drawing").to_string());
let mut choice: Option<&str> = None;
egui::Window::new(crate::tl!("Open in Block Editor"))
.id(egui::Id::new("Open in Block Editor"))
.id(egui::Id::unique("Open in Block Editor"))
.open(open)
.collapsible(false)
.resizable(false)
@@ -165,15 +165,15 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
let description = existing.as_deref().and_then(|n| d.block(n)).map(|b| b.description.clone()).unwrap_or_default();
let (mut ok, mut cancel) = (false, false);
egui::Window::new(crate::tl!("Edit Block Definition"))
.id(egui::Id::new("Edit Block Definition"))
.id(egui::Id::unique("Edit Block Definition"))
.open(open)
.collapsible(false)
.resizable(false)
.pivot(egui::Align2::CENTER_CENTER)
.default_pos(ctx.content_rect().center())
.show(ctx, |ui| {
ui.label(crate::tl!("Block to create or edit"));
let edit = ui.add(egui::TextEdit::singleline(&mut app.bedit.name).desired_width(460.0));
let name_label = ui.label(crate::tl!("Block to create or edit"));
let edit = ui.add(egui::TextEdit::singleline(&mut app.bedit.name).desired_width(460.0)).labelled_by(name_label.id);
if edit.changed() {
app.bedit.error = None;
}
+28 -7
View File
@@ -692,6 +692,18 @@ fn adaptive(unit: f64, scale: f64, min_px: f64) -> f64 {
u
}
/// Index ranges of the grid lines inside `wb` for line spacing `minor`, or `None` when there would be
/// 2000 lines or more along an axis. The span is computed with checked arithmetic: a huge view
/// saturates the float-to-integer casts at the i64 limits, and subtracting those would overflow.
fn grid_index_ranges(wb: &Bounds2, minor: f64) -> Option<((i64, i64), (i64, i64))> {
let range = |lo: f64, hi: f64| {
let a = (lo / minor).floor() as i64;
let b = (hi / minor).ceil() as i64;
b.checked_sub(a).filter(|n| *n < 2000).map(|_| (a, b))
};
Some((range(wb.min.x, wb.max.x)?, range(wb.min.y, wb.max.y)?))
}
fn draw_grid(app: &CadApp, p: &egui::Painter, xf: &Xf) {
let t = Tokens::get();
let s = app.session.drafting_settings();
@@ -704,11 +716,7 @@ fn draw_grid(app: &CadApp, p: &egui::Painter, xf: &Xf) {
let minor = adaptive(unit, xf.scale, 8.0);
let major = minor * major_n;
let mut lines = Vec::new();
let x0 = (wb.min.x / minor).floor() as i64;
let x1 = (wb.max.x / minor).ceil() as i64;
let y0 = (wb.min.y / minor).floor() as i64;
let y1 = (wb.max.y / minor).ceil() as i64;
if (x1 - x0) < 2000 && (y1 - y0) < 2000 {
if let Some(((x0, x1), (y0, y1))) = grid_index_ranges(&wb, minor) {
for i in x0..=x1 {
let x = i as f64 * minor;
let is_major = (x / major).round() * major - x == 0.0 || ((x / major) - (x / major).round()).abs() < 1e-6;
@@ -1362,7 +1370,7 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) {
// draws the others.
let tiles = tile_rects(app, full);
let rect = tiles.iter().find(|(_, tile)| tile.active).map_or(full, |(r, _)| *r);
let resp = ui.interact(full, ui.id().with("canvas"), Sense::click_and_drag());
let resp = ui.interact(full, ui.scope_id().with("canvas"), Sense::click_and_drag());
let painter = ui.painter_at(rect);
app.canvas.rect = Some(rect);
app.session.viewport_px = (f64::from(rect.width()).max(1.0), f64::from(rect.height()).max(1.0));
@@ -2846,7 +2854,7 @@ pub(crate) mod tests {
}
fn wheel(unit: egui::MouseWheelUnit, dy: f32, modifiers: egui::Modifiers) -> egui::Event {
egui::Event::MouseWheel { unit, delta: vec2(0.0, dy), phase: egui::TouchPhase::Move, modifiers }
egui::Event::MouseWheel { unit, delta: vec2(0.0, dy), phase: egui::TouchPhase::Move, source: egui::MouseWheelSource::Unknown, modifiers }
}
/// Wheel events convert to points as egui does; ⌘/Ctrl (egui's pinch zoom) and ⇧ (a sideways
@@ -2995,3 +3003,16 @@ mod hatch_pick_tests {
assert!(app.session.running.is_none(), "{:?}", app.session.running.as_ref().map(|r| r.machine.name()));
}
}
#[cfg(test)]
mod grid_range_tests {
use cadcraft_geom::{Bounds2, Vec2};
#[test]
fn grid_index_ranges_survive_extreme_views() {
let huge = Bounds2::new(Vec2::new(-1e300, -1e300), Vec2::new(1e300, 1e300));
assert_eq!(super::grid_index_ranges(&huge, 1.0), None);
let normal = Bounds2::new(Vec2::new(-5.0, 0.0), Vec2::new(5.0, 10.0));
assert_eq!(super::grid_index_ranges(&normal, 1.0), Some(((-5, 5), (0, 10))));
}
}
+1 -1
View File
@@ -11,7 +11,7 @@ use crate::CadApp;
use crate::theme::Tokens;
fn id() -> egui::Id {
egui::Id::new("cadcraft-block-editor-bar")
egui::Id::unique("cadcraft-block-editor-bar")
}
/// The block the Block Editor is editing in the drawing shown, and whether it has unsaved changes.
+1 -1
View File
@@ -26,7 +26,7 @@ struct Cache {
}
fn id() -> egui::Id {
egui::Id::new("cadcraft-compare-overlay")
egui::Id::unique("cadcraft-compare-overlay")
}
/// Draw the comparison over the canvas when it was made for the drawing and tab shown.
File diff suppressed because it is too large Load Diff
+19 -17
View File
@@ -80,7 +80,7 @@ pub fn title_and_toolbar(app: &mut CadApp, ui: &mut egui::Ui) {
ui.painter().galley(pos2(x, r.top() + (title_h - galley.size().y) / 2.0), galley, t.text_dim);
// Allow dragging the window by the title row.
let tr = Rect::from_min_size(r.min, vec2(r.width(), title_h));
let resp = ui.interact(tr, ui.id().with("titledrag"), Sense::CLICK | Sense::DRAG);
let resp = ui.interact(tr, ui.scope_id().with("titledrag"), Sense::CLICK | Sense::DRAG);
if resp.drag_started() {
ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag);
}
@@ -122,7 +122,7 @@ pub fn title_and_toolbar(app: &mut CadApp, ui: &mut egui::Ui) {
let Some(group) = TOOLBAR.get(*index) else { continue };
for (icon, cmd, label, shortcut) in group.iter() {
let br = Rect::from_min_size(pos2(x, row.center().y - size / 2.0), vec2(size, size));
let resp = ui.interact(br, ui.id().with(("tb", *cmd)), Sense::click());
let resp = ui.interact(br, ui.scope_id().with(("tb", *cmd)), Sense::click());
if resp.hovered() {
ui.painter().rect_filled(br, 3.0, t.control_hover.gamma_multiply(0.6));
}
@@ -141,7 +141,7 @@ pub fn title_and_toolbar(app: &mut CadApp, ui: &mut egui::Ui) {
}
if !fit.overflow.is_empty() {
let br = Rect::from_min_size(pos2(x, row.center().y - size / 2.0), vec2(fit.overflow_width, size));
let response = ui.interact(br, ui.id().with("toolbar_more"), Sense::click());
let response = ui.interact(br, ui.scope_id().with("toolbar_more"), Sense::click());
if response.hovered() {
ui.painter().rect_filled(br, 3.0, t.control_hover.gamma_multiply(0.6));
}
@@ -184,7 +184,7 @@ pub fn file_tabs(app: &mut CadApp, ui: &mut egui::Ui) {
icons::paint(ui.painter(), ib.shrink(1.0), Icon::Switcher, false);
x += 24.0;
let plus = Rect::from_min_size(pos2(x, r.top() + 4.0), vec2(18.0, 18.0));
let presp = ui.interact(plus, ui.id().with("tab+"), Sense::click());
let presp = ui.interact(plus, ui.scope_id().with("tab+"), Sense::click());
icons::paint(ui.painter(), plus.shrink(2.0), Icon::Plus, false);
icons::describe_control(ui, &presp, "New Drawing", None, false);
if presp.on_hover_text(crate::tl!("New Drawing")).clicked() {
@@ -238,7 +238,8 @@ pub fn file_tabs(app: &mut CadApp, ui: &mut egui::Ui) {
x += w;
(resp.clicked(), close, resp)
};
let (c, _, _) = tab(ui, crate::tl!("Start"), false, app.ui.start_tab || app.session.docs.is_empty(), false, 110.0, ui.id().with("tab_start"));
let (c, _, _) =
tab(ui, crate::tl!("Start"), false, app.ui.start_tab || app.session.docs.is_empty(), false, 110.0, ui.scope_id().with("tab_start"));
if c {
app.ui.start_tab = true;
}
@@ -247,7 +248,7 @@ pub fn file_tabs(app: &mut CadApp, ui: &mut egui::Ui) {
let titles: Vec<(usize, String, bool, bool)> =
app.session.docs.iter().enumerate().map(|(i, d)| (i, d.title.clone(), d.is_dirty(), i == app.session.active)).collect();
for (i, title, dirty, active) in titles {
let (c, x, resp) = tab(ui, &title, dirty, active && !app.ui.start_tab, true, 170.0, ui.id().with(("tab", i)));
let (c, x, resp) = tab(ui, &title, dirty, active && !app.ui.start_tab, true, 170.0, ui.scope_id().with(("tab", i)));
crate::shortcut_menus::attach(app, &resp, |app| crate::shortcut_menus::drawing_tab_entries(app, i));
if c {
switch_to = Some(i);
@@ -297,7 +298,8 @@ fn visible_tabs(widths: &[f32], active: Option<usize>, room: f32) -> std::ops::R
start..active + 1
}
/// Status bar: layout tabs on the left, coordinates and drafting toggles on the right.
/// Status bar: layout tabs follow the left-hand layout actions, independent of palette docking.
/// Coordinates and drafting toggles remain on the right.
pub fn status_bar(app: &mut CadApp, ui: &mut egui::Ui) {
let t = Tokens::get();
egui::Panel::bottom("cc_status").exact_size(26.0).frame(egui::Frame::NONE.fill(t.chrome)).show(ui, |ui| {
@@ -314,7 +316,7 @@ pub fn status_bar(app: &mut CadApp, ui: &mut egui::Ui) {
let mut list_button = None;
for (icon, tip) in [(Icon::Plus, "New layout"), (Icon::Menu, "Layout list")] {
let br = Rect::from_min_size(pos2(x, r.top() + 4.0), vec2(18.0, 18.0));
let resp = ui.interact(br, ui.id().with(("sb", tip)), Sense::click());
let resp = ui.interact(br, ui.scope_id().with(("sb", tip)), Sense::click());
icons::paint(&p, br.shrink(2.0), icon, false);
icons::describe_control(ui, &resp, tip, None, false);
let resp = resp.on_hover_text(crate::i18n::t(tip));
@@ -327,7 +329,7 @@ pub fn status_bar(app: &mut CadApp, ui: &mut egui::Ui) {
}
x += 22.0;
}
x = x.max(r.left() + if app.ui.show_toolsets { 232.0 } else { 60.0 });
x += 8.0;
// Right side: toggles, right-aligned.
let s = app.session.settings.clone();
let anno_all = app.session.doc().is_ok_and(|d| d.anno_all_visible());
@@ -368,7 +370,7 @@ pub fn status_bar(app: &mut CadApp, ui: &mut egui::Ui) {
let model = app.session.state().is_ok_and(|st| st.mspace.is_some());
let label = if model { "MODEL" } else { "PAPER" };
let br = Rect::from_min_max(pos2(rx - 60.0, r.center().y - 9.0), pos2(rx - 6.0, r.center().y + 9.0));
let resp = ui.interact(br, ui.id().with("mspace_toggle"), Sense::click());
let resp = ui.interact(br, ui.scope_id().with("mspace_toggle"), Sense::click());
p.rect_filled(br, 3.0, if resp.hovered() { t.control_hover } else { t.toggle_on.gamma_multiply(0.25) });
p.text(br.center(), egui::Align2::CENTER_CENTER, crate::i18n::t(label), crate::theme::small(), t.text);
icons::describe_control(ui, &resp, label, None, false);
@@ -417,7 +419,7 @@ pub fn status_bar(app: &mut CadApp, ui: &mut egui::Ui) {
let w = g.size().x + 26.0;
let tr = Rect::from_min_size(pos2(x, r.top() + 1.0), vec2(w, r.height() - 2.0)).intersect(clip);
let active = active_tab == Some(i);
let resp = ui.interact(tr, ui.id().with(("lt", i)), Sense::click());
let resp = ui.interact(tr, ui.scope_id().with(("lt", i)), Sense::click());
if active {
tp.rect_filled(tr, 2.0, t.tab_active);
} else if resp.hovered() {
@@ -433,7 +435,7 @@ pub fn status_bar(app: &mut CadApp, ui: &mut egui::Ui) {
x += w + 2.0;
if i == 0 {
let pr = Rect::from_min_size(pos2(x, r.top() + 5.0), vec2(16.0, 16.0));
let presp = ui.interact(pr, ui.id().with("layout_plus"), Sense::click());
let presp = ui.interact(pr, ui.scope_id().with("layout_plus"), Sense::click());
icons::paint(&p, pr.shrink(2.0), Icon::Plus, false);
icons::describe_control(ui, &presp, "New layout", None, false);
if presp.on_hover_text(crate::tl!("New layout")).clicked()
@@ -448,7 +450,7 @@ pub fn status_bar(app: &mut CadApp, ui: &mut egui::Ui) {
// The layouts that didn't fit, behind a button after the last tab shown.
let more = hidden.then(|| {
let mr = Rect::from_min_size(pos2(x + 2.0, r.top() + 5.0), vec2(16.0, 16.0));
let resp = ui.interact(mr, ui.id().with("layout_more"), Sense::click());
let resp = ui.interact(mr, ui.scope_id().with("layout_more"), Sense::click());
if resp.hovered() {
p.rect_filled(mr, 3.0, t.control_hover.gamma_multiply(0.5));
}
@@ -489,13 +491,13 @@ pub fn status_bar(app: &mut CadApp, ui: &mut egui::Ui) {
let rect = Rect::from_min_max(pos2(left, r.top()), pos2(coord_right, r.bottom()));
let text_x = if message.is_some() { left } else { coord_right - galley.size().x };
p.with_clip_rect(rect).galley(pos2(text_x, r.center().y - galley.size().y / 2.0), galley, color);
ui.interact(rect, ui.id().with("status_readout"), Sense::hover()).on_hover_text(text);
ui.interact(rect, ui.scope_id().with("status_readout"), Sense::hover()).on_hover_text(text);
}
}
let mut toggle_cmd = None;
for (icon, on, cmd, tip) in toggles {
let br = Rect::from_min_size(pos2(rx, r.center().y - size / 2.0), vec2(size, size));
let resp = ui.interact(br, ui.id().with(("tg", tip)), Sense::click());
let resp = ui.interact(br, ui.scope_id().with(("tg", tip)), Sense::click());
if on {
p.rect_filled(br, 3.0, t.toggle_on.gamma_multiply(0.35));
} else if resp.hovered() {
@@ -541,7 +543,7 @@ pub fn start_page(app: &mut CadApp, ui: &mut egui::Ui) {
let mut action = None;
for (label, cmd) in [("Open...", "open"), ("New", "new"), ("New (metric)", "new.metric"), ("Open sample drawing", "sample")] {
let br = Rect::from_min_size(pos2(left.left() + 34.0, y), vec2(220.0, 34.0));
let resp = ui.interact(br, ui.id().with(("start", cmd)), Sense::click());
let resp = ui.interact(br, ui.scope_id().with(("start", cmd)), Sense::click());
p.rect_stroke(br, 2.0, Stroke::new(1.0, if resp.hovered() { t.text } else { t.text_dim }), egui::StrokeKind::Inside);
p.text(pos2(br.left() + 16.0, br.center().y), egui::Align2::LEFT_CENTER, crate::i18n::t(label), crate::theme::body(), t.text);
if resp.clicked() {
@@ -555,7 +557,7 @@ pub fn start_page(app: &mut CadApp, ui: &mut egui::Ui) {
let mut y = main.top() + 44.0;
for (i, path) in app.ui.recent_files.iter().enumerate() {
let row = Rect::from_min_size(pos2(main.left(), y), vec2((r.right() - main.left() - 36.0).clamp(0.0, 640.0), 42.0));
let resp = ui.interact(row, ui.id().with(("recent", i)), Sense::click());
let resp = ui.interact(row, ui.scope_id().with(("recent", i)), Sense::click());
if resp.hovered() {
p.rect_filled(row, 2.0, t.control_hover.gamma_multiply(0.5));
}
+1 -1
View File
@@ -22,7 +22,7 @@ pub fn status(app: &mut CadApp, ui: &mut egui::Ui, right: f32, cy: f32) -> f32 {
let g = p.layout_no_wrap(current.clone(), crate::theme::small(), t.text);
let w = g.size().x.min(160.0) + 22.0;
let r = Rect::from_min_size(pos2(right - w, cy - 9.0), vec2(w, 18.0));
let resp = ui.interact(r, ui.id().with("anno_scale"), Sense::click());
let resp = ui.interact(r, ui.scope_id().with("anno_scale"), Sense::click());
p.rect_filled(r, 3.0, if resp.hovered() { t.control_hover } else { t.control.gamma_multiply(0.5) });
p.with_clip_rect(r.shrink2(vec2(4.0, 0.0))).galley(pos2(r.left() + 6.0, r.center().y - g.size().y / 2.0), g, t.text);
let (ax, ay) = (r.right() - 9.0, r.center().y);
+2 -2
View File
@@ -13,10 +13,10 @@ use crate::CadApp;
/// egui memory: the clipboard copy last mirrored, and the hash of the text it became.
fn serial_id() -> egui::Id {
egui::Id::new("cc_clipboard_serial")
egui::Id::unique("cc_clipboard_serial")
}
fn text_id() -> egui::Id {
egui::Id::new("cc_clipboard_text")
egui::Id::unique("cc_clipboard_text")
}
/// Line endings normalised (pasted text arrives with `\n`), so our own text is recognised.
+1 -1
View File
@@ -204,7 +204,7 @@ pub fn prompt(app: &mut CadApp, ctx: &egui::Context) {
};
let t = Tokens::get();
let mut choice = None;
egui::Modal::new(egui::Id::new("cc_close_prompt")).show(ctx, |ui| {
crate::modal::instant(ctx, egui::Id::unique("cc_close_prompt")).show(ctx, |ui| {
ui.set_width(360.0);
ui.label(RichText::new(crate::tl!("CADCraft")).strong());
ui.add_space(4.0);
+110 -6
View File
@@ -33,6 +33,16 @@ pub struct CmdLine {
pub option_rows: Vec<(Rect, String)>,
/// Where the Dynamic Input tooltip stays while the options menu is open.
pub options_anchor: Option<Pos2>,
/// The IME's composition (pre-edit) text: shown after the buffer while composing, never saved.
pub preedit: String,
/// The command line's editing rectangle and caret (screen) from the last frame, for the IME's
/// composition window (see `enable_ime`).
pub ime_rect: Option<(Rect, Rect)>,
/// The command line's IME context generation: a new one each time it starts collecting text,
/// as egui asks of an IME owner that (re)gains focus.
ime_generation: u64,
/// Whether the command line published an IME context last frame.
ime_on: bool,
}
/// The most history lines the command line shows above its bar.
@@ -247,9 +257,36 @@ pub struct KeyPress {
pub plain: bool,
}
/// Let the operating system's IME compose into the command line while a prompt asks for text.
/// Nothing owns keyboard focus there (typing goes to the command line from anywhere), so egui
/// never enables the IME by itself: publishing an IME output does it, with the command line's
/// rectangle and caret for the composition window. Without this a Windows IME (Pinyin, Japanese,
/// Korean...) cannot even switch on (#1761).
fn enable_ime(app: &mut CadApp, ctx: &egui::Context) {
if !app.cmd.ime_on {
app.cmd.ime_on = true;
app.cmd.ime_generation = app.cmd.ime_generation.wrapping_add(1);
}
let (rect, cursor_rect) = app.cmd.ime_rect.unwrap_or_else(|| {
let r = ctx.content_rect();
(r, Rect::from_min_size(pos2(r.left() + 30.0, r.bottom() - 34.0), vec2(1.0, 18.0)))
});
ctx.output_mut(|o| {
o.ime = Some(egui::output::IMEOutput::new(
egui::Id::unique("cc_cmdline_ime"),
app.cmd.ime_generation,
egui::IMEPurpose::Normal,
rect,
cursor_rect,
));
});
}
/// Handle keyboard input destined for the command line (when no text field has focus).
pub fn keyboard(app: &mut CadApp, ctx: &egui::Context) {
if ctx.egui_wants_keyboard_input() {
app.cmd.preedit.clear();
app.cmd.ime_on = false;
return;
}
let events = ctx.input(|i| i.events.clone());
@@ -257,6 +294,13 @@ pub fn keyboard(app: &mut CadApp, ctx: &egui::Context) {
app.cmd.watch_selection(&selection);
let mac = ctx.os() == egui::os::OperatingSystem::Mac;
let text_prompt = app.session.current_prompt().is_some_and(|p| p.accept.text && !p.accept.point && !p.accept.number);
if text_prompt {
enable_ime(app, ctx);
} else {
// No prompt is collecting text: a composition left over from one cannot belong here.
app.cmd.preedit.clear();
app.cmd.ime_on = false;
}
options_still_fit(app);
for ev in events {
// The options menu takes the arrows, Enter and Escape; typing closes it.
@@ -297,13 +341,26 @@ pub fn keyboard(app: &mut CadApp, ctx: &egui::Context) {
} else if t == " " && !text_prompt {
submit(app);
} else {
app.cmd.preedit.clear();
app.cmd.buffer.push_str(&t);
}
}
// IME composition: the pre-edit is shown but stays out of the buffer; the commit is
// the only text that is inserted. Enter/Return commits arrive separately as keys, so
// a newline commit is ignored (as egui's own text fields do) instead of submitting.
egui::Event::Ime(egui::ImeEvent::Preedit { text, .. }) => app.cmd.preedit = text,
egui::Event::Ime(egui::ImeEvent::Commit(text)) => {
app.cmd.preedit.clear();
if text != "\n" && text != "\r" {
app.cmd.buffer.push_str(&text);
}
}
egui::Event::Ime(_) => {}
egui::Event::Key { key, pressed: true, repeat, modifiers, .. } => match key {
Key::Enter => submit(app),
Key::Escape => {
app.cmd.buffer.clear();
app.cmd.preedit.clear();
app.session.cancel();
}
Key::Backspace => {
@@ -357,6 +414,7 @@ pub fn keyboard(app: &mut CadApp, ctx: &egui::Context) {
}
pub fn submit(app: &mut CadApp) {
app.cmd.preedit.clear();
// Grip dimensional input: the typed lengths and angles place the grip.
if crate::grip_dyn::submit(app) {
return;
@@ -395,6 +453,7 @@ fn visible_lines(app: &CadApp, canvas: Rect, bar: Rect) -> usize {
app.ui.history_lines.min(MAX_LINES).min(available)
}
/// Draw the floating command bar and its history over the bottom-centre of `canvas`; the bar is 48 % of the canvas width, clamped to 360–760 points and never wider than the canvas minus 8 points.
pub fn show(app: &mut CadApp, ui: &mut egui::Ui, canvas: Rect) {
let t = Tokens::get();
let w = (canvas.width() * 0.48).clamp(360.0, 760.0).min((canvas.width() - 8.0).max(0.0));
@@ -443,7 +502,7 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui, canvas: Rect) {
for (i, k) in kws.iter().enumerate() {
let g = p.layout_no_wrap(k.clone(), font.clone(), t.cmd_keyword);
let r = Rect::from_min_size(pos2(x, bar.top() + 3.0), vec2(g.size().x, h - 6.0));
let resp = ui.interact(r, ui.id().with(("kw", i)), Sense::click());
let resp = ui.interact(r, ui.scope_id().with(("kw", i)), Sense::click());
if resp.hovered() {
p.rect_filled(r, 2.0, t.cmd_keyword_hover);
}
@@ -492,14 +551,26 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui, canvas: Rect) {
}
}
}
let g = p.layout_no_wrap(app.cmd.buffer.clone(), font, t.text);
let g = p.layout_no_wrap(app.cmd.buffer.clone(), font.clone(), t.text);
let gw = g.size().x;
p.galley(pos2(x, bar.center().y - g.size().y / 2.0), g, t.text);
let mut caret_x = x + gw;
// The IME's composition text sits after the buffer (underlined) until it is committed.
if !app.cmd.preedit.is_empty() {
let pg = p.layout_no_wrap(app.cmd.preedit.clone(), font, t.text);
let pw = pg.size().x;
p.galley(pos2(caret_x, bar.center().y - pg.size().y / 2.0), pg, t.text);
p.line_segment([pos2(caret_x, bar.bottom() - 7.0), pos2(caret_x + pw, bar.bottom() - 7.0)], Stroke::new(1.0, t.accent));
caret_x += pw;
}
// Caret.
let blink = (ui.input(|i| i.time) * 2.0).floor() as i64 % 2 == 0;
if blink {
p.line_segment([pos2(x + gw + 1.0, bar.top() + 5.0), pos2(x + gw + 1.0, bar.bottom() - 5.0)], Stroke::new(1.0, t.text));
p.line_segment([pos2(caret_x + 1.0, bar.top() + 5.0), pos2(caret_x + 1.0, bar.bottom() - 5.0)], Stroke::new(1.0, t.text));
}
// Where the IME's composition window follows: the whole command line and the caret in it.
let caret = Rect::from_min_size(pos2(caret_x + 1.0, bar.top() + 5.0), vec2(1.0, h - 10.0));
app.cmd.ime_rect = Some((bar, caret));
// History chevron.
crate::icons::paint(
&p,
@@ -523,7 +594,7 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui, canvas: Rect) {
p.rect_stroke(lr, 3.0, Stroke::new(1.0, t.cmd_border), egui::StrokeKind::Inside);
for (i, s) in sug.iter().enumerate() {
let r = Rect::from_min_size(pos2(lr.left(), lr.top() + rh * i as f32), vec2(lr.width(), rh));
let resp = ui.interact(r, ui.id().with(("sug", i)), Sense::click());
let resp = ui.interact(r, ui.scope_id().with(("sug", i)), Sense::click());
if resp.hovered() || i == 0 {
p.rect_filled(r.shrink(1.0), 2.0, if resp.hovered() { t.cmd_keyword_hover } else { t.list_row });
}
@@ -586,7 +657,7 @@ fn zones(app: &CadApp, canvas: Rect, bar: Rect) -> Zones {
/// the top edge sets how many lines show (`ui.cmdline.lines`).
fn history(app: &mut CadApp, ui: &mut egui::Ui, canvas: Rect, bar: Rect) {
let z = zones(app, canvas, bar);
let resp = ui.interact(z.all, ui.id().with("cmdline"), Sense::click_and_drag());
let resp = ui.interact(z.all, ui.scope_id().with("cmdline"), Sense::click_and_drag());
let at = resp.hover_pos().or(resp.interact_pointer_pos());
let on_grip = |p: Pos2| z.grip.contains(p) && z.window.is_none();
let on_toggle = |p: Pos2| z.chevron.contains(p) || z.lines.is_some_and(|r| r.contains(p));
@@ -667,19 +738,52 @@ mod tests {
assert_eq!(app.ui.history_lines, 12);
}
fn frame(app: &mut CadApp, ctx: &egui::Context, events: Vec<Event>) {
fn frame(app: &mut CadApp, ctx: &egui::Context, events: Vec<Event>) -> egui::FullOutput {
let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1400.0, 900.0))), events, ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| {
app.logic(ui.ctx());
app.ui(ui);
});
out.textures_delta.clear();
out
}
fn press(app: &mut CadApp, ctx: &egui::Context, at: Pos2, pressed: bool) {
frame(app, ctx, vec![Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: egui::Modifiers::default() }]);
}
#[test]
fn ime_preedit_is_shown_and_its_commit_is_inserted_once() {
use cadcraft_engine::geom::Vec2;
let mut app = CadApp::new(Session::new(), crate::Services::default());
app.run("text", serde_json::json!({"at": [0, 0], "text": "HELLO", "height": 2.5})).unwrap();
app.start("textedit");
app.session.input(Input::Point(Vec2::new(1.0, 1.0))).unwrap();
assert_eq!(app.session.current_prompt().map(|p| p.message.clone()).as_deref(), Some("Enter new text"));
let ctx = egui::Context::default();
let out = frame(&mut app, &ctx, Vec::new());
// The IME is enabled for the command line while it is collecting text.
assert!(out.platform_output.ime.is_some());
// Composition is shown at the command line but is not part of the text being edited.
frame(&mut app, &ctx, vec![Event::Ime(egui::ImeEvent::Preedit { text: "zhong".into(), active_range_chars: None })]);
assert_eq!(app.cmd.preedit, "zhong");
assert!(app.cmd.buffer.is_empty());
// The committed text replaces the pre-edit, exactly once.
frame(&mut app, &ctx, vec![Event::Ime(egui::ImeEvent::Commit("中文".into()))]);
assert!(app.cmd.preedit.is_empty());
assert_eq!(app.cmd.buffer, "中文");
let enter = |pressed| Event::Key { key: Key::Enter, physical_key: None, pressed, repeat: false, modifiers: egui::Modifiers::NONE };
frame(&mut app, &ctx, vec![enter(true), enter(false)]);
let text = app.session.doc().unwrap().model.iter().next().map(|e| match &e.kind {
cadcraft_engine::doc::EntityKind::Text(t) => t.value.clone(),
_ => String::new(),
});
assert_eq!(text.as_deref(), Some("中文"));
// Back to selecting objects: the next frame lets the IME go, so it can't swallow ASCII typing.
let out = frame(&mut app, &ctx, Vec::new());
assert!(out.platform_output.ime.is_none());
}
#[test]
fn history_expands_by_click_chevron_f2_and_resizes_by_drag_without_touching_the_drawing() {
let mut app = CadApp::new(Session::empty(), crate::Services::default());
+2 -2
View File
@@ -313,8 +313,8 @@ struct Press(f64, egui::Pos2);
/// the menu while it is open.
pub fn in_command(app: &mut CadApp, ui: &egui::Ui, inside: bool, right_clicked: bool) -> bool {
let ctx = ui.ctx().clone();
let press_id = egui::Id::new("cc_command_menu_press");
let popup_id = egui::Id::new("cc_command_menu");
let press_id = egui::Id::unique("cc_command_menu_press");
let popup_id = egui::Id::unique("cc_command_menu");
let secondary = egui::PointerButton::Secondary;
let (pressed, released, now, pos) =
ctx.input(|i| (i.pointer.button_pressed(secondary), i.pointer.button_released(secondary), i.time, i.pointer.interact_pos()));
+181 -10
View File
@@ -102,8 +102,9 @@ struct Widget {
selected: Option<bool>,
}
/// egui temp-data key holding the widgets observed during the current frame.
fn widgets_id() -> egui::Id {
egui::Id::new("cadcraft-observed-widgets")
egui::Id::unique("cadcraft-observed-widgets")
}
pub(crate) fn begin_frame(ctx: &egui::Context) {
@@ -252,6 +253,7 @@ pub fn cmdline_state(app: &CadApp) -> Value {
})
}
/// Build the `ui.inspect` snapshot: UI state and theme, animations, focus, canvas/window rects, view, cursor, snap, session summary, close prompt and performance counters.
pub fn inspect(app: &CadApp, ctx: &egui::Context) -> Value {
let r = ctx.content_rect();
let view = app.session.state().map(|s| s.view()).ok();
@@ -267,7 +269,7 @@ pub fn inspect(app: &CadApp, ctx: &egui::Context) -> Value {
"snap": app.canvas.snap.map(|s| json!({"point": [s.point.x, s.point.y], "mode": s.name})),
"session": app.session.summary(),
"closePrompt": app.close_prompt(),
"perf": {"frameMs": app.frame_ms, "buildMs": app.canvas.build_ms, "drawMs": app.canvas.draw_ms, "meshMs": app.canvas.mesh_ms, "building": app.canvas.building(), "renderer": if app.canvas.gpu.is_some() { "gpu" } else { "cpu" }, "prims": app.canvas.list.as_ref().map(|l| l.prims.len())},
"perf": {"frameMs": app.frame_ms, "buildMs": app.canvas.build_ms, "drawMs": app.canvas.draw_ms, "meshMs": app.canvas.mesh_ms, "building": app.canvas.building(), "renderer": if app.canvas.gpu.is_some() { "gpu" } else { "cpu" }, "prims": app.canvas.list.as_ref().map(|l| l.prims.len()), "uiFrames": app.ui_frames, "passIndex": ctx.current_pass_index()},
})
}
@@ -543,18 +545,138 @@ fn render_reply(png: &[u8], w: u32, h: u32, path: Option<&str>) -> Value {
v
}
/// Save a screenshot PNG.
pub fn save_screenshot(image: &egui::ColorImage, path: Option<&str>) -> Value {
/// A private, per-process screenshot directory for pathless `ui.screenshot` requests.
///
/// The directory is created with owner-only permissions (`0700` on Unix) under a name that
/// includes the process id, a monotonic nonce and a sequence number. `create_dir` fails rather
/// than following a pre-created symlink, so another local user cannot redirect the write.
fn private_shot_dir() -> std::io::Result<std::path::PathBuf> {
use std::sync::OnceLock;
static DIR: OnceLock<Result<std::path::PathBuf, String>> = OnceLock::new();
DIR.get_or_init(|| create_private_shot_dir().map_err(|e| e.to_string())).clone().map_err(std::io::Error::other)
}
/// Process-wide monotonic counter used to keep pathless screenshot names unique.
fn next_pathless_nonce() -> u64 {
use std::sync::atomic::{AtomicU64, Ordering};
static NEXT: AtomicU64 = AtomicU64::new(0);
NEXT.fetch_add(1, Ordering::Relaxed)
}
/// Create at most 32 exclusive `cadcraft-shots-<pid>-<stamp>-<nonce>` directories in the temp dir; only `AlreadyExists` is retried.
fn create_private_shot_dir() -> std::io::Result<std::path::PathBuf> {
let base = std::env::temp_dir();
let stamp = crate::now_ms().to_bits();
for _ in 0..32 {
let nonce = next_pathless_nonce();
let dir = base.join(format!("cadcraft-shots-{}-{stamp:x}-{nonce}", std::process::id()));
match create_private_dir(&dir) {
Ok(()) => return Ok(dir),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(e) => return Err(e),
}
}
Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, "could not create a private screenshot directory"))
}
/// Create `path` with mode 0700 on Unix (plain create elsewhere), failing rather than following or reusing a pre-created entry.
fn create_private_dir(path: &std::path::Path) -> std::io::Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::DirBuilderExt as _;
std::fs::DirBuilder::new().mode(0o700).create(path)
}
#[cfg(not(unix))]
{
std::fs::create_dir(path)
}
}
/// Create a new file with owner-only permissions. `create_new` uses `O_EXCL` on Unix and refuses
/// an existing path, including a symlink, so a pre-created link cannot redirect the screenshot.
fn create_private_file(path: &std::path::Path) -> std::io::Result<std::fs::File> {
let mut options = std::fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt as _;
options.mode(0o600);
}
options.open(path)
}
/// True when the metadata has no group/other permission bits (always true on non-Unix).
fn is_private_shot_dir(meta: &std::fs::Metadata) -> bool {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
meta.permissions().mode() & 0o077 == 0
}
#[cfg(not(unix))]
{
let _ = meta;
true
}
}
/// Save a pathless screenshot into the private per-process directory: re-validate or recreate it as an owner-only real directory (0700 on Unix; plain create elsewhere), then create up to 64 exclusive files (0600 on Unix) and write a PNG.
fn save_pathless_screenshot(img: &image::RgbaImage, token: u64) -> std::io::Result<std::path::PathBuf> {
use std::io::Write as _;
let dir = private_shot_dir()?;
// Tests and users may remove the private directory between requests; recreate it exclusively
// rather than following anything that replaced it.
if !std::fs::symlink_metadata(&dir).is_ok_and(|m| m.file_type().is_dir() && !m.file_type().is_symlink() && is_private_shot_dir(&m)) {
match create_private_dir(&dir) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
let meta = std::fs::symlink_metadata(&dir)?;
if !meta.file_type().is_dir() || meta.file_type().is_symlink() || !is_private_shot_dir(&meta) {
return Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "the screenshot directory was replaced"));
}
}
Err(e) => return Err(e),
}
}
for _ in 0..64 {
let path = dir.join(format!("cadcraft-shot-{token}-{}.png", next_pathless_nonce()));
match create_private_file(&path) {
Ok(mut file) => {
img.write_to(&mut file, image::ImageFormat::Png).map_err(std::io::Error::other)?;
file.flush()?;
return Ok(path);
}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(e) => return Err(e),
}
}
Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, "could not allocate a unique screenshot file"))
}
/// Save a screenshot PNG. Only called from the logic drain, never from the screenshot callback.
///
/// An explicit `path` is written as before. Without one, the file gets a unique name inside a
/// private per-process directory, created exclusively with owner-only permissions; concurrent
/// requests (even from two app instances with the same per-process token) cannot collide or
/// follow a pre-created symlink.
pub fn save_screenshot(image: &egui::ColorImage, path: Option<&str>, token: u64) -> Value {
let [w, h] = image.size;
let mut buf = Vec::with_capacity(w * h * 4);
let Some(len) = w.checked_mul(h).and_then(|pixels| pixels.checked_mul(4)) else {
return json!({"ok": false, "error": "screenshot dimensions overflow", "request": token});
};
let mut buf = Vec::with_capacity(len);
for c in &image.pixels {
buf.extend_from_slice(&c.to_array());
}
let Some(img) = image::RgbaImage::from_raw(w as u32, h as u32, buf) else { return json!({"ok": false, "error": "bad image"}) };
let path = path.map(str::to_string).unwrap_or_else(|| std::env::temp_dir().join("cadcraft-shot.png").to_string_lossy().to_string());
match img.save(&path) {
Ok(()) => json!({"ok": true, "result": {"path": path, "width": w, "height": h}}),
Err(e) => json!({"ok": false, "error": e.to_string()}),
let Some(img) = image::RgbaImage::from_raw(w as u32, h as u32, buf) else {
return json!({"ok": false, "error": "bad image", "request": token});
};
let saved = match path {
Some(path) => img.save(path).map(|()| path.to_string()).map_err(std::io::Error::other),
None => save_pathless_screenshot(&img, token).map(|path| path.to_string_lossy().into_owned()),
};
match saved {
Ok(path) => json!({"ok": true, "request": token, "result": {"path": path, "width": w, "height": h}}),
Err(e) => json!({"ok": false, "error": e.to_string(), "request": token}),
}
}
@@ -610,6 +732,55 @@ mod tests {
assert_eq!(pending.wait(Duration::ZERO), json!({"ok": true}));
}
/// Two pathless saves with the same token produce distinct, decodable PNGs; on Unix the files are 0600 and their directory 0700.
#[test]
fn pathless_screenshots_are_unique_private_png_files() {
let img = egui::ColorImage::filled([4, 3], egui::Color32::RED);
let first = save_screenshot(&img, None, 1);
let second = save_screenshot(&img, None, 1);
assert_eq!(first["ok"], true, "{first}");
assert_eq!(second["ok"], true, "{second}");
let a = std::path::PathBuf::from(first["result"]["path"].as_str().unwrap_or_default());
let b = std::path::PathBuf::from(second["result"]["path"].as_str().unwrap_or_default());
assert_ne!(a, b, "the same per-process token must not reuse a pathless file");
for path in [&a, &b] {
let bytes = std::fs::read(path).expect("the screenshot file exists");
assert!(bytes.starts_with(b"\x89PNG\r\n\x1a\n"), "{path:?} is a PNG");
image::load_from_memory(&bytes).expect("decodable PNG");
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
for path in [&a, &b] {
let mode = std::fs::metadata(path).expect("metadata").permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "screenshot {path:?} is owner-only");
}
let dir = a.parent().expect("the file has a private directory");
let dir_mode = std::fs::metadata(dir).expect("directory metadata").permissions().mode() & 0o777;
assert_eq!(dir_mode, 0o700, "the screenshot directory is owner-only");
}
for path in [&a, &b] {
let _ = std::fs::remove_file(path);
}
}
/// `create_new` refuses a pre-existing symlink with `AlreadyExists` and leaves the link target unchanged.
#[cfg(unix)]
#[test]
fn private_screenshot_file_refuses_a_preexisting_symlink() {
let dir = std::env::temp_dir().join(format!("cadcraft-shot-symlink-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
create_private_dir(&dir).expect("private test directory");
let target = dir.join("target.txt");
std::fs::write(&target, b"do not overwrite").expect("target file");
let link = dir.join("shot.png");
std::os::unix::fs::symlink(&target, &link).expect("symlink");
let err = create_private_file(&link).expect_err("an existing symlink is refused");
assert_eq!(err.kind(), std::io::ErrorKind::AlreadyExists);
assert_eq!(std::fs::read(&target).expect("target unchanged"), b"do not overwrite");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn render_reply_has_base64_width_height_and_keeps_old_keys() {
let r = render_reply(b"abc", 7, 5, Some("x.png"));
+1 -1
View File
@@ -212,7 +212,7 @@ impl Default for View {
/// About ▸ Contributors: a name toggle, a sort, and the list as a grab bag or a table.
pub fn contributors_ui(ui: &mut egui::Ui) {
let id = egui::Id::new("credits_view");
let id = egui::Id::unique("credits_view");
let mut v = ui.data_mut(|d| d.get_temp::<View>(id)).unwrap_or_default();
ui.horizontal_wrapped(|ui| {
ui.label(crate::tl!("Show"));
+32 -25
View File
@@ -39,10 +39,11 @@ pub fn show(app: &mut CadApp, ctx: &egui::Context) {
}
}
/// Drafting Settings window: snap/grid modes and spacing, isometric snap, polar tracking and object snaps, editing the session settings directly.
fn dsettings(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
let settings = app.session.drafting_settings();
let (mut snapunit, mut gridunit) = (settings.snapunit, settings.gridunit);
egui::Window::new(crate::tl!("Drafting Settings")).id(egui::Id::new("Drafting Settings")).open(open).resizable(false).show(ctx, |ui| {
egui::Window::new(crate::tl!("Drafting Settings")).id(egui::Id::unique("Drafting Settings")).open(open).resizable(false).show(ctx, |ui| {
let s = &mut app.session.settings;
ui.heading(crate::tl!("Snap and Grid"));
ui.checkbox(&mut s.snapmode, crate::tl!("Snap On (F9)"));
@@ -52,10 +53,10 @@ fn dsettings(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
ui.radio_value(&mut s.isodraft, true, crate::tl!("Isometric snap"));
});
ui.horizontal(|ui| {
ui.label(crate::tl!("Snap X spacing"));
ui.add(egui::DragValue::new(&mut snapunit.x).speed(0.05).range(0.0001..=1e6).clamp_existing_to_range(false));
ui.label(crate::tl!("Y"));
ui.add(egui::DragValue::new(&mut snapunit.y).speed(0.05).range(0.0001..=1e6).clamp_existing_to_range(false));
let x_label = ui.label(crate::tl!("Snap X spacing"));
ui.add(egui::DragValue::new(&mut snapunit.x).speed(0.05).range(0.0001..=1e6).clamp_existing_to_range(false)).labelled_by(x_label.id);
let y_label = ui.label(crate::tl!("Y"));
ui.add(egui::DragValue::new(&mut snapunit.y).speed(0.05).range(0.0001..=1e6).clamp_existing_to_range(false)).labelled_by(y_label.id);
});
// SNAPTYPE and POLARDIST.
ui.horizontal(|ui| {
@@ -65,16 +66,16 @@ fn dsettings(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
});
ui.add_enabled_ui(s.polarsnap, |ui| {
ui.horizontal(|ui| {
ui.label(crate::tl!("Polar distance"));
ui.add(egui::DragValue::new(&mut s.polardist).speed(0.05).range(0.0..=1e6));
let label = ui.label(crate::tl!("Polar distance"));
ui.add(egui::DragValue::new(&mut s.polardist).speed(0.05).range(0.0..=1e6)).labelled_by(label.id);
});
});
ui.checkbox(&mut s.gridmode, crate::tl!("Grid On (F7)"));
ui.horizontal(|ui| {
ui.label(crate::tl!("Grid spacing"));
ui.add(egui::DragValue::new(&mut gridunit.x).speed(0.05).range(0.0..=1e6).clamp_existing_to_range(false));
ui.label(crate::tl!("Major line every"));
ui.add(egui::DragValue::new(&mut s.gridmajor).range(1..=100));
let grid_label = ui.label(crate::tl!("Grid spacing"));
ui.add(egui::DragValue::new(&mut gridunit.x).speed(0.05).range(0.0..=1e6).clamp_existing_to_range(false)).labelled_by(grid_label.id);
let major_label = ui.label(crate::tl!("Major line every"));
ui.add(egui::DragValue::new(&mut s.gridmajor).range(1..=100)).labelled_by(major_label.id);
});
ui.separator();
ui.heading(crate::tl!("Polar Tracking"));
@@ -84,12 +85,13 @@ fn dsettings(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
let current = (s.polarang.to_degrees() * 1e6).round() / 1e6;
let mut deg = current;
ui.horizontal(|ui| {
ui.label(crate::tl!("Increment angle"));
egui::ComboBox::from_id_salt("polarang").selected_text(format!("{deg}")).show_ui(ui, |ui| {
let label = ui.label(crate::tl!("Increment angle"));
let combo = egui::ComboBox::from_id_salt("polarang").selected_text(format!("{deg}")).show_ui(ui, |ui| {
for a in [90.0, 45.0, 30.0, 22.5, 18.0, 15.0, 10.0, 5.0] {
ui.selectable_value(&mut deg, a, format!("{a}"));
}
});
combo.response.labelled_by(label.id);
});
if deg != current {
s.polarang = deg.to_radians();
@@ -161,14 +163,15 @@ fn tracking_options(ui: &mut egui::Ui, s: &mut cadcraft_engine::Settings) {
}
let mut additional = s.polar_flags & polarmode::ADDITIONAL != 0;
ui.horizontal(|ui| {
if ui.checkbox(&mut additional, crate::tl!("Additional angles")).changed() {
let additional_label = ui.checkbox(&mut additional, crate::tl!("Additional angles"));
if additional_label.changed() {
s.polar_flags ^= polarmode::ADDITIONAL;
}
// The list as typed (degrees separated by `;`), kept while it doesn't parse yet.
let id = ui.id().with("polaraddang");
let id = ui.scope_id().with("polaraddang");
let shown = cadcraft_engine::sysvars::format_angle_list(&s.polaraddang);
let mut text = ui.data_mut(|d| d.get_temp::<String>(id)).unwrap_or_else(|| shown.clone());
let resp = ui.add_enabled(additional, egui::TextEdit::singleline(&mut text).desired_width(140.0));
let resp = ui.add_enabled(additional, egui::TextEdit::singleline(&mut text).desired_width(140.0)).labelled_by(additional_label.id);
if resp.changed()
&& let Some(angles) = cadcraft_engine::sysvars::parse_angle_list(&text)
{
@@ -250,7 +253,7 @@ fn length_precisions(lunits: i64) -> Vec<(i64, String)> {
/// through the `units` command on OK.
fn units(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
use cadcraft_engine::units::{format_angle, format_distance};
let id = egui::Id::new("units_dialog_values");
let id = egui::Id::unique("units_dialog_values");
let mut v = ctx.data_mut(|d| d.get_temp::<[i64; 5]>(id)).unwrap_or_else(|| {
app.session
.doc()
@@ -314,11 +317,12 @@ fn units(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
}
}
/// About window with About, Contributors and Models tabs, version, licence and project links.
fn about(ctx: &egui::Context, open: &mut bool) {
let t = Tokens::get();
let tab_id = egui::Id::new("about_tab");
let tab_id = egui::Id::unique("about_tab");
egui::Window::new(crate::tl!("About CADCraft"))
.id(egui::Id::new("About CADCraft"))
.id(egui::Id::unique("About CADCraft"))
.open(open)
.default_size(vec2(640.0, 420.0))
.collapsible(false)
@@ -351,16 +355,17 @@ fn about(ctx: &egui::Context, open: &mut bool) {
});
}
/// Command Reference window; filters commands by id or label (English or translated) and starts the clicked command after closing the dialog.
fn commands(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
let mut start = None;
egui::Window::new(crate::tl!("Command Reference")).id(egui::Id::new("Command Reference")).open(open).default_size(vec2(640.0, 480.0)).show(
egui::Window::new(crate::tl!("Command Reference")).id(egui::Id::unique("Command Reference")).open(open).default_size(vec2(640.0, 480.0)).show(
ctx,
|ui| {
let id = ui.id().with("cmdfilter");
let id = ui.scope_id().with("cmdfilter");
let mut filter = ui.data_mut(|d| d.get_temp::<String>(id)).unwrap_or_default();
ui.horizontal(|ui| {
ui.label(crate::tl!("Filter"));
ui.text_edit_singleline(&mut filter);
let label = ui.label(crate::tl!("Filter"));
ui.text_edit_singleline(&mut filter).labelled_by(label.id);
});
ui.data_mut(|d| d.insert_temp(id, filter.clone()));
let f = filter.to_lowercase();
@@ -391,9 +396,10 @@ fn commands(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
}
}
/// Blocks window listing named (non-anonymous) blocks; clicking one starts INSERT with that block name already supplied.
fn blocks(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
let mut pick = None;
egui::Window::new(crate::tl!("Blocks")).id(egui::Id::new("Blocks")).open(open).default_size(vec2(320.0, 360.0)).show(ctx, |ui| {
egui::Window::new(crate::tl!("Blocks")).id(egui::Id::unique("Blocks")).open(open).default_size(vec2(320.0, 360.0)).show(ctx, |ui| {
let Ok(d) = app.session.doc() else { return };
let names: Vec<&String> = d.blocks.keys().filter(|k| !k.starts_with('*')).collect();
if names.is_empty() {
@@ -422,8 +428,9 @@ fn insert_block(app: &mut CadApp, name: &str) {
}
}
/// Interface Language combo; selecting a language runs `ui.language` with its code.
fn language(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
egui::Window::new(crate::tl!("Interface Language")).id(egui::Id::new("interface_language")).open(open).resizable(false).show(ctx, |ui| {
egui::Window::new(crate::tl!("Interface Language")).id(egui::Id::unique("interface_language")).open(open).resizable(false).show(ctx, |ui| {
egui::ComboBox::from_id_salt("interface_language_choice").selected_text(app.ui.interface_language.name()).show_ui(ui, |ui| {
for preference in crate::i18n::Preference::for_picker() {
if ui.selectable_label(app.ui.interface_language == preference, preference.name()).clicked() {
+3 -2
View File
@@ -213,6 +213,7 @@ pub fn command(app: &mut crate::CadApp, reset: bool, params: &Value) -> Result<V
Ok(json!({"docking": app.ui.docking}))
}
/// Validate and repair the stored workspace, sync it with the app, and render the dock panels; a failed restore falls back to the default layout with a status message.
pub fn show(app: &mut crate::CadApp, ui: &mut egui::Ui) {
let t = crate::theme::Tokens::get();
let mut workspace = app.ui.docking.clone();
@@ -237,7 +238,7 @@ pub fn show(app: &mut crate::CadApp, ui: &mut egui::Ui) {
// Panels arrange in a split tree around the drawing; floating title bars only move windows.
style.new_dock_zones = false;
style.title_bar_docks = false;
let output = DockArea::new(egui::Id::new("cadcraft-docking")).show_with_limits(
let output = DockArea::new(egui::Id::unique("cadcraft-docking")).show_with_limits(
ui,
&workspace.layout,
&style,
@@ -273,7 +274,7 @@ pub fn show(app: &mut crate::CadApp, ui: &mut egui::Ui) {
let _ = sync(&mut workspace, app);
let stale = workspace != rendered;
if stale {
craft_ui::docking::cancel_drag::<Panel>(ui.ctx(), egui::Id::new("cadcraft-docking"));
craft_ui::docking::cancel_drag::<Panel>(ui.ctx(), egui::Id::unique("cadcraft-docking"));
}
publish_visibility(app, &workspace);
app.ui.docking = workspace;
+1 -1
View File
@@ -131,7 +131,7 @@ pub(crate) fn menu(app: &mut CadApp, ctx: &egui::Context, xf: &Xf) {
let gs = app.session.settings.gripsize as f32;
let at = xf.to_screen(g.at) + vec2(-gs, gs * 1.5);
let mut chosen: Option<String> = None;
let area = egui::Area::new(egui::Id::new(("dyn-grip-list", h, g.node))).order(egui::Order::Foreground).fixed_pos(at).show(ctx, |ui| {
let area = egui::Area::new(egui::Id::unique(("dyn-grip-list", h, g.node))).order(egui::Order::Foreground).fixed_pos(at).show(ctx, |ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| {
for c in &g.choices {
if ui.selectable_label(c.eq_ignore_ascii_case(&current), c.as_str()).clicked() {
+5 -5
View File
@@ -72,14 +72,14 @@ const FROM_PLOT: &str = "cc_plot_preview_from_plot";
/// return to the dialog.
pub(crate) fn preview_plot(app: &mut CadApp, ctx: &egui::Context, params: Value, info: String) {
PREVIEW_GEN.fetch_add(1, Ordering::Relaxed);
ctx.data_mut(|d| d.insert_temp(egui::Id::new(FROM_PLOT), FromPlot { params, info }));
ctx.data_mut(|d| d.insert_temp(egui::Id::unique(FROM_PLOT), FromPlot { params, info }));
app.ui.dialog = Some("preview".into());
}
/// Render the plot preview of the current layout (or model), or of the Plot dialog's settings,
/// as a texture.
fn render(app: &mut CadApp, ctx: &egui::Context) -> Result<(TextureHandle, String), String> {
if let Some(f) = ctx.data(|d| d.get_temp::<FromPlot>(egui::Id::new(FROM_PLOT))) {
if let Some(f) = ctx.data(|d| d.get_temp::<FromPlot>(egui::Id::unique(FROM_PLOT))) {
let r = app.session.execute("plot", &f.params).map_err(|e| e.to_string())?;
let png = r["data"].as_str().and_then(cadcraft_engine::cmd::file::base64_decode).ok_or("the preview has no image")?;
let img = image::load_from_memory_with_format(&png, image::ImageFormat::Png).map_err(|e| e.to_string())?.to_rgba8();
@@ -101,7 +101,7 @@ fn render(app: &mut CadApp, ctx: &egui::Context) -> Result<(TextureHandle, Strin
/// The Plot Preview window: the sheet as it plots, with Print and Close.
pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
let id = egui::Id::new("cc_plot_preview");
let id = egui::Id::unique("cc_plot_preview");
let generation = PREVIEW_GEN.load(Ordering::Relaxed);
let cached = ctx.data(|d| d.get_temp::<Preview>(id)).filter(|p| p.generation == generation);
let preview = match cached {
@@ -137,11 +137,11 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
if close || print {
*open = false;
}
let from_plot = ctx.data(|d| d.get_temp::<FromPlot>(egui::Id::new(FROM_PLOT))).is_some();
let from_plot = ctx.data(|d| d.get_temp::<FromPlot>(egui::Id::unique(FROM_PLOT))).is_some();
if !*open {
ctx.data_mut(|d| {
d.remove::<Preview>(id);
d.remove::<FromPlot>(egui::Id::new(FROM_PLOT));
d.remove::<FromPlot>(egui::Id::unique(FROM_PLOT));
});
}
if from_plot && !*open {
+5 -4
View File
@@ -430,7 +430,7 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
};
let title = if st.edit.is_empty() { crate::tl!("Hatch and Gradient") } else { crate::tl!("Hatch Edit") };
let mut action = Action::None;
egui::Window::new(title).id(egui::Id::new("hatch_dialog")).open(open).collapsible(false).resizable(false).show(ctx, |ui| {
egui::Window::new(title).id(egui::Id::unique("hatch_dialog")).open(open).collapsible(false).resizable(false).show(ctx, |ui| {
ui.horizontal_top(|ui| {
ui.vertical(|ui| {
ui.set_width(340.0);
@@ -478,6 +478,7 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
}
}
/// Pattern/gradient fill selector and its controls; an origin pick is reported through `action`.
fn fill_ui(ui: &mut egui::Ui, f: &mut Fill, action: &mut Action) {
ui.horizontal(|ui| {
ui.selectable_value(&mut f.gradient, false, crate::tl!("Pattern"));
@@ -487,12 +488,12 @@ fn fill_ui(ui: &mut egui::Ui, f: &mut Fill, action: &mut Action) {
if f.gradient {
egui::Grid::new("hatch_gradient").num_columns(2).spacing(vec2(12.0, 6.0)).show(ui, |ui| {
ui.label(crate::tl!("Color 1"));
if let Some(c) = crate::layers::color_button(ui, egui::Id::new("hatch_color1"), f.color1, false, 160.0) {
if let Some(c) = crate::layers::color_button(ui, egui::Id::unique("hatch_color1"), f.color1, false, 160.0) {
f.color1 = c;
}
ui.end_row();
ui.label(crate::tl!("Color 2"));
if let Some(c) = crate::layers::color_button(ui, egui::Id::new("hatch_color2"), f.color2, false, 160.0) {
if let Some(c) = crate::layers::color_button(ui, egui::Id::unique("hatch_color2"), f.color2, false, 160.0) {
f.color2 = c;
}
ui.end_row();
@@ -853,7 +854,7 @@ mod tests {
let (mut app, _) = app_with_rect();
open_create(&mut app, gradient);
let ctx = frames(&mut app);
let r = ctx.memory(|m| m.area_rect(egui::Id::new("hatch_dialog"))).unwrap();
let r = ctx.memory(|m| m.area_rect(egui::Id::unique("hatch_dialog"))).unwrap();
// The screen is 1000 points high: the window keeps to its content.
assert!(r.height() < 700.0, "gradient {gradient}: {r:?}");
}
+3
View File
@@ -1237,3 +1237,6 @@ menu Modify تعديل
menu View عرض
@plural {n} commit {n} إيداع|{n} إيداع|{n} إيداعان|{n} إيداعات|{n} إيداعاً|{n} إيداع
@plural {n} contributor {n} مساهم|{n} مساهم|{n} مساهمان|{n} مساهمين|{n} مساهماً|{n} مساهم
Check for Updates… التحقق من وجود تحديثات…
CADCraft is up to date. CADCraft محدّث.
Could not check for updates. تعذّر التحقق من وجود تحديثات.
1 # CADCraft Arabic interface catalog — original translations.
1237 menu
1238 @plural
1239 @plural
1240
1241
1242
+3
View File
@@ -1222,3 +1222,6 @@ menu Modify Modifikace
Not a number or distance: {text} Není číslo ani vzdálenost: {text}
Query Texts Dotaz na texty
Analyze Shapes Analyzovat tvary
Check for Updates… Zkontrolovat aktualizace…
CADCraft is up to date. CADCraft je aktuální.
Could not check for updates. Aktualizace se nepodařilo zkontrolovat.
1 # CADCraft Czech interface catalog — original translations by @Losminek (AI-assisted).
1222
1223
1224
1225
1226
1227
+3
View File
@@ -1039,3 +1039,6 @@
Justify Blocksatz
Text color Textfarbe
Click the ruler to add a tab stop, right-click to remove one Klick auf das Lineal setzt einen Tabstopp, Rechtsklick entfernt ihn
Check for Updates… Nach Updates suchen…
CADCraft is up to date. CADCraft ist auf dem neuesten Stand.
Could not check for updates. Die Suche nach Updates ist fehlgeschlagen.
1 # CADCraft German interface catalog — original translations by the CADCraft contributors.
1039
1040
1041
1042
1043
1044
+3
View File
@@ -1333,3 +1333,6 @@ menu Dimension Διαστασιολόγηση
menu Draw Σχεδίαση
menu Modify Τροποποίηση
menu View Προβολή
Check for Updates… Έλεγχος για ενημερώσεις…
CADCraft is up to date. Το CADCraft είναι ενημερωμένο.
Could not check for updates. Δεν ήταν δυνατός ο έλεγχος για ενημερώσεις.
1 # Greek interface catalog for CADCraft. Original, AI-assisted translation by @Camthalion77 with ChatGPT.
1333 menu
1334 menu
1335 menu
1336
1337
1338
+3
View File
@@ -1251,3 +1251,6 @@
Not a number or distance: {text} No es un número ni una distancia: {text}
Query Texts Consultar textos
Analyze Shapes Analizar formas
Check for Updates… Buscar actualizaciones…
CADCraft is up to date. CADCraft está actualizado.
Could not check for updates. No se pudo buscar actualizaciones.
1 # CADCraft Spanish interface catalog — original translations.
1251
1252
1253
1254
1255
1256
+3
View File
@@ -1012,3 +1012,6 @@
Not a number or distance: {text} Ni un nombre ni une distance : {text}
Query Texts Interroger les textes
Analyze Shapes Analyser les formes
Check for Updates… Rechercher des mises à jour…
CADCraft is up to date. CADCraft est à jour.
Could not check for updates. Impossible de rechercher les mises à jour.
1 # CADCraft French interface catalog — original translations by @pechnikov2026-star.
1012
1013
1014
1015
1016
1017
+3
View File
@@ -1344,3 +1344,6 @@ menu Dimension Méretezés
menu Draw Rajzolás
menu Modify Módosítás
menu View Nézet
Check for Updates… Frissítések keresése…
CADCraft is up to date. A CADCraft naprakész.
Could not check for updates. A frissítések keresése nem sikerült.
Can't render this file because it contains an unexpected character in line 16 and column 72.
+3
View File
@@ -1362,3 +1362,6 @@ menu Modify Edita
Click the ruler to add a tab stop, right-click to remove one Fare clic sul righello per aggiungere una tabulazione, clic destro per rimuoverla
Select using two clicks only Seleziona solo con due clic
Define each corner of the selection window with a separate click. Definire ogni angolo della finestra di selezione con un clic separato.
Check for Updates… Verifica aggiornamenti…
CADCraft is up to date. CADCraft è aggiornato.
Could not check for updates. Impossibile verificare gli aggiornamenti.
1 # CADCraft Italian interface catalog — original translations by Emanuele Bottelli (AI-assisted, Claude).
1362
1363
1364
1365
1366
1367
+3
View File
@@ -1242,3 +1242,6 @@ menu View 보기
menu Draw 그리기
menu Dimension 치수
menu Modify 수정
Check for Updates… 업데이트 확인…
CADCraft is up to date. CADCraft가 최신 버전입니다.
Could not check for updates. 업데이트를 확인하지 못했습니다.
1 # CADCraft Korean interface catalog — original Korean translations.
1242 menu
1243 menu
1244 menu
1245
1246
1247
+3
View File
@@ -1251,3 +1251,6 @@
Not a number or distance: {text} Não é um número nem uma distância: {text}
Select using two clicks only Selecionar apenas com dois cliques
Define each corner of the selection window with a separate click. Defina cada canto da janela de seleção com um clique separado.
Check for Updates… Verificar atualizações…
CADCraft is up to date. O CADCraft está atualizado.
Could not check for updates. Não foi possível verificar atualizações.
1 # CADCraft Brazilian Portuguese (pt-BR) interface catalog.
1251
1252
1253
1254
1255
1256
+3
View File
@@ -1013,3 +1013,6 @@
Not a number or distance: {text} Не число и не расстояние: {text}
Query Texts Запрос текстов
Analyze Shapes Анализ фигур
Check for Updates… Проверить обновления…
CADCraft is up to date. У вас последняя версия CADCraft.
Could not check for updates. Не удалось проверить обновления.
1 # CADCraft Russian interface catalog — original translations by @pechnikov2026-star.
1013
1014
1015
1016
1017
1018
+52
View File
@@ -1397,3 +1397,55 @@ menu Modify Ändra
Window < Fönster <
X offset (mm) X-förskjutning (mm)
Y offset (mm) Y-förskjutning (mm)
AutoLISP (LSP) AutoLISP (LSP)
Units of new drawings Enheter för nya ritningar
Imperial (inches) Anglosaxiskt (tum)
Metric (millimetres) Metriskt (millimeter)
New and the first drawing at startup use these units unless a default template is set (QNEWTEMPLATE). Nya ritningar och den första ritningen vid start använder dessa enheter om ingen standardmall är angiven (QNEWTEMPLATE).
Analyze Usage Analysera användning
Implied Loops Implicita slingor
Angstroms Ångström
Architectural Arkitektonisk
Astronomical Units Astronomiska enheter
Centimeters Centimeter
Decameters Dekameter
Decimal Decimal
Decimal Degrees Decimalgrader
Decimeters Decimeter
Deg/Min/Sec Grad/min/sek
Don't Save Spara inte
Drawing Units Ritningsenheter
Engineering Teknisk
Feet Fot
Fractional Bråk
Gigameters Gigameter
Grads Gon
Hectometers Hektometer
Inches Tum
Insertion scale Insättningsskala
Kilometers Kilometer
Light Years Ljusår
Meters Meter
Microinches Mikrotum
Microns Mikrometer
Miles Engelska mil
Millimeters Millimeter
Mils Tusendels tum
Nanometers Nanometer
Parsecs Parsek
Radians Radianer
Sample output Exempel på utdata
Save changes to {name}? Spara ändringar i {name}?
Scientific Vetenskaplig
Surveyor's Units Lantmäterienheter
Unitless Enhetslös
Units to scale inserted content Enheter för skalning av infogat innehåll
US Survey Feet Lantmäterifot (USA)
US Survey Inch Lantmäteritum (USA)
US Survey Mile Engelsk lantmäterimil (USA)
US Survey Yard Lantmäteriyard (USA)
Yards Yard
Your changes will be lost if you don't save them. Dina ändringar går förlorade om du inte sparar dem.
Check for Updates… Sök efter uppdateringar…
CADCraft is up to date. CADCraft är uppdaterat.
Could not check for updates. Det gick inte att söka efter uppdateringar.
1 # CADCraft Swedish interface catalog — original translations by the CADCraft contributors.
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
+32 -6
View File
@@ -323,8 +323,9 @@ fn live_switch_translates_the_shell_without_mutating_drawing_or_automation() {
// Without a font for its script, a language stays chosen and the interface shows English.
let shown = app.language();
assert_eq!(shown, if crate::theme::can_show(language.code) { language.code } else { "en" });
assert_eq!(app.fonts_language, Some(shown));
let text = render(&mut app, &ctx);
// Fonts are a frame-only concern: the render above installs them.
assert_eq!(app.fonts_language, Some(shown));
for source in ["Properties", "File"] {
assert!(text.contains(tr(shown, source)), "{}: {source}: {text}", language.code);
}
@@ -345,8 +346,8 @@ fn live_switch_translates_the_shell_without_mutating_drawing_or_automation() {
let _ = crate::control::handle(&mut app, &ctx, &request);
assert_eq!(app.ui.interface_language, Preference::parse(code).unwrap());
app.logic(&ctx);
assert_eq!(app.fonts_language, Some(app.language()));
render(&mut app, &ctx);
assert_eq!(app.fonts_language, Some(app.language()));
}
assert!(render(&mut app, &ctx).contains("Properties"));
// The menu tree used by the control channel keeps its original labels and IDs.
@@ -409,9 +410,9 @@ fn interface_fonts_cover_every_letter_in_each_catalog() {
for family in [egui::FontFamily::Proportional, egui::FontFamily::Monospace] {
// In egui 0.36, has_glyph can report a false negative for glyphs in the same
// face as the replacement character. Check the family's actual character maps.
let mut font = view.fonts.font(&family);
let chars = view.characters(&family);
for c in &letters {
if !font.characters().contains_key(c) {
if !chars.contains_key(c) {
missing.push(format!("{family:?}: {c} (U+{:04X})", u32::from(*c)));
}
}
@@ -470,9 +471,9 @@ fn korean_ui_fonts_cover_the_language_picker_in_both_families() {
let missing = ctx.fonts_mut(|view| {
let mut missing = Vec::new();
for family in [egui::FontFamily::Proportional, egui::FontFamily::Monospace] {
let mut font = view.fonts.font(&family);
let chars = view.characters(&family);
for c in "한국어".chars() {
if !font.characters().contains_key(&c) {
if !chars.contains_key(&c) {
missing.push(format!("{family:?}: {c}"));
}
}
@@ -481,3 +482,28 @@ fn korean_ui_fonts_cover_the_language_picker_in_both_families() {
});
assert!(missing.is_empty(), "missing Korean picker glyphs: {}", missing.join(", "));
}
/// The production font chain covers alphanumeric ASCII in proportional and monospace, plus ⌘ ⇧ ↩ … − in proportional and ⇧ in monospace.
#[test]
fn production_font_chain_covers_latin_and_shortcut_symbols() {
// Exercise the same chain the app installs: the workspace egui dependency enables
// `default_fonts` and `monochrome_emoji_fonts` in production, so web and native builds
// always start with bounded Latin and monochrome icon faces instead of an empty family.
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx, "en");
let mut output = ctx.run_ui(Default::default(), |_| {});
output.textures_delta.clear();
ctx.fonts_mut(|view| {
for family in [egui::FontFamily::Proportional, egui::FontFamily::Monospace] {
for c in "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789".chars() {
assert!(view.has_glyph(&egui::FontId::new(12.0, family.clone()), c), "Latin {c:?} missing in {family:?}");
}
}
// Proportional shortcut labels (`Button::shortcut_text` and the translated menus) use
// these symbols: ⌘ from the monochrome icon face, ⇧ from Hack as a bounded fallback.
for c in ['\u{2318}', '\u{21e7}', '\u{21a9}', '\u{2026}', '\u{2212}'] {
assert!(view.has_glyph(&egui::FontId::proportional(12.0), c), "U+{:04X} missing in Proportional", c as u32);
}
assert!(view.has_glyph(&egui::FontId::monospace(12.0), '\u{21e7}'), "U+21E7 shift symbol missing in Monospace");
});
}

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