diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 64c13bb23..7b3b6bf5b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index 11d44fca4..93be914f2 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6cc35e572..368d4f322 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 }} diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 264c7430f..e124d102a 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -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 9f89fe8 vendor/krilla/icc/sGrey-v2-magic.icc](https://github.com/storytold/craft-libs/blob/9f89fe8f6d72ab4db4cc06f186381a6a254463ab/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 9f89fe8 vendor/krilla/icc/sGrey-v4.icc](https://github.com/storytold/craft-libs/blob/9f89fe8f6d72ab4db4cc06f186381a6a254463ab/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 9f89fe8 vendor/krilla/icc/sRGB-v2-magic.icc](https://github.com/storytold/craft-libs/blob/9f89fe8f6d72ab4db4cc06f186381a6a254463ab/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 9f89fe8 vendor/krilla/icc/sRGB-v4.icc](https://github.com/storytold/craft-libs/blob/9f89fe8f6d72ab4db4cc06f186381a6a254463ab/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 | diff --git a/Cargo.lock b/Cargo.lock index 48e90a2b1..b646729fb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,20 +4,20 @@ version = 4 [[package]] name = "acadrust" -version = "0.6.5" +version = "0.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eaa72520f70eb6f7f09b06f44e1ed0d8fad5fa743364f2c3715c30df962ec7c" +checksum = "8923c99ea1f151cb7fc17ce2fc14f8b1e330fa22611e535602d985c0cc3c89a2" dependencies = [ "anyhow", "bitflags 2.13.2", "byteorder", "encoding_rs", "flate2", - "foldhash", + "foldhash 0.2.0", "indexmap", "itoa", "memmap2", - "nom", + "nom 7.1.3", "oem_cp", "once_cell", "rayon", @@ -28,9 +28,9 @@ dependencies = [ [[package]] name = "accesskit" -version = "0.24.1" +version = "0.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" +checksum = "4e883d1d28c5fd5568c79ed2d2bd48d9f2fd3945c8974ed0f53690ceddb051be" dependencies = [ "enumn", "serde", @@ -39,57 +39,51 @@ dependencies = [ [[package]] name = "accesskit_atspi_common" -version = "0.18.1" +version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e8c61bee90b42a772d39d06a740207dc71a4e780004ace1db8d99fb1baaa954" +checksum = "24f5baf62e34a4a6c30e3d697e4782f821a7337049c44c3b40576a27dfbb580d" dependencies = [ "accesskit", - "accesskit_consumer 0.36.0", + "accesskit_consumer", "atspi-common", - "phf 0.13.1", + "phf 0.14.0", "serde", "zvariant", ] [[package]] name = "accesskit_consumer" -version = "0.35.0" +version = "0.39.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53cf47daed85312e763fbf85ceca136e0d7abc68e0a7e12abe11f48172bc3b10" +checksum = "f3da8f27636015191b2264ee1df681503170a632cafd832fdc1edfdd4e3a2fd3" dependencies = [ "accesskit", - "hashbrown 0.16.1", + "hashbrown 0.17.1", ] [[package]] -name = "accesskit_consumer" -version = "0.36.0" +name = "accesskit_ios" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25e0d7e25d06f4dc21d1774d67146e9e80d6789216cbd4d1e88185b0095dba60" +checksum = "a5280724673bbfa8b30a838c013500b719392989be3b97c5a9964062c252c232" dependencies = [ "accesskit", - "hashbrown 0.16.1", -] - -[[package]] -name = "accesskit_consumer" -version = "0.38.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245" -dependencies = [ - "accesskit", - "hashbrown 0.16.1", + "accesskit_consumer", + "hashbrown 0.17.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", + "objc2-ui-kit 0.2.2", ] [[package]] name = "accesskit_macos" -version = "0.26.3" +version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076" +checksum = "123828ceeec755b05255e3976cb0852993202de98a0626c97dd8d34246991f84" dependencies = [ "accesskit", - "accesskit_consumer 0.38.0", - "hashbrown 0.16.1", + "accesskit_consumer", + "hashbrown 0.17.1", "objc2 0.5.3", "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", @@ -97,9 +91,9 @@ dependencies = [ [[package]] name = "accesskit_unix" -version = "0.21.1" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b016ca8db0ea0ea2ceff29a9d6240391492d960716aa471967c00e8cc8cb197c" +checksum = "30d5192871228c54f1b0fb4a85cef000e11a3804f25d2165f14afa0daa67f3f6" dependencies = [ "accesskit", "accesskit_atspi_common", @@ -115,13 +109,13 @@ dependencies = [ [[package]] name = "accesskit_windows" -version = "0.32.1" +version = "0.35.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eff7009f1a532e917d66970a1e80c965140c6cfbbabbdde3d64e5431e6c78e21" +checksum = "498668b90da9995302a0055aaecad4756932333da2ddef6baee1c00844c430fc" dependencies = [ "accesskit", - "accesskit_consumer 0.35.0", - "hashbrown 0.16.1", + "accesskit_consumer", + "hashbrown 0.17.1", "static_assertions", "windows", "windows-core", @@ -129,11 +123,12 @@ dependencies = [ [[package]] name = "accesskit_winit" -version = "0.32.2" +version = "0.34.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fe9a94394896352cc4660ca2288bd4ef883d83238853c038b44070c8f134313" +checksum = "cd4ab4437b520ff03088fbe48676a11e7d44a929535e64b31432c1073e48dbcc" dependencies = [ "accesskit", + "accesskit_ios", "accesskit_macos", "accesskit_unix", "accesskit_windows", @@ -224,6 +219,7 @@ dependencies = [ "parking_lot", "percent-encoding", "windows-sys 0.61.2", + "wl-clipboard-rs", "x11rb", ] @@ -347,7 +343,7 @@ checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -382,7 +378,7 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -441,10 +437,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] -name = "basic-udev" -version = "0.1.2" +name = "base64" +version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a45f9771ced8a774de5e5ebffbe520f52e3943bf5a9a6baa3a5d14a5de1afe6" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "basic-udev" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "868a8681ed3a19fae3e302c705b1cabf5be53278cc8720a9b82e9805c9ab171d" [[package]] name = "bit-set" @@ -476,6 +478,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.5.1" @@ -530,7 +541,7 @@ checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -553,13 +564,16 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadcraft" -version = "0.12.0" +version = "0.13.0" dependencies = [ "cadcraft-engine", "cadcraft-io", "cadcraft-mcp", "cadcraft-ui-egui", "chrono", + "craft-ui", + "craft-update", + "craft-window", "eframe", "egui", "hidapi", @@ -577,7 +591,7 @@ dependencies = [ [[package]] name = "cadcraft-cli" -version = "0.12.0" +version = "0.13.0" dependencies = [ "cadcraft-engine", "cadcraft-io", @@ -588,14 +602,14 @@ dependencies = [ [[package]] name = "cadcraft-color" -version = "0.12.0" +version = "0.13.0" dependencies = [ "serde", ] [[package]] name = "cadcraft-constraints" -version = "0.12.0" +version = "0.13.0" dependencies = [ "cadcraft-doc", "cadcraft-geom", @@ -605,7 +619,7 @@ dependencies = [ [[package]] name = "cadcraft-doc" -version = "0.12.0" +version = "0.13.0" dependencies = [ "cadcraft-color", "cadcraft-geom", @@ -616,14 +630,14 @@ dependencies = [ [[package]] name = "cadcraft-dwg" -version = "0.12.0" +version = "0.13.0" dependencies = [ "acadrust", ] [[package]] name = "cadcraft-dxf" -version = "0.12.0" +version = "0.13.0" dependencies = [ "encoding_rs", "log", @@ -632,7 +646,7 @@ dependencies = [ [[package]] name = "cadcraft-engine" -version = "0.12.0" +version = "0.13.0" dependencies = [ "cadcraft-color", "cadcraft-constraints", @@ -653,7 +667,7 @@ dependencies = [ [[package]] name = "cadcraft-fonts" -version = "0.12.0" +version = "0.13.0" dependencies = [ "cadcraft-geom", "harfrust 0.14.0", @@ -663,14 +677,14 @@ dependencies = [ [[package]] name = "cadcraft-geom" -version = "0.12.0" +version = "0.13.0" dependencies = [ "serde", ] [[package]] name = "cadcraft-io" -version = "0.12.0" +version = "0.13.0" dependencies = [ "acadrust", "cadcraft-color", @@ -691,7 +705,7 @@ dependencies = [ [[package]] name = "cadcraft-mcp" -version = "0.12.0" +version = "0.13.0" dependencies = [ "cadcraft-engine", "cadcraft-io", @@ -703,7 +717,7 @@ dependencies = [ [[package]] name = "cadcraft-render" -version = "0.12.0" +version = "0.13.0" dependencies = [ "cadcraft-color", "cadcraft-doc", @@ -717,7 +731,7 @@ dependencies = [ [[package]] name = "cadcraft-ui-egui" -version = "0.12.0" +version = "0.13.0" dependencies = [ "cadcraft-color", "cadcraft-doc", @@ -727,6 +741,8 @@ dependencies = [ "cadcraft-render", "chrono", "craft-ui", + "craft-update", + "craft-window", "egui", "egui-wgpu", "egui_kittest", @@ -736,11 +752,12 @@ dependencies = [ "serde", "serde_json", "toml", + "web-time", ] [[package]] name = "cadcraft-web" -version = "0.12.0" +version = "0.13.0" dependencies = [ "cadcraft-engine", "cadcraft-io", @@ -806,9 +823,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.6.0" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630" +checksum = "86e055fa6e3e40ad6187ba25b6855eeb635d5271f8e0f0e1e9a5826b8d75bea0" dependencies = [ "find-msvc-tools", "jobserver", @@ -848,9 +865,9 @@ dependencies = [ [[package]] name = "clipboard-win" -version = "5.4.1" +version = "5.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +checksum = "052af7878466feccda12ba7a6a67d019d712f296809559b47c12c8cc196d3a9a" dependencies = [ "error-code", ] @@ -893,9 +910,9 @@ dependencies = [ [[package]] name = "combine" -version = "4.6.8" +version = "4.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +checksum = "4356eba6976435498d05a311ee9fc486da3bc81c200d3f0d7dd9703b41913f92" dependencies = [ "bytes", "memchr", @@ -920,6 +937,16 @@ dependencies = [ "libc", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -933,7 +960,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" dependencies = [ "bitflags 1.3.2", - "core-foundation", + "core-foundation 0.9.4", "core-graphics-types", "foreign-types", "libc", @@ -946,7 +973,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" dependencies = [ "bitflags 1.3.2", - "core-foundation", + "core-foundation 0.9.4", "libc", ] @@ -956,15 +983,24 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "craft-docpath" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63" +source = "git+https://github.com/storytold/craft-libs?rev=9f89fe8f6d72ab4db4cc06f186381a6a254463ab#9f89fe8f6d72ab4db4cc06f186381a6a254463ab" [[package]] name = "craft-pdf" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=7895f5ed79da2f1271b10139cb84d61d4cbc50ca#7895f5ed79da2f1271b10139cb84d61d4cbc50ca" +source = "git+https://github.com/storytold/craft-libs?rev=9f89fe8f6d72ab4db4cc06f186381a6a254463ab#9f89fe8f6d72ab4db4cc06f186381a6a254463ab" dependencies = [ "hayro-syntax", "krilla", @@ -974,17 +1010,57 @@ dependencies = [ [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675" +source = "git+https://github.com/storytold/craft-libs?rev=9f89fe8f6d72ab4db4cc06f186381a6a254463ab#9f89fe8f6d72ab4db4cc06f186381a6a254463ab" dependencies = [ "egui", + "log", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", "serde", ] +[[package]] +name = "craft-update" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=9f89fe8f6d72ab4db4cc06f186381a6a254463ab#9f89fe8f6d72ab4db4cc06f186381a6a254463ab" +dependencies = [ + "chrono", + "dunce", + "egui", + "flate2", + "getrandom 0.3.4", + "log", + "plist", + "semver", + "serde", + "serde_json", + "sha2", + "tar", + "tempfile", + "ureq", + "verifysign", + "winreg", + "winsafe", + "zip", +] + +[[package]] +name = "craft-window" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=9f89fe8f6d72ab4db4cc06f186381a6a254463ab#9f89fe8f6d72ab4db4cc06f186381a6a254463ab" +dependencies = [ + "egui", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "wayland-client", +] + [[package]] name = "crc32fast" -version = "1.5.2" +version = "1.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055" dependencies = [ "cfg-if", ] @@ -1029,12 +1105,28 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "cursor-icon" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" +[[package]] +name = "deranged" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" + [[package]] name = "dify" version = "0.8.0" @@ -1048,6 +1140,16 @@ dependencies = [ "rayon", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "dispatch" version = "0.2.0" @@ -1074,7 +1176,7 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -1108,10 +1210,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" [[package]] -name = "ecolor" -version = "0.36.2" +name = "dunce" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0656a7530b9e2841ee7a5f983965bdd9840c32f4012f759cfd44b912cced6b34" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "ecolor" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "bytemuck", "emath", @@ -1120,9 +1227,8 @@ dependencies = [ [[package]] name = "eframe" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55ad41fac6e3149abb4866e93df37dbd33baa95db525c3368c944584e95a5dc2" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "bytemuck", @@ -1145,6 +1251,7 @@ dependencies = [ "profiling", "raw-window-handle", "ron", + "send_wrapper", "serde", "static_assertions", "wasm-bindgen", @@ -1157,9 +1264,8 @@ dependencies = [ [[package]] name = "egui" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc938cc27cd911415e1e4d151bc56ff9df063d8db081374e87aeb805ea74b2ba" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "accesskit", "ahash", @@ -1179,9 +1285,8 @@ dependencies = [ [[package]] name = "egui-wgpu" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cef1f6d1bba40b09a4d0d62cb822b81030922cfbf65c5373398c3a3caafc5bd" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "bytemuck", @@ -1199,32 +1304,35 @@ dependencies = [ [[package]] name = "egui-winit" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98466000559d66ba4db786a30448b01483946add4188e7f10f522a27b4fb89cc" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "accesskit_winit", "arboard", + "block2 0.6.2", "bytemuck", "egui", + "image", "log", "objc2 0.6.5", + "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "objc2-ui-kit 0.3.2", + "parking_lot", "profiling", "raw-window-handle", "serde", "smithay-clipboard", "web-time", "webbrowser", + "windows-sys 0.61.2", "winit", ] [[package]] name = "egui_glow" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "606ed4167daf341e5753d703285c4c1d562324b2c07aa8f405cd7d066fe58f3b" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "bytemuck", "egui", @@ -1236,9 +1344,8 @@ dependencies = [ [[package]] name = "egui_kittest" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb3f3effb2726fee23449293e67acbf6cc6ce35525b1722bd5743bb348114d5" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "dify", "eframe", @@ -1263,9 +1370,8 @@ checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" [[package]] name = "emath" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52c8d4141cf0f60fb2f7aefaf352d3ffa5171ef0ab09731c64391801e95e48f1" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "bytemuck", "serde", @@ -1325,9 +1431,8 @@ dependencies = [ [[package]] name = "epaint" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0ec0308dc23130fc1a7d4623a8534c2ed069c6dc84e258ee4f3de99615aa1a3" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "bytemuck", @@ -1344,22 +1449,23 @@ dependencies = [ "serde", "skrifa 0.44.0", "smallvec", + "unicode-bidi", "unicode-general-category", + "unicode-properties", "unicode-segmentation", "vello_cpu", ] [[package]] name = "epaint_default_fonts" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773fa9c96dd0dbef887e39d0ed6177f141cce4d68e6041df77570aa3702dfa13" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" [[package]] name = "equivalent" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" [[package]] name = "errno" @@ -1434,10 +1540,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" [[package]] -name = "find-msvc-tools" -version = "0.1.14" +name = "filetime" +version = "0.2.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" [[package]] name = "flate2" @@ -1465,6 +1587,12 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8bf7cc16383c4b8d58b9905a8509f02926ce3058053c056376248d958c9df1e8" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foldhash" version = "0.2.0" @@ -1508,7 +1636,7 @@ checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -1559,7 +1687,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -1581,6 +1709,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "gethostname" version = "1.1.0" @@ -1600,6 +1738,17 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -1619,8 +1768,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", + "wasm-bindgen", ] [[package]] @@ -1646,12 +1797,12 @@ dependencies = [ [[package]] name = "glifo" -version = "0.2.0" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a" +checksum = "282a26c1e23de04bdab3e34a21b6f877a479b96737792516b9b8b8f69b6661be" dependencies = [ "bytemuck", - "foldhash", + "foldhash 0.2.0", "hashbrown 0.17.1", "log", "peniko", @@ -1806,6 +1957,15 @@ dependencies = [ "byteorder", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -1814,7 +1974,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -1823,7 +1983,7 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -1835,7 +1995,7 @@ checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a" [[package]] name = "hayro-jbig2" version = "0.3.0" -source = "git+https://github.com/storytold/craft-libs?rev=7895f5ed79da2f1271b10139cb84d61d4cbc50ca#7895f5ed79da2f1271b10139cb84d61d4cbc50ca" +source = "git+https://github.com/storytold/craft-libs?rev=9f89fe8f6d72ab4db4cc06f186381a6a254463ab#9f89fe8f6d72ab4db4cc06f186381a6a254463ab" dependencies = [ "hayro-ccitt", ] @@ -1849,7 +2009,7 @@ checksum = "c75ab947623ef4ccaa7acf0579edf7cbb5a73838e3839a7be73335e522f433a1" [[package]] name = "hayro-syntax" version = "0.7.2" -source = "git+https://github.com/storytold/craft-libs?rev=7895f5ed79da2f1271b10139cb84d61d4cbc50ca#7895f5ed79da2f1271b10139cb84d61d4cbc50ca" +source = "git+https://github.com/storytold/craft-libs?rev=9f89fe8f6d72ab4db4cc06f186381a6a254463ab#9f89fe8f6d72ab4db4cc06f186381a6a254463ab" dependencies = [ "hayro-jbig2", "hayro-jpeg2000", @@ -1891,16 +2051,32 @@ dependencies = [ "cc", "cfg-if", "libc", - "nix", + "nix 0.30.1", "pkg-config", "windows-sys 0.61.2", ] [[package]] -name = "icu_collections" -version = "2.3.0" +name = "http" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "icu_collections" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd" dependencies = [ "displaydoc", "potential_utf", @@ -1912,9 +2088,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0" dependencies = [ "displaydoc", "litemap", @@ -1925,9 +2101,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -1939,15 +2115,15 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" +checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc" [[package]] name = "icu_properties" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f" dependencies = [ "displaydoc", "icu_collections", @@ -1960,15 +2136,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" +checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf" [[package]] name = "icu_provider" -version = "2.3.1" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514" dependencies = [ "displaydoc", "icu_locale_core", @@ -2184,20 +2360,20 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" [[package]] name = "kittest" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90ceaa75eb0036a32b6b9833962eb18137449e9817e2e586006471925b727fd5" +checksum = "0bb81a721ba76aa095b7f45deb180504f786904e56ca4b8e141f66eb89a78b75" dependencies = [ "accesskit", - "accesskit_consumer 0.35.0", + "accesskit_consumer", ] [[package]] name = "krilla" version = "0.8.2" -source = "git+https://github.com/storytold/craft-libs?rev=7895f5ed79da2f1271b10139cb84d61d4cbc50ca#7895f5ed79da2f1271b10139cb84d61d4cbc50ca" +source = "git+https://github.com/storytold/craft-libs?rev=9f89fe8f6d72ab4db4cc06f186381a6a254463ab#9f89fe8f6d72ab4db4cc06f186381a6a254463ab" dependencies = [ - "base64", + "base64 0.22.1", "bumpalo", "flate2", "float-cmp", @@ -2267,7 +2443,7 @@ dependencies = [ "bitflags 2.13.2", "libc", "plain", - "redox_syscall 0.9.4", + "redox_syscall 0.9.5", ] [[package]] @@ -2296,9 +2472,9 @@ checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] name = "litrs" -version = "1.0.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" +checksum = "4744e383959f0db86ede514b809b1c53251889093803c05267acc7d4e7030d70" [[package]] name = "lock_api" @@ -2317,9 +2493,9 @@ checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] name = "lyon_geom" -version = "1.0.19" +version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4336502e29e32af93cf2dad2214ed6003c17ceb5bd499df77b1de663b9042b92" +checksum = "d82dd26aa4dc0b5b70b9115de59824842bcb43cf4a28e1b1e6d0ad148c93f93e" dependencies = [ "arrayvec", "euclid", @@ -2338,9 +2514,9 @@ dependencies = [ [[package]] name = "lyon_tessellation" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43b8dcf906637ecef61b3c0740c7a4e7f27caeb31257cfac0cc579ce15be6005" +checksum = "fd4872650e70dc907dd4f7ebc449d7d657a0d2658e423b2e724970870bbbf75b" dependencies = [ "float_next_after", "lyon_path", @@ -2355,9 +2531,9 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "memmap2" -version = "0.9.11" +version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +checksum = "6b0ca1155a9ecc25aa12e8313a119f32edf0255fea2f44839c4ac0a12b2ac157" dependencies = [ "libc", ] @@ -2511,6 +2687,18 @@ dependencies = [ "libc", ] +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nohash-hasher" version = "0.2.0" @@ -2527,6 +2715,21 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "num-traits" version = "0.2.19" @@ -2887,14 +3090,20 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "open" -version = "5.4.4" +version = "5.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa576c76302b7b808eecc68061e67336c47833ef9d22caa74dda10fa9675eebc" +checksum = "8d8c8b8e725ad26404d6896eb2f064294a0a7b4f1d9120210d85544684c4ca95" dependencies = [ "is-wsl", "libc", ] +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "orbclient" version = "0.3.55" @@ -2907,9 +3116,9 @@ dependencies = [ [[package]] name = "ordered-float" -version = "5.5.0" +version = "5.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c7c9e0d9b23589f26070720bac724174bfec1083e82f7854cdd0267518343c0" +checksum = "7f59006ce1e204c30956638535b74d317bca99477b9356dfaec54c37f6f600ab" dependencies = [ "num-traits", ] @@ -2924,6 +3133,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "parking" version = "2.2.1" @@ -2984,6 +3203,17 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + [[package]] name = "phf" version = "0.11.3" @@ -2995,33 +3225,33 @@ dependencies = [ [[package]] name = "phf" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" dependencies = [ "phf_macros", - "phf_shared 0.13.1", + "phf_shared 0.14.0", "serde", ] [[package]] name = "phf_generator" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100" dependencies = [ "fastrand", - "phf_shared 0.13.1", + "phf_shared 0.14.0", ] [[package]] name = "phf_macros" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085" dependencies = [ "phf_generator", - "phf_shared 0.13.1", + "phf_shared 0.14.0", "proc-macro2", "quote", "syn 2.0.119", @@ -3038,9 +3268,9 @@ dependencies = [ [[package]] name = "phf_shared" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89" dependencies = [ "siphasher", ] @@ -3094,6 +3324,19 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "plist" +version = "1.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "327a4cb2ad49dc48adfdc9da89e22c64d488b508c67a044d71ba729e4521a285" +dependencies = [ + "base64 0.23.1", + "indexmap", + "quick-xml 0.42.0", + "serde", + "time", +] + [[package]] name = "png" version = "0.18.1" @@ -3166,6 +3409,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" + [[package]] name = "presser" version = "0.3.1" @@ -3319,7 +3568,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4edc8db0d972de88e53a085b1f01fb3fbdae7bb0cb7a0ce2b2f8285a412fe3ef" dependencies = [ "bytemuck", - "foldhash", + "foldhash 0.2.0", "font-types 0.12.7", "once_cell", "yoke", @@ -3345,9 +3594,9 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.9.4" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "737970939a87c6fa31e7acad13307bccbb017a073b695b6089a2c484f929e20e" +checksum = "580c875845b41ecb1564ad1ae4703044cef774145e3d7a94dc2b3fa7c97868fd" dependencies = [ "bitflags 2.13.2", ] @@ -3385,6 +3634,20 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "ron" version = "0.12.2" @@ -3457,6 +3720,80 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" + +[[package]] +name = "rustls-webpki" +version = "0.103.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -3478,6 +3815,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scoped-tls" version = "1.0.1" @@ -3490,6 +3836,29 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "self_cell" version = "1.3.0" @@ -3501,6 +3870,16 @@ name = "semver" version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" [[package]] name = "serde" @@ -3529,7 +3908,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -3553,18 +3932,29 @@ checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] name = "serde_spanned" -version = "1.1.1" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" dependencies = [ "serde_core", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shlex" version = "2.0.1" @@ -3656,9 +4046,9 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.16.2" +version = "1.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" +checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" dependencies = [ "serde", ] @@ -3785,6 +4175,12 @@ dependencies = [ "write-fonts", ] +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.119" @@ -3798,9 +4194,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.6" +version = "3.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" dependencies = [ "proc-macro2", "quote", @@ -3815,7 +4211,7 @@ checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -3827,6 +4223,16 @@ dependencies = [ "libc", ] +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -3886,7 +4292,7 @@ checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -3903,6 +4309,36 @@ dependencies = [ "zune-jpeg", ] +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tiny-skia" version = "0.12.0" @@ -3931,9 +4367,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.4" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a" dependencies = [ "displaydoc", "zerovec", @@ -3947,18 +4383,18 @@ checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" [[package]] name = "tokio" -version = "1.53.2" +version = "1.54.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" +checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" dependencies = [ "pin-project-lite", ] [[package]] name = "toml" -version = "1.1.6+spec-1.1.0" +version = "1.1.8+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" +checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d" dependencies = [ "indexmap", "serde_core", @@ -3971,18 +4407,18 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "1.1.1+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" dependencies = [ "serde_core", ] [[package]] name = "toml_edit" -version = "0.25.15+spec-1.1.0" +version = "0.25.17+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" +checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c" dependencies = [ "indexmap", "toml_datetime", @@ -3992,18 +4428,18 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.1.3+spec-1.1.0" +version = "1.1.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" dependencies = [ "winnow", ] [[package]] name = "toml_writer" -version = "1.1.2+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" +checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" [[package]] name = "tracing" @@ -4037,6 +4473,17 @@ dependencies = [ "once_cell", ] +[[package]] +name = "tree_magic_mini" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" +dependencies = [ + "memchr", + "nom 8.0.0", + "petgraph", +] + [[package]] name = "type-map" version = "0.5.1" @@ -4046,12 +4493,24 @@ dependencies = [ "rustc-hash 2.1.3", ] +[[package]] +name = "typed-path" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce898823d8cd0f028662d2f349694b1af0a5cc9be83f1d213a00d3147445e1e" + [[package]] name = "typeid" version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "uds_windows" version = "1.2.1" @@ -4077,9 +4536,9 @@ checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" [[package]] name = "unicode-ident" -version = "1.0.26" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" [[package]] name = "unicode-normalization" @@ -4108,6 +4567,41 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cde93ff05758a36a851a398a772b0b79a82fbe3c186a46f2696baaeaf84c966b" +dependencies = [ + "base64 0.23.1", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "ureq-proto", + "utf8-zero", +] + +[[package]] +name = "ureq-proto" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841583fc36089d7d59419568ae157f43a5a03a2e5a81ba87c77d1596ef46e922" +dependencies = [ + "base64 0.23.1", + "http", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.8" @@ -4120,6 +4614,12 @@ dependencies = [ "serde", ] +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -4128,9 +4628,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.27.0" +version = "1.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97277d36b9c3ace13e58fa6e753f8b0bbbf302a18dd193240d70f9e29681059a" +checksum = "4a0e2d11620c50695fe12bbbfde96b3477fe606e7284c22d505c12ce51214514" dependencies = [ "js-sys", "serde_core", @@ -4139,9 +4639,9 @@ dependencies = [ [[package]] name = "vello_common" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2e9aed918117e8152c9eddfd8362d73c465c23f26c44786aa331707b8a64fa2" +checksum = "bbb2141a2bca6e6d598e471fd4d1d7eed8e020aad6a28187edda07f091a325dd" dependencies = [ "bytemuck", "fearless_simd", @@ -4154,9 +4654,9 @@ dependencies = [ [[package]] name = "vello_cpu" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac7349e1f55f6b801c7c277958df4ea53e7f20f21e8014910ad888b2ecda93ea" +checksum = "ace506ca414548966fcf3c283be94eb1cad8254de488791ee698bf438b0890a9" dependencies = [ "bytemuck", "glifo", @@ -4164,6 +4664,16 @@ dependencies = [ "vello_common", ] +[[package]] +name = "verifysign" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6acfcc42a8d44f340d001193d97ef56bf33210b51ad760ae8308c97d5241ca51" +dependencies = [ + "core-foundation 0.10.1", + "windows-sys 0.61.2", +] + [[package]] name = "version_check" version = "0.9.5" @@ -4180,6 +4690,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "wasip2" version = "1.0.4+wasi-0.2.12" @@ -4232,7 +4748,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "wasm-bindgen-shared", ] @@ -4402,9 +4918,9 @@ dependencies = [ [[package]] name = "webbrowser" -version = "1.2.4" +version = "1.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62c35be770821a214dbc362fc26908c853e776c0004294d0b10b8a6bad582f94" +checksum = "d2c8ddfbbf66e501434657400268cefe51b95140a0266236415cda7627732df7" dependencies = [ "jni", "log", @@ -4416,6 +4932,15 @@ dependencies = [ "web-sys", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e49d0e65abc00c142e8f9428da991da9f1a250c39be97459901eb12b9b49f62a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "weezl" version = "0.1.12" @@ -4818,8 +5343,7 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "winit" version = "0.30.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +source = "git+https://github.com/storytold/winit.git?rev=a517b3846ee8457d84ee4975110bfda6797c44dc#a517b3846ee8457d84ee4975110bfda6797c44dc" dependencies = [ "ahash", "android-activity", @@ -4830,7 +5354,7 @@ dependencies = [ "calloop 0.13.0", "cfg_aliases", "concurrent-queue", - "core-foundation", + "core-foundation 0.9.4", "core-graphics", "cursor-icon", "dpi", @@ -4875,6 +5399,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + [[package]] name = "winresource" version = "0.1.34" @@ -4885,12 +5419,36 @@ dependencies = [ "version_check", ] +[[package]] +name = "winsafe" +version = "0.0.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ef0ffc427f045c0cc9ebffd6f4f91153dcd2a1547b5c3c29ee3f541e16e95c6" + [[package]] name = "wit-bindgen" version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "wl-clipboard-rs" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d7888ccd4896447b2d14d3a9350a85df2aeb6f181e2e7a31349d104ac46cac1" +dependencies = [ + "libc", + "log", + "os_pipe", + "rustix 1.1.5", + "thiserror 2.0.21", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", +] + [[package]] name = "write-fonts" version = "0.48.1" @@ -4981,7 +5539,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" [[package]] name = "xtask" -version = "0.12.0" +version = "0.13.0" dependencies = [ "chrono", "serde_json", @@ -4989,9 +5547,9 @@ dependencies = [ [[package]] name = "yoke" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -5006,7 +5564,7 @@ checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "synstructure", ] @@ -5078,7 +5636,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "zbus_names", "zvariant", "zvariant_utils", @@ -5086,9 +5644,9 @@ dependencies = [ [[package]] name = "zbus_names" -version = "4.3.4" +version = "4.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +checksum = "812a12be4ead7a59e18ea8f047d08add0066b73bc62fc1467066a2472ae088dd" dependencies = [ "serde", "winnow", @@ -5118,18 +5676,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.61" +version = "0.8.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "879fb705ce98c32e41ebdb970fbe1204f8492423b314c6ab0354c3e7b5542866" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.61" +version = "0.8.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "708882a28301d604fa039cc7727607a98d04c4b86dc76ec9cc683f805d709759" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" dependencies = [ "proc-macro2", "quote", @@ -5153,10 +5711,16 @@ checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "synstructure", ] +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" + [[package]] name = "zerotrie" version = "0.2.5" @@ -5170,9 +5734,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.8" +version = "0.11.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40" dependencies = [ "yoke", "zerofrom", @@ -5181,13 +5745,30 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.6" +version = "0.11.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", +] + +[[package]] +name = "zip" +version = "9.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "518b847fd864440c379748ad684c449f293af68978a485a4ff16f7c901c1f7c4" +dependencies = [ + "crc32fast", + "flate2", + "getrandom 0.4.3", + "indexmap", + "memchr", + "nix 0.31.3", + "typed-path", + "windows-sys 0.61.2", + "zopfli", ] [[package]] @@ -5202,6 +5783,18 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" +[[package]] +name = "zopfli" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + [[package]] name = "zune-core" version = "0.5.3" @@ -5219,9 +5812,9 @@ dependencies = [ [[package]] name = "zvariant" -version = "5.15.0" +version = "5.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" +checksum = "80bf6d6cd6a4b601acb64cfb8284cdc643fcfa75ef26ae3c46ec8aa4e8a1fbb7" dependencies = [ "endi", "enumflags2", @@ -5234,14 +5827,14 @@ dependencies = [ [[package]] name = "zvariant_derive" -version = "5.15.0" +version = "5.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" +checksum = "ef2672456ba1cb54b5eaec136e63135b3c7495fb938e53b28a62e633496d486d" dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "zvariant_utils", ] @@ -5254,6 +5847,26 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn 3.0.6", + "syn 3.0.9", "winnow", ] + +[[patch.unused]] +name = "egui_demo_lib" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" + +[[patch.unused]] +name = "egui_extras" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" + +[[patch.unused]] +name = "egui_inspection" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" + +[[patch.unused]] +name = "egui_system_font_provider" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" diff --git a/Cargo.toml b/Cargo.toml index d3ea9a09a..db0d88092 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,10 +4,10 @@ members = ["crates/*", "apps/*", "xtask"] # default-members [workspace.package] -version = "0.12.0" +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 = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" } +craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "9f89fe8f6d72ab4db4cc06f186381a6a254463ab" } # 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 = "7895f5ed79da2f1271b10139cb84d61d4cbc50ca", default-features = false } +craft-pdf = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "9f89fe8f6d72ab4db4cc06f186381a6a254463ab", default-features = false } # foundation thiserror = "2" @@ -53,19 +53,18 @@ encoding_rs = "0.8.35" quick-xml = "0.42" # ui -craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", 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 = "9f89fe8f6d72ab4db4cc06f186381a6a254463ab", features = ["serde"] } +craft-window = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "9f89fe8f6d72ab4db4cc06f186381a6a254463ab", features = ["egui"] } +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 = "9f89fe8f6d72ab4db4cc06f186381a6a254463ab", 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 = "7895f5ed79da2f1271b10139cb84d61d4cbc50ca" } -hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "7895f5ed79da2f1271b10139cb84d61d4cbc50ca" } -hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "7895f5ed79da2f1271b10139cb84d61d4cbc50ca" } - [workspace.lints.rust] unsafe_code = "forbid" @@ -88,3 +87,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 = "9f89fe8f6d72ab4db4cc06f186381a6a254463ab" } +hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "9f89fe8f6d72ab4db4cc06f186381a6a254463ab" } +hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "9f89fe8f6d72ab4db4cc06f186381a6a254463ab" } + +# 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" } diff --git a/NOTICE b/NOTICE index 22163da72..2eb24b221 100644 --- a/NOTICE +++ b/NOTICE @@ -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/9f89fe8f6d72ab4db4cc06f186381a6a254463ab/vendor/krilla/icc). +CC0 1.0 Universal: https://creativecommons.org/publicdomain/zero/1.0/ +Pinned licence text: https://github.com/storytold/craft-libs/blob/9f89fe8f6d72ab4db4cc06f186381a6a254463ab/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. diff --git a/README.md b/README.md index 72fcdc7f8..dd0abd25d 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/apps/cadcraft-cli/src/main.rs b/apps/cadcraft-cli/src/main.rs index 32c781ec4..99a8b4282 100644 --- a/apps/cadcraft-cli/src/main.rs +++ b/apps/cadcraft-cli/src/main.rs @@ -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) -> 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)) } diff --git a/apps/cadcraft-web/Cargo.toml b/apps/cadcraft-web/Cargo.toml index 4fb706d77..e5e86a0e3 100644 --- a/apps/cadcraft-web/Cargo.toml +++ b/apps/cadcraft-web/Cargo.toml @@ -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"] } diff --git a/apps/cadcraft-web/src/host.rs b/apps/cadcraft-web/src/host.rs index 03fe15b22..c8d6380c6 100644 --- a/apps/cadcraft-web/src/host.rs +++ b/apps/cadcraft-web/src/host.rs @@ -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())]) { diff --git a/apps/cadcraft-web/src/main.rs b/apps/cadcraft-web/src/main.rs index 035e1d6cc..f69e02d8a 100644 --- a/apps/cadcraft-web/src/main.rs +++ b/apps/cadcraft-web/src/main.rs @@ -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); } } diff --git a/apps/cadcraft-web/src/upload.rs b/apps/cadcraft-web/src/upload.rs index 1e5b32706..0b567f8b7 100644 --- a/apps/cadcraft-web/src/upload.rs +++ b/apps/cadcraft-web/src/upload.rs @@ -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()); } diff --git a/apps/cadcraft/Cargo.toml b/apps/cadcraft/Cargo.toml index 7a46fa9bc..48c2c2d15 100644 --- a/apps/cadcraft/Cargo.toml +++ b/apps/cadcraft/Cargo.toml @@ -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,10 +34,16 @@ 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] +# Floating panels open in their own OS windows on the desktop; the web build keeps them in the page. +craft-ui = { workspace = true, features = ["native-windows"] } +craft-update = { workspace = true } rfd = { workspace = true } sys-locale = "0.3" [target.'cfg(target_os = "macos")'.dependencies] +# `title_bar::sync_movable`: AppKit's title-band drag leaves the window still under the row's +# controls and under floating panels. +craft-window = { workspace = true } muda = { version = "0.21", default-features = false } # View ▸ Accessibility ▸ Animation Effects ▸ Use System Setting: the system's Reduce motion # (system_animations.rs). Already in Cargo.lock through muda. diff --git a/apps/cadcraft/src/main.rs b/apps/cadcraft/src/main.rs index e80b838b1..1007fc0c1 100644 --- a/apps/cadcraft/src/main.rs +++ b/apps/cadcraft/src/main.rs @@ -39,11 +39,22 @@ struct App { workspace_error: Option, #[cfg(target_os = "macos")] native_menu: Option, + /// The `movable` flag last set on the main window. + #[cfg(target_os = "macos")] + movable: Option, } impl App { - fn persist_workspace(&mut self, final_attempt: bool) { - let result = if final_attempt { self.workspace.save_on_exit(&self.cad.ui) } else { self.workspace.save_if_changed(&self.cad.ui) }; + /// `ctx` is `None` only for the final attempt at exit. + fn persist_workspace(&mut self, ctx: Option<&egui::Context>) { + let result = match ctx { + None => self.workspace.save_on_exit(&self.cad.ui), + Some(ctx) => self.workspace.save_if_changed(&self.cad.ui, std::time::Instant::now()).map(|wait| { + if let Some(wait) = wait { + ctx.request_repaint_after(wait); + } + }), + }; if let Err(error) = result { if self.workspace_error.as_ref() != Some(&error) { eprintln!("CADCraft workspace: {error}"); @@ -65,37 +76,49 @@ fn follow_desktop(raw: &mut egui::RawInput, desktop: Option) { } 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(Some(ctx)); + } 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); + #[cfg(target_os = "macos")] + craft_window::title_bar::sync_movable(ui.ctx(), &mut self.movable); } /// 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.persist_workspace(None); + self.cad.cancel_screenshots(); self.cad.shutdown_autosave(); } /// Preferences (the interface theme) survive restarts; window and egui state are not kept. @@ -232,9 +255,19 @@ fn control_flag(current: Option, value: Option) -> Option { 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::>()) { + 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 +313,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 +374,7 @@ fn main() -> eframe::Result { // Autosave files (SAVEFILEPATH) default to `/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 `/support`; the // folder is made so it is there to put them in. let support = dir.join("support"); @@ -381,6 +418,8 @@ fn main() -> eframe::Result { workspace_error, #[cfg(target_os = "macos")] native_menu: None, + #[cfg(target_os = "macos")] + movable: None, })) }), ) diff --git a/apps/cadcraft/src/native_menu.rs b/apps/cadcraft/src/native_menu.rs index 727043558..8649ed8e4 100644 --- a/apps/cadcraft/src/native_menu.rs +++ b/apps/cadcraft/src/native_menu.rs @@ -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, counter: usize, + events: mpsc::Receiver, } /// Accelerator for "Cmd+Shift+S" (modifier-less shortcuts stay in the app so typing works). @@ -30,7 +35,16 @@ fn accel(sc: &str) -> Option { } 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,13 +111,14 @@ 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")); } else { - menus::activate(app, &cmd.clone()); + menus::activate_native(app, ctx, &cmd.clone()); } ctx.request_repaint(); } @@ -113,17 +138,25 @@ 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; + let typing = menus::text_field_window(ctx).is_some(); for (cmd, _, item) in self.items.values() { - let en = cadcraft_engine::find_command(cmd).is_none_or(|c| (c.enabled)(&app.session).is_ok()); + let en = (typing && menus::edits_text(cmd)) || cadcraft_engine::find_command(cmd).is_none_or(|c| (c.enabled)(&app.session).is_ok()); item.set_enabled(en); } } } +/// 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 +183,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)); + } +} diff --git a/apps/cadcraft/src/workspace.rs b/apps/cadcraft/src/workspace.rs index e2a429d94..b8e541ed3 100644 --- a/apps/cadcraft/src/workspace.rs +++ b/apps/cadcraft/src/workspace.rs @@ -5,11 +5,15 @@ use std::fs::{self, File, OpenOptions}; use std::io::{Read, Write}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{Duration, Instant}; use cadcraft_ui_egui::UiState; use serde_json::{Value, json}; const MAX_BYTES: u64 = 64 * 1024; +/// Dragging a floating window changes its rect every frame; writes (each with an fsync) are +/// spaced at least this far apart, and the newest state is written when the interval ends. +const MIN_WRITE_INTERVAL: Duration = Duration::from_millis(500); static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0); pub struct Workspace { @@ -17,6 +21,7 @@ pub struct Workspace { last_attempt: Option, read_failed: bool, pending_write_error: Option, + last_write: Option, } fn config_dir_from(platform: &str, get: impl Fn(&str) -> Option) -> Option { @@ -32,7 +37,7 @@ fn config_dir_from(platform: &str, get: impl Fn(&str) -> Option) -> Op } fn snapshot(ui: &UiState) -> Result { - ui.docking.validate()?; + cadcraft_ui_egui::docking::validate(&ui.docking)?; if !matches!(ui.toolset_tab.as_str(), "Drafting" | "Modeling") { return Err("toolsetTab must be Drafting or Modeling".into()); } @@ -44,6 +49,7 @@ fn snapshot(ui: &UiState) -> Result { } Ok(json!({ "docking": ui.docking, + "dockingVersion": cadcraft_ui_egui::docking::LAYOUT_VERSION, "showToolsets": ui.show_toolsets, "showPalettes": ui.show_palettes, "showToolbar": ui.show_toolbar, @@ -80,12 +86,18 @@ fn read(path: &Path) -> Result, String> { value.get("workspace").filter(|value| value.is_object()).ok_or_else(|| format!("{}: missing workspace object", path.display()))?; // Dock schemas can evolve independently of the other saved workspace preferences. // Recover only this optional field; runtime UiState deserialization remains strict. + // A layout from before the current shape (split frames, saved without `dockingVersion`) + // gives way to the default, its hidden panels with it. let mut fields = workspace.clone(); - let docking = fields.as_object_mut().and_then(|fields| fields.remove("docking")); + let (docking, version) = match fields.as_object_mut() { + Some(fields) => (fields.remove("docking"), fields.remove("dockingVersion")), + None => (None, None), + }; + let current = version.and_then(|version| version.as_u64()).is_some_and(|version| version >= u64::from(cadcraft_ui_egui::docking::LAYOUT_VERSION)); let mut ui: UiState = serde_json::from_value(fields).map_err(|error| format!("{}: invalid workspace: {error}", path.display()))?; - if let Some(docking) = docking + if let Some(docking) = docking.filter(|_| current) && let Ok(docking) = serde_json::from_value::(docking) - && docking.validate().is_ok() + && cadcraft_ui_egui::docking::validate(&docking).is_ok() { ui.docking = docking; } @@ -136,38 +148,47 @@ impl Workspace { match result { Ok(ui) => { let initial = ui.as_ref().cloned().unwrap_or_default(); - (Self { path, last_attempt: snapshot(&initial).ok(), read_failed: false, pending_write_error: None }, ui, None) + (Self { path, last_attempt: snapshot(&initial).ok(), read_failed: false, pending_write_error: None, last_write: None }, ui, None) } Err(error) => ( - Self { path, last_attempt: None, read_failed: true, pending_write_error: None }, + Self { path, last_attempt: None, read_failed: true, pending_write_error: None, last_write: None }, None, Some(format!("Workspace could not be restored; its file was left unchanged. {error}")), ), } } - pub fn save_if_changed(&mut self, ui: &UiState) -> Result<(), String> { - let Some(path) = &self.path else { return Ok(()) }; + /// Writes `ui` if it differs from the last state written. `Ok(Some(wait))` means a change + /// is held back by [`MIN_WRITE_INTERVAL`]; call again after `wait` to write it. + pub fn save_if_changed(&mut self, ui: &UiState, now: Instant) -> Result, String> { + let Some(path) = &self.path else { return Ok(None) }; if self.read_failed { // Preserve a corrupt, oversized or newer-version file until the user repairs it. - return Ok(()); + return Ok(None); } let value = snapshot(ui)?; if self.last_attempt.as_ref() == Some(&value) { - return self.pending_write_error.clone().map_or(Ok(()), Err); + return self.pending_write_error.clone().map_or(Ok(None), Err); + } + if let Some(wait) = self.last_write.and_then(|last| MIN_WRITE_INTERVAL.checked_sub(now.saturating_duration_since(last))) + && !wait.is_zero() + { + return Ok(Some(wait)); } // Keep a failed state's warning without retrying disk I/O every rendered frame. self.last_attempt = Some(value.clone()); + self.last_write = Some(now); let result = write(path, &value); self.pending_write_error = result.as_ref().err().cloned(); - result + result.map(|()| None) } pub fn save_on_exit(&mut self, ui: &UiState) -> Result<(), String> { if self.pending_write_error.is_some() { self.last_attempt = None; } - self.save_if_changed(ui) + self.last_write = None; + self.save_if_changed(ui, Instant::now()).map(|_| ()) } } @@ -216,7 +237,7 @@ mod tests { start_tab: true, ..Default::default() }; - first.save_if_changed(&ui).unwrap(); + first.save_if_changed(&ui, Instant::now()).unwrap(); let bytes = fs::read(&path).unwrap(); let value: Value = serde_json::from_slice(&bytes).unwrap(); assert!(value["workspace"].get("dialog").is_none()); @@ -229,34 +250,68 @@ mod tests { let mut transient_only = ui.clone(); transient_only.dialog = None; transient_only.start_tab = false; - first.save_if_changed(&transient_only).unwrap(); + first.save_if_changed(&transient_only, Instant::now()).unwrap(); assert_eq!(fs::read(&path).unwrap(), bytes); fs::remove_dir_all(directory).unwrap(); } #[test] - fn docked_floating_and_hidden_panels_survive_native_workspace_file_reload() { - use cadcraft_ui_egui::docking::Panel; + fn dock_columns_floating_and_hidden_panels_survive_native_workspace_file_reload() { + use cadcraft_ui_egui::docking::{LAYOUT_VERSION, Panel, permissions, reveal_panel}; let directory = temp_dir(); let path = directory.join("workspace.json"); let (mut store, _, _) = Workspace::at(Some(path.clone())); let mut ui = UiState::default(); let mut app = cadcraft_ui_egui::CadApp::new(cadcraft_engine::Session::default(), Default::default()); - app.run("ui.dock", json!({"operation":"float", "panel":"layers", "rect":[120,90,340,410]})).unwrap(); + app.run("ui.dock", json!({"operation":"move", "panel":"layers", "target":"properties", "zone":"left"})).unwrap(); + app.run("ui.dock", json!({"operation":"float", "panel":"properties", "rect":[120,90,340,410]})).unwrap(); ui.docking = app.ui.docking; - ui.docking.set_visible(Panel::ToolSets, false).unwrap(); + ui.docking.set_visible(Panel::ToolSets, false, None, permissions).unwrap(); ui.show_toolsets = false; - store.save_if_changed(&ui).unwrap(); + store.save_if_changed(&ui, Instant::now()).unwrap(); + let saved: Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap(); + assert_eq!(saved["workspace"]["dockingVersion"], json!(LAYOUT_VERSION)); let (_, reloaded, error) = Workspace::at(Some(path)); assert!(error.is_none()); let mut reloaded = reloaded.unwrap(); assert_eq!(serde_json::to_value(&reloaded.docking).unwrap(), serde_json::to_value(&ui.docking).unwrap()); - reloaded.docking.set_visible(Panel::ToolSets, true).unwrap(); - assert!(reloaded.docking.layout.contains(&Panel::ToolSets)); + assert!(!reloaded.show_toolsets); + reveal_panel(&mut reloaded.docking, Panel::ToolSets).unwrap(); + let tools = reloaded.docking.layout.location(&Panel::ToolSets).unwrap(); + assert_eq!(serde_json::to_value(&tools.group).unwrap()["column"]["side"], json!("Start"), "{tools:?}"); assert_eq!(reloaded.docking.layout.floating[0].rect, [120.0, 90.0, 340.0, 410.0]); fs::remove_dir_all(directory).unwrap(); } + #[test] + fn split_frame_layout_file_loads_as_the_dock_default_with_its_other_settings() { + use cadcraft_ui_egui::docking::default_workspace; + // Written as CADCraft 0.7 writes it: craft-ui's v1 layout with no docking version, a + // floating window as one `{panels, active, rect}` group, Tool Sets closed. + let file = r#"{"version":1,"workspace":{ + "docking":{ + "layout":{ + "root":{"Split":{"axis":"Horizontal","size":{"FixedSecond":300.0}, + "first":{"Tabs":{"panels":["canvas"],"active":0}}, + "second":{"Stack":{"entries":[{"panel":"properties","open":false,"height":null}]}}}}, + "floating":[{"panels":["layers","externalReferences"],"active":1,"rect":[120.0,90.0,340.0,410.0]}]}, + "hidden":[["toolSets",{"floating":null,"anchor":"canvas","placement":{"Split":"Left"}, + "size":{"FixedFirst":220.0},"stack":null,"sibling":["canvas","properties"]}]]}, + "showToolsets":false,"showPalettes":true,"showToolbar":false,"toolsetTab":"Modeling","historyLines":4}}"#; + let directory = temp_dir(); + let path = directory.join("workspace.json"); + fs::write(&path, file).unwrap(); + let (_, restored, error) = Workspace::at(Some(path)); + assert!(error.is_none(), "{error:?}"); + let ui = restored.unwrap(); + assert_eq!(ui.toolset_tab, "Modeling"); + assert_eq!(ui.history_lines, 4); + assert!(!ui.show_toolsets && ui.show_palettes && !ui.show_toolbar); + assert_eq!(ui.docking.layout, default_workspace().layout); + assert!(ui.docking.hidden.is_empty(), "{:?}", ui.docking.hidden); + fs::remove_dir_all(directory).unwrap(); + } + #[test] fn malformed_oversized_and_newer_state_are_reported_and_preserved() { let directory = temp_dir(); @@ -272,7 +327,7 @@ mod tests { let (mut store, ui, error) = Workspace::at(Some(path.clone())); assert!(ui.is_none()); assert!(error.as_deref().is_some_and(|error| error.contains("left unchanged"))); - store.save_if_changed(&UiState { show_palettes: false, ..Default::default() }).unwrap(); + store.save_if_changed(&UiState { show_palettes: false, ..Default::default() }, Instant::now()).unwrap(); assert_eq!(fs::read(&path).unwrap(), bytes); } fs::remove_dir_all(directory).unwrap(); @@ -287,7 +342,8 @@ mod tests { json!({"layout":false,"hidden":[]}), json!({"layout":{"root":null,"floating":[]},"hidden":[["futurePanel",{}]]}), ] { - let workspace = json!({"showToolbar":false,"showPalettes":false,"toolsetTab":"Modeling", "collapsedGroups":["Draw"], "historyLines":7,"docking":docking}); + let workspace = json!({"showToolbar":false,"showPalettes":false,"toolsetTab":"Modeling", "collapsedGroups":["Draw"], "historyLines":7,"docking":docking, + "dockingVersion":cadcraft_ui_egui::docking::LAYOUT_VERSION}); assert!(serde_json::from_value::(workspace.clone()).is_err(), "runtime state must stay strict"); fs::write(&path, serde_json::to_vec(&json!({"version":1,"workspace":workspace})).unwrap()).unwrap(); let (_, restored, error) = Workspace::at(Some(path.clone())); @@ -303,16 +359,41 @@ mod tests { fs::remove_dir_all(directory).unwrap(); } + #[test] + fn rapid_changes_are_written_once_per_interval_with_the_newest_state() { + let directory = temp_dir(); + let path = directory.join("workspace.json"); + let (mut store, _, _) = Workspace::at(Some(path.clone())); + let start = Instant::now(); + let mut ui = UiState { history_lines: 1, ..Default::default() }; + assert_eq!(store.save_if_changed(&ui, start).unwrap(), None); + // A drag: a new state every frame, none of them written until the interval ends. + for (frame, lines) in (2..=5).enumerate() { + ui.history_lines = lines; + let now = start + Duration::from_millis(16 * (frame as u64 + 1)); + let wait = store.save_if_changed(&ui, now).unwrap(); + assert_eq!(wait, Some(MIN_WRITE_INTERVAL - (now - start))); + } + assert_eq!(read(&path).unwrap().unwrap().history_lines, 1); + assert_eq!(store.save_if_changed(&ui, start + MIN_WRITE_INTERVAL).unwrap(), None); + assert_eq!(read(&path).unwrap().unwrap().history_lines, 5); + ui.history_lines = 6; + assert!(store.save_if_changed(&ui, start + MIN_WRITE_INTERVAL).unwrap().is_some()); + store.save_on_exit(&ui).unwrap(); + assert_eq!(read(&path).unwrap().unwrap().history_lines, 6); + fs::remove_dir_all(directory).unwrap(); + } + #[test] fn invalid_update_cannot_replace_last_good_workspace() { let directory = temp_dir(); let path = directory.join("workspace.json"); let (mut store, _, _) = Workspace::at(Some(path.clone())); let mut ui = UiState { show_palettes: false, ..Default::default() }; - store.save_if_changed(&ui).unwrap(); + store.save_if_changed(&ui, Instant::now()).unwrap(); let good = fs::read(&path).unwrap(); ui.collapsed_groups = vec!["x".repeat(257)]; - assert!(store.save_if_changed(&ui).is_err()); + assert!(store.save_if_changed(&ui, Instant::now()).is_err()); assert_eq!(fs::read(&path).unwrap(), good); fs::remove_dir_all(directory).unwrap(); } @@ -325,9 +406,9 @@ mod tests { // Simulate a destination becoming unavailable after startup. fs::create_dir(&path).unwrap(); let ui = UiState { show_palettes: false, ..Default::default() }; - let first_error = store.save_if_changed(&ui).unwrap_err(); + let first_error = store.save_if_changed(&ui, Instant::now()).unwrap_err(); fs::remove_dir(&path).unwrap(); - assert_eq!(store.save_if_changed(&ui).unwrap_err(), first_error); + assert_eq!(store.save_if_changed(&ui, Instant::now()).unwrap_err(), first_error); assert!(!path.exists(), "ordinary frames must not retry disk writes"); store.save_on_exit(&ui).unwrap(); assert!(!read(&path).unwrap().unwrap().show_palettes); diff --git a/contributors/people.toml b/contributors/people.toml index 5c5d61005..40d6ba823 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -87,3 +87,7 @@ real_name = "Richard Sauer" display_name = "Richard Sauer" sync_github_name = false +[people.dotCooCoo] +real_name = "Robert Lee" +sync_github_name = true + diff --git a/crates/constraints/src/dof.rs b/crates/constraints/src/dof.rs index f0a34b9f8..559452223 100644 --- a/crates/constraints/src/dof.rs +++ b/crates/constraints/src/dof.rs @@ -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()); } } diff --git a/crates/constraints/src/lib.rs b/crates/constraints/src/lib.rs index 81e81fc25..89c6276cd 100644 --- a/crates/constraints/src/lib.rs +++ b/crates/constraints/src/lib.rs @@ -81,6 +81,12 @@ pub struct SolveOptions { pub keep: Vec, /// Entities that should take up the change. pub prefer_move: Vec, + /// 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, + /// 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, weights: Vec, + /// Parameters of the points held by [`SolveOptions::hold`]. + held: Vec, tol: f64, } impl Comp { fn locks(&self, skip: Option) -> Vec { 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| 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 { @@ -322,6 +358,56 @@ fn components<'a>(cons: &[&'a Constraint]) -> Vec> { groups.into_values().collect() } +/// The solver weight of each unknown of `model`: kept handles weigh more, preferred-to-move ones +/// less, angles and ratios by size², and contact parameters move freely. +fn solver_weights(model: &Model, opts: &SolveOptions) -> Vec { + let mut weights = vec![1.0; model.x0.len()]; + for s in &model.slots { + let w = if opts.keep.contains(&s.handle) { + 1e4 + } else if opts.prefer_move.contains(&s.handle) { + 1e-2 + } else { + 1.0 + }; + for j in s.off..s.off + s.len { + if let Some(slot) = weights.get_mut(j) { + *slot = w; + } + } + for (j, f) in slot_scales(model, s) { + if let Some(slot) = weights.get_mut(j) { + *slot *= f; + } + } + } + // Contact parameters move freely. + let geo = model.slots.iter().map(|s| s.off + s.len).max().unwrap_or(0); + for w in weights.iter_mut().skip(geo) { + *w = 1e-3; + } + weights +} + +/// Per-unknown factors for the angles and ratios of one slot: size², so that the movement of +/// points is what is minimised. +fn slot_scales(model: &Model, s: &model::Slot) -> Vec<(usize, f64)> { + let sq = |j: usize| model.x0.get(j).map_or(1.0, |r| (r * r).max(1e-6)); + match s.shape { + model::Shape::Arc => vec![(s.off + 3, sq(s.off + 2)), (s.off + 4, sq(s.off + 2))], + model::Shape::Ellipse { arc } => { + let a2 = (sq(s.off + 2) + sq(s.off + 3)).max(1e-6); + let mut v = vec![(s.off + 4, a2)]; + if arc { + v.extend([(s.off + 5, a2), (s.off + 6, a2)]); + } + v + } + model::Shape::Insert { .. } => vec![(s.off + 2, model.rigid_extent2(s.handle).max(1.0))], + _ => Vec::new(), + } +} + /// Build the component systems. Constraints that cannot be interpreted are returned as skipped. pub(crate) fn build(d: &Drawing, cons: &[&Constraint], opts: &SolveOptions) -> Result<(Vec, Vec<(u32, String)>), SolveError> { let mut members_left = model::MAX_RIGID_WORK; @@ -346,55 +432,24 @@ 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)), } } if built.is_empty() { continue; } - let mut weights = vec![1.0; model.x0.len()]; - for s in &model.slots { - let w = if opts.keep.contains(&s.handle) { - 1e4 - } else if opts.prefer_move.contains(&s.handle) { - 1e-2 - } else { - 1.0 - }; - for j in s.off..s.off + s.len { - if let Some(slot) = weights.get_mut(j) { - *slot = w; - } - } - // Angles and ratios: weight by size² so the movement of points is what is minimised. - let sq = |j: usize| model.x0.get(j).map_or(1.0, |r| (r * r).max(1e-6)); - let scaled: Vec<(usize, f64)> = match s.shape { - model::Shape::Arc => vec![(s.off + 3, sq(s.off + 2)), (s.off + 4, sq(s.off + 2))], - model::Shape::Ellipse { arc } => { - let a2 = (sq(s.off + 2) + sq(s.off + 3)).max(1e-6); - let mut v = vec![(s.off + 4, a2)]; - if arc { - v.extend([(s.off + 5, a2), (s.off + 6, a2)]); - } - v - } - model::Shape::Insert { .. } => vec![(s.off + 2, model.rigid_extent2(s.handle).max(1.0))], - _ => Vec::new(), - }; - for (j, f) in scaled { - if let Some(slot) = weights.get_mut(j) { - *slot *= f; - } - } - } - // Contact parameters move freely. - let geo = model.slots.iter().map(|s| s.off + s.len).max().unwrap_or(0); - for w in weights.iter_mut().skip(geo) { - *w = 1e-3; - } + let weights = solver_weights(&model, opts); + 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 +500,9 @@ pub fn residuals(d: &Drawing) -> Vec<(u32, Option)> { /// 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 = d .constraints @@ -461,10 +519,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 +549,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::>()) + .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 { + 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 = 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 = 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 = d.constraints.iter().flat_map(|c| c.refs.iter().map(|r| r.handle)).collect(); diff --git a/crates/constraints/src/model.rs b/crates/constraints/src/model.rs index ba7e58548..d18646c32 100644 --- a/crates/constraints/src/model.rs +++ b/crates/constraints/src/model.rs @@ -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 { @@ -189,6 +191,77 @@ impl Model { Self::build_budgeted(d, handles, &mut members_left) } + /// Add the parameters of one supported entity to `m`, and return its shape; `None` when it is + /// left out (too large, degenerate or unsupported). + fn add_params(m: &mut Model, d: &Drawing, h: Handle, kind: &EntityKind, off: usize, members_left: &mut usize) -> Option { + let shape = match kind { + EntityKind::Line(l) => { + m.x0.extend([l.a.x, l.a.y, l.b.x, l.b.y]); + Shape::Line + } + EntityKind::Circle(c) => { + m.x0.extend([c.center.x, c.center.y, c.radius]); + Shape::Circle + } + EntityKind::Arc(a) => { + m.x0.extend([a.center.x, a.center.y, a.radius, a.start, a.end]); + Shape::Arc + } + EntityKind::LwPolyline(p) => { + if p.vertices.len() < 2 || off + p.vertices.len() * 2 > MAX_PARAMS { + return None; + } + for v in &p.vertices { + m.x0.extend([v.p.x, v.p.y]); + } + Shape::Poly { n: p.vertices.len(), closed: p.closed } + } + EntityKind::Point(p) => { + m.x0.extend([p.p.x, p.p.y]); + Shape::Point + } + EntityKind::Ellipse(el) => { + let ell = cadcraft_geom::Ellipse { center: el.center.xy(), major: el.major.xy(), ratio: el.ratio, start: el.start, end: el.end }; + let arc = !ell.is_full(); + m.x0.extend([el.center.x, el.center.y, el.major.x, el.major.y, el.ratio]); + if arc { + m.x0.extend([el.start, el.end]); + } + Shape::Ellipse { arc } + } + EntityKind::Spline(sp) => { + let fit = fit_mode(sp); + let pts = if fit { &sp.fit } else { &sp.control }; + let n = pts.len(); + if !(2..=MAX_SPLINE_POINTS).contains(&n) || off + n * 2 > MAX_PARAMS || (!fit && !sp.is_valid()) { + return None; + } + for p in pts { + m.x0.extend([p.x, p.y]); + } + // Fit points interpolate exactly, so the curve's ends are the first and last + // fit point; a control polygon's ends are its first and last points when the + // knots are clamped. + let ends = !sp.closed && (fit || (clamped(sp, false) && clamped(sp, true))); + // Missing weights default to 1 in eval; only excess weights are unused. + let mut kept = sp.clone(); + kept.weights.truncate(kept.control.len()); + m.splines.insert(h, kept); + Shape::Spline { n, fit, ends } + } + EntityKind::Insert(ins) => { + let base = Vec2::new(ins.insert.x, ins.insert.y); + let pts: Vec = rigid_points_budgeted(d, ins, members_left).into_iter().map(|p| (p - base).rotate(-ins.rotation)).collect(); + m.x0.extend([ins.insert.x, ins.insert.y, ins.rotation]); + let n = pts.len(); + m.rigid.insert(h, pts); + Shape::Insert { n } + } + _ => return None, + }; + Some(shape) + } + /// Build solver parameters for supported entities while sharing rigid-member sampling work with other models. pub(crate) fn build_budgeted(d: &Drawing, handles: &[Handle], members_left: &mut usize) -> Model { let mut m = Model::default(); @@ -198,71 +271,7 @@ impl Model { } let Some(e) = d.entity(h) else { continue }; let off = m.x0.len(); - let shape = match &e.kind { - EntityKind::Line(l) => { - m.x0.extend([l.a.x, l.a.y, l.b.x, l.b.y]); - Shape::Line - } - EntityKind::Circle(c) => { - m.x0.extend([c.center.x, c.center.y, c.radius]); - Shape::Circle - } - EntityKind::Arc(a) => { - m.x0.extend([a.center.x, a.center.y, a.radius, a.start, a.end]); - Shape::Arc - } - EntityKind::LwPolyline(p) => { - if p.vertices.len() < 2 || off + p.vertices.len() * 2 > MAX_PARAMS { - continue; - } - for v in &p.vertices { - m.x0.extend([v.p.x, v.p.y]); - } - Shape::Poly { n: p.vertices.len(), closed: p.closed } - } - EntityKind::Point(p) => { - m.x0.extend([p.p.x, p.p.y]); - Shape::Point - } - EntityKind::Ellipse(el) => { - let ell = cadcraft_geom::Ellipse { center: el.center.xy(), major: el.major.xy(), ratio: el.ratio, start: el.start, end: el.end }; - let arc = !ell.is_full(); - m.x0.extend([el.center.x, el.center.y, el.major.x, el.major.y, el.ratio]); - if arc { - m.x0.extend([el.start, el.end]); - } - Shape::Ellipse { arc } - } - EntityKind::Spline(sp) => { - let fit = fit_mode(sp); - let pts = if fit { &sp.fit } else { &sp.control }; - let n = pts.len(); - if !(2..=MAX_SPLINE_POINTS).contains(&n) || off + n * 2 > MAX_PARAMS || (!fit && !sp.is_valid()) { - continue; - } - for p in pts { - m.x0.extend([p.x, p.y]); - } - // Fit points interpolate exactly, so the curve's ends are the first and last - // fit point; a control polygon's ends are its first and last points when the - // knots are clamped. - let ends = !sp.closed && (fit || (clamped(sp, false) && clamped(sp, true))); - // Missing weights default to 1 in eval; only excess weights are unused. - let mut kept = sp.clone(); - kept.weights.truncate(kept.control.len()); - m.splines.insert(h, kept); - Shape::Spline { n, fit, ends } - } - EntityKind::Insert(ins) => { - let base = Vec2::new(ins.insert.x, ins.insert.y); - let pts: Vec = rigid_points_budgeted(d, ins, members_left).into_iter().map(|p| (p - base).rotate(-ins.rotation)).collect(); - m.x0.extend([ins.insert.x, ins.insert.y, ins.rotation]); - let n = pts.len(); - m.rigid.insert(h, pts); - Shape::Insert { n } - } - _ => continue, - }; + let Some(shape) = Self::add_params(&mut m, d, h, &e.kind, off, members_left) else { continue }; if m.x0.len() > MAX_PARAMS || m.x0.get(off..).unwrap_or(&[]).iter().any(|v| !v.is_finite()) { m.x0.truncate(off); continue; @@ -477,6 +486,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 { + 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> { @@ -504,6 +525,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> { + 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 { let mut moved = Vec::new(); @@ -572,8 +644,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 { diff --git a/crates/constraints/src/tests.rs b/crates/constraints/src/tests.rs index 6db26c95e..35b73fea2 100644 --- a/crates/constraints/src/tests.rs +++ b/crates/constraints/src/tests.rs @@ -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)); } diff --git a/crates/constraints/tests/dof.rs b/crates/constraints/tests/dof.rs index ea78ca932..02fd935c0 100644 --- a/crates/constraints/tests/dof.rs +++ b/crates/constraints/tests/dof.rs @@ -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)); +} diff --git a/crates/doc/src/dynblock/apply.rs b/crates/doc/src/dynblock/apply.rs index 70ef484eb..ed5281bf6 100644 --- a/crates/doc/src/dynblock/apply.rs +++ b/crates/doc/src/dynblock/apply.rs @@ -29,16 +29,21 @@ fn tag(code: i32, value: impl Into) -> 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 { - 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 { 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 { + 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))]); + } +} diff --git a/crates/doc/src/entity.rs b/crates/doc/src/entity.rs index 87c7e45b7..0d1f5eb5f 100644 --- a/crates/doc/src/entity.rs +++ b/crates/doc/src/entity.rs @@ -18,7 +18,11 @@ impl Handle { format!("{:X}", self.0) } pub fn parse_hex(s: &str) -> Option { - 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 diff --git a/crates/doc/src/tables.rs b/crates/doc/src/tables.rs index 69badebb0..a2c63ca6a 100644 --- a/crates/doc/src/tables.rs +++ b/crates/doc/src/tables.rs @@ -177,6 +177,11 @@ pub struct DimStyle { pub dim_line_color: Color, pub ext_line_color: Color, pub text_color: Color, + /// DIMTFILL, the background of the text: 0 none, 1 the drawing's background color, + /// 2 `text_fill_color`. + pub text_fill: u8, + /// DIMTFILLCLR: the text background color when `text_fill` is 2. + pub text_fill_color: Color, pub text_style: String, /// DIMPOST prefix/suffix "<>" template. pub post: String, @@ -296,6 +301,8 @@ pub const DIMVARS: &[(&str, &str)] = &[ ("DIMCLRD", "dimLineColor"), ("DIMCLRE", "extLineColor"), ("DIMCLRT", "textColor"), + ("DIMTFILL", "textFill"), + ("DIMTFILLCLR", "textFillColor"), ("DIMTXSTY", "textStyle"), ("DIMPOST", "post"), ("DIMCEN", "centerMark"), @@ -367,6 +374,8 @@ impl Default for DimStyle { dim_line_color: Color::ByBlock, ext_line_color: Color::ByBlock, text_color: Color::ByBlock, + text_fill: 0, + text_fill_color: Color::ByBlock, text_style: "Standard".into(), post: String::new(), center_mark: 0.09, @@ -432,6 +441,49 @@ impl DimStyle { .or_else(|| ["name", "annotative"].into_iter().find(|f| f.eq_ignore_ascii_case(name))) } + /// The value `v` takes for a field whose current value is `old`: coerced to the field's type where + /// the text or number can be read as it. + fn coerce_value(old: &serde_json::Value, v: &serde_json::Value, field: &str) -> serde_json::Value { + use serde_json::Value; + match (old, v) { + (Value::Bool(_), Value::Number(n)) => Value::Bool(n.as_f64().is_some_and(|x| x != 0.0)), + (Value::Bool(_), Value::String(t)) => Value::Bool(matches!(t.to_ascii_lowercase().as_str(), "1" | "on" | "true" | "yes")), + (Value::Number(_), Value::String(t)) => match t.trim().parse::() { + Ok(x) => serde_json::json!(x), + // Lineweight names. + Err(_) if matches!(field, "dimLineweight" | "extLineweight") => match t.trim().to_ascii_lowercase().as_str() { + "bylayer" => serde_json::json!(-1), + "byblock" => serde_json::json!(-2), + "default" => serde_json::json!(-3), + _ => v.clone(), + }, + Err(_) => v.clone(), + }, + (Value::Number(_), Value::Bool(b)) => serde_json::json!(u8::from(*b)), + (Value::String(_), Value::Number(n)) => Value::String(n.to_string()), + (Value::Object(_), Value::Number(n)) => { + let c = n.as_i64().and_then(|i| i16::try_from(i).ok()).map(Color::from_aci); + c.and_then(|c| serde_json::to_value(c).ok()).unwrap_or_else(|| v.clone()) + } + (Value::Object(_), Value::String(t)) => Color::parse(t).and_then(|c| serde_json::to_value(c).ok()).unwrap_or_else(|| v.clone()), + _ => v.clone(), + } + } + + /// Whether `coerced` is outside the range its `field` can take. + fn out_of_range(field: &str, coerced: &serde_json::Value) -> bool { + let x = coerced.as_f64(); + match field { + "textHeight" | "tolScale" => x.is_some_and(|x| x <= 0.0), + "scale" | "arrowSize" | "tickSize" | "fixedExtLength" => x.is_some_and(|x| x < 0.0), + "fit" | "angularZeroSuppression" => x.is_some_and(|x| x > 3.0), + "textMove" | "tolJust" | "arcSymbol" | "textFill" => x.is_some_and(|x| x > 2.0), + "dimLineweight" | "extLineweight" => x.is_some_and(|x| !(-3.0..=211.0).contains(&x)), + "jogAngle" => x.is_some_and(|x| !(5.0..=90.0).contains(&x)), + _ => false, + } + } + /// Set fields from a JSON object of field or DIM* variable names (numbers, booleans as /// 0/1, colours as ACI numbers or names). Returns the names that were not recognised or /// had unusable values. @@ -449,51 +501,29 @@ impl DimStyle { rejected.push(k.clone()); continue; }; - let coerced = match (&old, v) { - (Value::Bool(_), Value::Number(n)) => Value::Bool(n.as_f64().is_some_and(|x| x != 0.0)), - (Value::Bool(_), Value::String(t)) => Value::Bool(matches!(t.to_ascii_lowercase().as_str(), "1" | "on" | "true" | "yes")), - (Value::Number(_), Value::String(t)) => match t.trim().parse::() { - Ok(x) => serde_json::json!(x), - // Lineweight names. - Err(_) if matches!(field, "dimLineweight" | "extLineweight") => match t.trim().to_ascii_lowercase().as_str() { - "bylayer" => serde_json::json!(-1), - "byblock" => serde_json::json!(-2), - "default" => serde_json::json!(-3), - _ => v.clone(), - }, - Err(_) => v.clone(), - }, - (Value::Number(_), Value::Bool(b)) => serde_json::json!(u8::from(*b)), - (Value::String(_), Value::Number(n)) => Value::String(n.to_string()), - (Value::Object(_), Value::Number(n)) => { - let c = n.as_i64().and_then(|i| i16::try_from(i).ok()).map(Color::from_aci); - c.and_then(|c| serde_json::to_value(c).ok()).unwrap_or_else(|| v.clone()) - } - (Value::Object(_), Value::String(t)) => Color::parse(t).and_then(|c| serde_json::to_value(c).ok()).unwrap_or_else(|| v.clone()), - _ => v.clone(), - }; + let coerced = Self::coerce_value(&old, v, field); // Integers stored in u8 fields: round floats. let coerced = match (&old, &coerced) { // Lineweights (i16): whole hundredths of a mm or -1/-2/-3. (Value::Number(_), Value::Number(n)) if matches!(field, "dimLineweight" | "extLineweight") && !n.is_i64() => { - serde_json::json!(n.as_f64().filter(|x| x.is_finite()).unwrap_or(0.0).round().clamp(-3.0, 211.0) as i64) + serde_json::json!(n.as_f64().filter(|x| x.is_finite()).unwrap_or(0.0).round() as i64) } + (Value::Number(_), Value::Number(_)) if matches!(field, "dimLineweight" | "extLineweight") => coerced, + // A value below 0 or one that rounds above 255 is refused; DIMADEC -1 reads as 0. (Value::Number(o), Value::Number(n)) if o.is_u64() && !n.is_u64() => { - serde_json::json!(n.as_f64().filter(|x| x.is_finite()).unwrap_or(0.0).round().clamp(0.0, 255.0) as u64) + let min = if field == "angularDecimals" { -1.0 } else { 0.0 }; + match n.as_f64().filter(|x| x.is_finite() && *x >= min).map(f64::round).filter(|x| *x <= 255.0) { + Some(x) => serde_json::json!(x.max(0.0) as u64), + None => { + rejected.push(k.clone()); + continue; + } + } } _ => coerced, }; // Sizes that can't be negative (text height and tolerance scale: not zero either). - let x = coerced.as_f64(); - let out_of_range = match field { - "textHeight" | "tolScale" => x.is_some_and(|x| x <= 0.0), - "scale" | "arrowSize" | "tickSize" | "fixedExtLength" => x.is_some_and(|x| x < 0.0), - "fit" | "angularZeroSuppression" => x.is_some_and(|x| x > 3.0), - "textMove" | "tolJust" | "arcSymbol" => x.is_some_and(|x| x > 2.0), - "dimLineweight" | "extLineweight" => x.is_some_and(|x| !(-3.0..=211.0).contains(&x)), - "jogAngle" => x.is_some_and(|x| !(5.0..=90.0).contains(&x)), - _ => false, - }; + let out_of_range = Self::out_of_range(field, &coerced); if out_of_range { rejected.push(k.clone()); continue; diff --git a/crates/dxf/src/lib.rs b/crates/dxf/src/lib.rs index 72f834978..ec350adaa 100644 --- a/crates/dxf/src/lib.rs +++ b/crates/dxf/src/lib.rs @@ -545,11 +545,17 @@ pub fn is_binary(bytes: &[u8]) -> bool { /// A string value as `strings` stores it. Line breaks become spaces. Before R2007 the text is /// in the `$DWGCODEPAGE` code page: ASCII stays as it is, a character the code page has becomes /// its bytes, and every other one `\U+XXXX` (a surrogate pair of escapes above U+FFFF). Without -/// a code page the result is ASCII, so it is valid in any code page. +/// a code page the result is ASCII, so it is valid in any code page. In every form a backslash +/// that would start a `\U+` or `\M+` sequence is written as `\U+005C`, so the reader gives back +/// the literal text (#2050). fn push_string(s: &str, strings: Strings, out: &mut Vec) { - for c in s.chars() { + let chars: Vec = s.chars().collect(); + for (i, &c) in chars.iter().enumerate() { match (c, strings) { ('\r' | '\n', _) => out.push(b' '), + ('\\', _) if matches!(chars.get(i + 1), Some('U' | 'M')) && chars.get(i + 2) == Some(&'+') => { + out.extend_from_slice(b"\\U+005C"); + } (c, Strings::Utf8) => out.extend_from_slice(c.encode_utf8(&mut [0; 4]).as_bytes()), (c, _) if c.is_ascii() => out.extend_from_slice(c.encode_utf8(&mut [0; 4]).as_bytes()), (c, Strings::Escaped(Some(d))) if codepage::encode_char(c, d, out) => {} @@ -609,6 +615,19 @@ pub fn records(tags: &[Tag]) -> Vec<(String, Vec)> { 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![ diff --git a/crates/dxf/tests/code_page_write.rs b/crates/dxf/tests/code_page_write.rs index bec95b73a..466a2b2a6 100644 --- a/crates/dxf/tests/code_page_write.rs +++ b/crates/dxf/tests/code_page_write.rs @@ -76,3 +76,19 @@ fn code_page_bytes_that_are_valid_utf8_are_escaped_in_ascii() { // Bytes that are not valid UTF-8 stay as they are. assert!(contains(&write_ascii_in(&file("ANSI_1251", "Слой"), "ANSI_1251"), b"\xD1\xEB\xEE\xE9")); } + +/// A literal `\U+` or `\M+` in the text (#2050) reads back as written alongside code page bytes, +/// in ASCII and binary, including an ASCII file that falls back to escapes because its bytes +/// would read as UTF-8. +#[test] +fn literal_escapes_survive_code_page_text() { + for (cp, text) in + [("ANSI_1252", "Ação \\U+00E9 \\M+183A4F"), ("ANSI_1251", "Слой \\U+0041"), ("ANSI_1251", "СІ \\U+0041"), ("ANSI_932", "表 \\M+1")] + { + let tags = file(cp, text); + for (bytes, what) in [(write_ascii_in(&tags, cp), "ASCII"), (write_binary_in(&tags, cp), "binary")] { + assert!(contains(&bytes, b"\\U+005C"), "{cp} {what}: {:?}", String::from_utf8_lossy(&bytes)); + assert_eq!(parse(&bytes).unwrap()[10].str(), text, "{cp} {what}"); + } + } +} diff --git a/crates/engine/src/autosave.rs b/crates/engine/src/autosave.rs index 0904711e4..4da107449 100644 --- a/crates/engine/src/autosave.rs +++ b/crates/engine/src/autosave.rs @@ -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 } diff --git a/crates/engine/src/blockedit.rs b/crates/engine/src/blockedit.rs index 776909a68..4dab97f1f 100644 --- a/crates/engine/src/blockedit.rs +++ b/crates/engine/src/blockedit.rs @@ -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 { + /// 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> { 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()), } } diff --git a/crates/engine/src/cmd/annoscale.rs b/crates/engine/src/cmd/annoscale.rs index 913cc5e6c..6ec2fb8f5 100644 --- a/crates/engine/src/cmd/annoscale.rs +++ b/crates/engine/src/cmd/annoscale.rs @@ -7,7 +7,8 @@ use cadcraft_doc::{AnnoScale, Drawing, EntityKind, Handle, MAX_SCALES, is_annota use serde_json::{Value, json}; use super::machines::{SelOutcome, SelectPhase, SelectRun}; -use super::*; +use super::{CommandSpec, bad, f64_req, targets}; + use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step}; pub fn specs() -> Vec { diff --git a/crates/engine/src/cmd/annotate.rs b/crates/engine/src/cmd/annotate.rs index 4d7bd1b7b..6fdd0869d 100644 --- a/crates/engine/src/cmd/annotate.rs +++ b/crates/engine/src/cmd/annotate.rs @@ -9,7 +9,8 @@ use serde_json::{Value, json}; use super::helpers::v3; use super::machines::{SelOutcome, SelectPhase, SelectRun}; -use super::*; +use super::{CommandSpec, bad, point_param, point_req, points_param, str_param, targets}; + use crate::{Accept, EngineError, Input, Interactive, LeaderOrder, MLeaderOptions, Prompt, Result, Session, Step}; pub fn specs() -> Vec { @@ -119,7 +120,7 @@ fn style(s: &Session) -> String { s.doc().map(|d| d.header.str("DIMSTYLE", "Standard")).unwrap_or_else(|_| "Standard".into()) } -fn dim(s: &Session, kind: DimKind, defpt: Vec2, p13: Vec2, p14: Vec2, p15: Vec2, p16: Vec2, text: &str) -> EntityKind { +fn dim(s: &Session, kind: DimKind, defpt: Vec2, p13: Vec2, p14: Vec2, p15: Vec2, text: &str) -> EntityKind { EntityKind::Dimension(Dimension { kind, defpt: v3(defpt), @@ -127,7 +128,7 @@ fn dim(s: &Session, kind: DimKind, defpt: Vec2, p13: Vec2, p14: Vec2, p15: Vec2, p13: v3(p13), p14: v3(p14), p15: v3(p15), - p16: v3(p16), + p16: v3(Vec2::ZERO), text: text.into(), style: style(s), measurement: 0.0, @@ -204,7 +205,10 @@ fn angular_from_lines(s: &Session, h1: Handle, h2: Handle, at: Vec2) -> Option Result { }; let at = point_req("dimlinear", p, "at")?; let rot = p.get("rotation").and_then(Value::as_f64).map(f64::to_radians); - let k = dim(s, linear_kind(a, b, at, rot, false), at, a, b, Vec2::ZERO, Vec2::ZERO, str_param(p, "text").unwrap_or("")); + let k = dim(s, linear_kind(a, b, at, rot, false), at, a, b, Vec2::ZERO, str_param(p, "text").unwrap_or("")); Ok(json!({ "handle": add_dim(s, k)?.hex() })) } @@ -232,7 +236,7 @@ fn run_aligned(s: &mut Session, p: &Value) -> Result { let a = point_req("dimaligned", p, "p1")?; let b = point_req("dimaligned", p, "p2")?; let at = point_req("dimaligned", p, "at")?; - let k = dim(s, DimKind::Aligned, at, a, b, Vec2::ZERO, Vec2::ZERO, str_param(p, "text").unwrap_or("")); + let k = dim(s, DimKind::Aligned, at, a, b, Vec2::ZERO, str_param(p, "text").unwrap_or("")); Ok(json!({ "handle": add_dim(s, k)?.hex() })) } @@ -260,9 +264,9 @@ fn radial(s: &mut Session, p: &Value, diameter: bool) -> Result { return Err(bad(id, "`point` must differ from `center`")); } let k = if diameter { - dim(s, DimKind::Diameter, c + (c - pt), Vec2::ZERO, Vec2::ZERO, pt, Vec2::ZERO, "") + dim(s, DimKind::Diameter, c + (c - pt), Vec2::ZERO, Vec2::ZERO, pt, "") } else { - dim(s, DimKind::Radius, c, Vec2::ZERO, Vec2::ZERO, pt, Vec2::ZERO, "") + dim(s, DimKind::Radius, c, Vec2::ZERO, Vec2::ZERO, pt, "") }; let k = match at { Some(at) => text_at(k, at), @@ -307,7 +311,7 @@ fn run_angular(s: &mut Session, p: &Value) -> Result { _ => return Err(bad("dimangular", "object is not an arc")), }; let g = cadcraft_geom::Arc::new(a.center.xy(), a.radius, a.start, a.end); - let k = dim(s, DimKind::Angular3P, at, g.start_point(), g.end_point(), g.center, Vec2::ZERO, ""); + let k = dim(s, DimKind::Angular3P, at, g.start_point(), g.end_point(), g.center, ""); return Ok(json!({ "handle": add_dim(s, k)?.hex() })); } let v = point_req("dimangular", p, "vertex")?; @@ -317,7 +321,7 @@ fn run_angular(s: &mut Session, p: &Value) -> Result { if v.near(a, 1e-12) || v.near(b, 1e-12) { return Err(bad("dimangular", "`p1` and `p2` must differ from `vertex`")); } - let k = dim(s, DimKind::Angular3P, at, a, b, v, Vec2::ZERO, ""); + let k = dim(s, DimKind::Angular3P, at, a, b, v, ""); Ok(json!({ "handle": add_dim(s, k)?.hex() })) } @@ -329,7 +333,7 @@ fn run_arclen(s: &mut Session, p: &Value) -> Result { _ => return Err(bad("dimarc", "object is not an arc")), }; let ga = cadcraft_geom::Arc::new(a.center.xy(), a.radius, a.start, a.end); - let k = dim(s, DimKind::ArcLength, at, ga.start_point(), ga.end_point(), ga.center, Vec2::ZERO, ""); + let k = dim(s, DimKind::ArcLength, at, ga.start_point(), ga.end_point(), ga.center, ""); Ok(json!({ "handle": add_dim(s, k)?.hex() })) } @@ -337,7 +341,7 @@ fn run_ordinate(s: &mut Session, p: &Value) -> Result { let f = point_req("dimordinate", p, "feature")?; let l = point_req("dimordinate", p, "leader")?; let xtype = p.get("xtype").and_then(Value::as_bool).unwrap_or((l - f).y.abs() > (l - f).x.abs()); - let k = dim(s, DimKind::Ordinate { x_type: xtype }, Vec2::ZERO, f, l, Vec2::ZERO, Vec2::ZERO, ""); + let k = dim(s, DimKind::Ordinate { x_type: xtype }, Vec2::ZERO, f, l, Vec2::ZERO, ""); Ok(json!({ "handle": add_dim(s, k)?.hex() })) } @@ -897,7 +901,7 @@ fn pick_at(s: &Session, p: Vec2) -> Option { } /// The straight or curved piece of `h` picked at `p`: a line, an arc, or the polyline segment nearest `p`. -fn segment_at(s: &Session, h: Handle, p: Vec2) -> Option { +pub(super) fn segment_at(s: &Session, h: Handle, p: Vec2) -> Option { match &s.doc().ok()?.entity(h)?.kind { EntityKind::Line(l) => Some(Segment::Line(cadcraft_geom::Line::new(l.a.xy(), l.b.xy()))), EntityKind::Arc(a) => Some(Segment::Arc { arc: cadcraft_geom::Arc::new(a.center.xy(), a.radius, a.start, a.end), ccw: true }), @@ -1059,7 +1063,7 @@ impl Interactive for LinearM { } (_, Input::Point(at)) => { let (Some(&a), Some(&b)) = (self.pts.first(), self.pts.get(1)) else { return Ok(Step::Continue) }; - let k = self.text.apply(dim(s, linear_kind(a, b, at, self.rotation, self.aligned), at, a, b, Vec2::ZERO, Vec2::ZERO, "")); + let k = self.text.apply(dim(s, linear_kind(a, b, at, self.rotation, self.aligned), at, a, b, Vec2::ZERO, "")); add_dim(s, k)?; if let Some(EntityKind::Dimension(d)) = s.last_dim.and_then(|h| s.doc().ok()?.entity(h).map(|e| e.kind.clone())) { let st = s.doc()?.dim_style(&d.style).cloned().unwrap_or_default(); @@ -1086,7 +1090,7 @@ impl Interactive for LinearM { fn preview(&self, s: &Session, c: Vec2) -> Vec { match self.pts.as_slice() { [a] => vec![super::helpers::line(*a, c)], - [a, b] => vec![self.text.apply(dim(s, linear_kind(*a, *b, c, self.rotation, self.aligned), c, *a, *b, Vec2::ZERO, Vec2::ZERO, ""))], + [a, b] => vec![self.text.apply(dim(s, linear_kind(*a, *b, c, self.rotation, self.aligned), c, *a, *b, Vec2::ZERO, ""))], _ => Vec::new(), } } @@ -1105,9 +1109,9 @@ impl RadialM { fn kind(&self, s: &Session, c: Vec2, r: f64, at: Vec2) -> EntityKind { let pt = c + (at - c).normalized() * r; let k = self.text.apply(if self.diameter { - dim(s, DimKind::Diameter, c + (c - pt), Vec2::ZERO, Vec2::ZERO, pt, Vec2::ZERO, "") + dim(s, DimKind::Diameter, c + (c - pt), Vec2::ZERO, Vec2::ZERO, pt, "") } else { - dim(s, DimKind::Radius, c, Vec2::ZERO, Vec2::ZERO, pt, Vec2::ZERO, "") + dim(s, DimKind::Radius, c, Vec2::ZERO, Vec2::ZERO, pt, "") }); text_at(k, at) } @@ -1240,7 +1244,7 @@ impl Interactive for AngularM { ([h1, h2], None) => angular_from_lines(s, *h1, *h2, at), _ => None, }; - let k = k.unwrap_or_else(|| dim(s, DimKind::Angular3P, at, a, b, v, Vec2::ZERO, "")); + let k = k.unwrap_or_else(|| dim(s, DimKind::Angular3P, at, a, b, v, "")); add_dim(s, self.text.apply(k))?; Ok(Step::Done) } @@ -1306,9 +1310,7 @@ impl Interactive for AngularM { } } fn preview(&self, s: &Session, c: Vec2) -> Vec { - self.geometry() - .map(|(v, a, b)| vec![self.text.apply(dim(s, DimKind::Angular3P, self.located(v, c), a, b, v, Vec2::ZERO, ""))]) - .unwrap_or_default() + self.geometry().map(|(v, a, b)| vec![self.text.apply(dim(s, DimKind::Angular3P, self.located(v, c), a, b, v, ""))]).unwrap_or_default() } } @@ -1384,7 +1386,7 @@ impl Interactive for ArcLenM { Ok(Step::Continue) } (Some(g), Input::Point(at)) => { - add_dim(s, self.text.apply(dim(s, DimKind::ArcLength, at, g.start_point(), g.end_point(), g.center, Vec2::ZERO, "")))?; + add_dim(s, self.text.apply(dim(s, DimKind::ArcLength, at, g.start_point(), g.end_point(), g.center, "")))?; Ok(Step::Done) } (Some(_), Input::Keyword(k)) => { @@ -1403,9 +1405,7 @@ impl Interactive for ArcLenM { if self.partial.is_some() { return Vec::new(); } - self.arc - .map(|g| vec![self.text.apply(dim(s, DimKind::ArcLength, c, g.start_point(), g.end_point(), g.center, Vec2::ZERO, ""))]) - .unwrap_or_default() + self.arc.map(|g| vec![self.text.apply(dim(s, DimKind::ArcLength, c, g.start_point(), g.end_point(), g.center, ""))]).unwrap_or_default() } } @@ -1420,7 +1420,7 @@ struct OrdinateM { impl OrdinateM { fn kind(&self, s: &Session, f: Vec2, l: Vec2) -> EntityKind { let x = self.xtype.unwrap_or((l - f).y.abs() > (l - f).x.abs()); - self.text.apply(dim(s, DimKind::Ordinate { x_type: x }, Vec2::ZERO, f, l, Vec2::ZERO, Vec2::ZERO, "")) + self.text.apply(dim(s, DimKind::Ordinate { x_type: x }, Vec2::ZERO, f, l, Vec2::ZERO, "")) } } diff --git a/crates/engine/src/cmd/annotate_dimedit.rs b/crates/engine/src/cmd/annotate_dimedit.rs index 19538e0d5..563b67de1 100644 --- a/crates/engine/src/cmd/annotate_dimedit.rs +++ b/crates/engine/src/cmd/annotate_dimedit.rs @@ -5,8 +5,11 @@ use cadcraft_doc::{DimBreak, DimKind, Dimension, EntityKind, Handle, Inspection, use cadcraft_geom::{Segment, Vec2}; use serde_json::{Value, json}; -use super::machines::{SelOutcome, SelectPhase}; -use super::*; +use super::super::helpers::v3; +use super::super::machines::{SelOutcome, SelectPhase}; +use super::super::{CommandSpec, bad, bool_or, point_param, point_req, size_param, str_param, targets}; +use super::{DimText, add_dim, circle_of, dim, dims_of, pick_at}; + use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step}; /// Most objects DIMBREAK Auto looks at, and most breaks it adds to one dimension. @@ -280,7 +283,7 @@ fn jogged_kind(s: &Session, c: Vec2, r: f64, center: Vec2, at: Vec2, jog: Option } let tip = c + dir * r; let jog = jog.unwrap_or_else(|| center.mid(tip)); - let mut k = dim(s, DimKind::Radius, c, Vec2::ZERO, Vec2::ZERO, tip, Vec2::ZERO, text); + let mut k = dim(s, DimKind::Radius, c, Vec2::ZERO, Vec2::ZERO, tip, text); if let EntityKind::Dimension(dm) = &mut k { dm.marks.jogged = Some(JoggedRadius { center: v3(center), jog: v3(jog) }); if at.dist(c) > r + 1e-9 { diff --git a/crates/engine/src/cmd/annotate_qdim.rs b/crates/engine/src/cmd/annotate_qdim.rs index 49e604f9a..34b327e4e 100644 --- a/crates/engine/src/cmd/annotate_qdim.rs +++ b/crates/engine/src/cmd/annotate_qdim.rs @@ -6,8 +6,10 @@ use cadcraft_doc::{DimKind, EntityKind, Handle, Prim}; use cadcraft_geom::{Segment, Vec2}; use serde_json::{Value, json}; -use super::machines::{SelOutcome, SelectPhase}; -use super::*; +use super::super::machines::{SelOutcome, SelectPhase}; +use super::super::{bad, point_param, point_req, points_param, str_param, targets}; +use super::{MAX_CHAIN, assoc_enabled, dim}; + use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step}; pub(super) const PARAMS: &str = "{handles?, at: [x,y] (dimension line position), mode?: continuous|staggered|baseline|ordinate|radius|diameter (default continuous), vertical?: bool (default: from `at`), datum?: [x,y] (baseline and ordinate; default the first point, or the origin for ordinate), add?: [[x,y]], remove?: [[x,y]] (dimension points; removes those within `tolerance`)}"; @@ -94,9 +96,9 @@ fn plan( let dir = if dir == Vec2::ZERO || !dir.is_finite() { Vec2::from_angle(std::f64::consts::FRAC_PI_4) } else { dir }; let p = *c + dir * *r; out.push(if mode == Mode::Diameter { - dim(s, DimKind::Diameter, *c - dir * *r, Vec2::ZERO, Vec2::ZERO, p, Vec2::ZERO, "") + dim(s, DimKind::Diameter, *c - dir * *r, Vec2::ZERO, Vec2::ZERO, p, "") } else { - dim(s, DimKind::Radius, *c, Vec2::ZERO, Vec2::ZERO, p, Vec2::ZERO, "") + dim(s, DimKind::Radius, *c, Vec2::ZERO, Vec2::ZERO, p, "") }); } if out.is_empty() { @@ -129,7 +131,7 @@ fn plan( .iter() .map(|p| { let end = if vertical { Vec2::new(at.x, p.y) } else { Vec2::new(p.x, at.y) }; - dim(s, DimKind::Ordinate { x_type: !vertical }, origin, *p, end, Vec2::ZERO, Vec2::ZERO, "") + dim(s, DimKind::Ordinate { x_type: !vertical }, origin, *p, end, Vec2::ZERO, "") }) .collect()); } @@ -145,8 +147,7 @@ fn plan( let mid = bounds.center(); let out_dir = if vertical { Vec2::new(if at.x >= mid.x { 1.0 } else { -1.0 }, 0.0) } else { Vec2::new(0.0, if at.y >= mid.y { 1.0 } else { -1.0 }) }; - let lin = - |a: Vec2, b: Vec2, k: usize| dim(s, DimKind::Linear { rotation: rot }, at + out_dir * (step * k as f64), a, b, Vec2::ZERO, Vec2::ZERO, ""); + let lin = |a: Vec2, b: Vec2, k: usize| dim(s, DimKind::Linear { rotation: rot }, at + out_dir * (step * k as f64), a, b, Vec2::ZERO, ""); Ok(match mode { Mode::Baseline => { // From the datum (the point nearest it, by default the first) to every other point. diff --git a/crates/engine/src/cmd/array.rs b/crates/engine/src/cmd/array.rs index 04cbda225..479b7873a 100644 --- a/crates/engine/src/cmd/array.rs +++ b/crates/engine/src/cmd/array.rs @@ -17,7 +17,8 @@ use serde_json::{Value, json}; use super::helpers::{line, v3}; use super::machines::{SelOutcome, SelectPhase, number}; use super::modify::transform_entities; -use super::*; +use super::{CommandSpec, bad, bool_or, f64_or, find_command, point_param, point_req, str_param, targets}; + use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step}; mod edit; diff --git a/crates/engine/src/cmd/array/edit.rs b/crates/engine/src/cmd/array/edit.rs index 806a75591..21bf6b132 100644 --- a/crates/engine/src/cmd/array/edit.rs +++ b/crates/engine/src/cmd/array/edit.rs @@ -16,7 +16,8 @@ use serde_json::{Value, json}; use super::super::helpers::v3; use super::super::machines::{SelOutcome, SelectPhase, number}; -use super::super::*; +use super::super::{CommandSpec, bad, bool_or, point_param, targets}; + use super::{KEYWORDS, angle, check_size, count, fmt, nesting, other, yes, yes_no}; use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step}; diff --git a/crates/engine/src/cmd/audit.rs b/crates/engine/src/cmd/audit.rs index 0a6805eb5..fa3cc2f98 100644 --- a/crates/engine/src/cmd/audit.rs +++ b/crates/engine/src/cmd/audit.rs @@ -5,7 +5,9 @@ use std::sync::Arc; use serde_json::{Value, json}; -use super::*; +use super::{CommandSpec, bad, bool_or, file, str_param}; + +use super::always; use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step}; pub fn specs() -> Vec { diff --git a/crates/engine/src/cmd/blocks.rs b/crates/engine/src/cmd/blocks.rs index f95a99b93..15d75da14 100644 --- a/crates/engine/src/cmd/blocks.rs +++ b/crates/engine/src/cmd/blocks.rs @@ -12,7 +12,9 @@ use serde_json::{Value, json}; use super::helpers::v3; use super::machines::{SelOutcome, SelectPhase, number}; -use super::*; +use super::{CommandSpec, bad, bool_or, f64_or, find_command, ok, point_param, point_req, point_value, str_param, targets}; + +use super::has_doc; use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step}; mod attedit; @@ -74,10 +76,12 @@ pub fn specs() -> Vec { attribs::attdisp_spec(), attribs::attsync_spec(), burst::burst_spec(), + blockreplace::blockreplace_spec(), extract::dataextraction_spec(), extract::eattext_spec(), ] .into_iter() + .chain(attribs::attdisp_menu_specs()) .chain(xref::specs()) .chain(refedit::specs()) .chain(bedit::specs()) @@ -88,12 +92,19 @@ pub(crate) fn valid_block_name(n: &str) -> bool { !n.trim().is_empty() && n.len() <= 255 && !n.starts_with('*') && !n.chars().any(|c| "<>/\\\":;?*|,=`".contains(c)) } -/// The block an entity references, when it is a block reference. +/// The block an entity draws, if any: a block reference's block, a dimension's block or a +/// multileader's block content. fn referenced_block(e: &Entity) -> Option { - if let EntityKind::Insert(i) = &e.kind { Some(i.block.clone()) } else { None } + match &e.kind { + EntityKind::Insert(i) => Some(i.block.clone()), + EntityKind::Dimension(dm) => dm.block.clone(), + EntityKind::MLeader(m) => m.block.as_ref().map(|b| b.block.clone()), + _ => None, + } } -/// Whether any of `ents` refers to block `name`, directly or through nested block references. +/// Whether any of `ents` refers to block `name`, directly or through nested block references, +/// dimension blocks and multileader block content. pub(crate) fn references_block(d: &cadcraft_doc::Drawing, ents: &[Entity], name: &str) -> bool { let mut todo: Vec = ents.iter().filter_map(referenced_block).collect(); let mut seen = std::collections::HashSet::new(); @@ -878,3 +889,4 @@ impl Interactive for AttdefM { mod attribs; mod battman; +mod blockreplace; diff --git a/crates/engine/src/cmd/blocks/attribs.rs b/crates/engine/src/cmd/blocks/attribs.rs index 38e1c5c89..8b51cae5d 100644 --- a/crates/engine/src/cmd/blocks/attribs.rs +++ b/crates/engine/src/cmd/blocks/attribs.rs @@ -16,6 +16,22 @@ pub(super) fn attdisp_spec() -> CommandSpec { CommandSpec::new("attdisp", "Attribute Display", run_attdisp) .params("{mode: \"normal\" | \"on\" | \"off\"} (sets ATTMODE to 1 / 2 / 0) → {attmode}") .interactive(|_| Ok(Box::new(AttdispM))) + .transparent() +} + +/// Returns the View > Attribute Display > Normal, On and Off commands, which set ATTMODE as the matching ATTDISP option does. +pub(super) fn attdisp_menu_specs() -> Vec { + vec![ + CommandSpec::new("attdisp.normal", "Normal", |s, _| set_attmode(s, 1)) + .menu(&["View", "Attribute Display", "Normal"]) + .params("{} (ATTDISP Normal: ATTMODE 1) → {attmode}"), + CommandSpec::new("attdisp.on", "On", |s, _| set_attmode(s, 2)) + .menu(&["View", "Attribute Display", "On"]) + .params("{} (ATTDISP ON: ATTMODE 2) → {attmode}"), + CommandSpec::new("attdisp.off", "Off", |s, _| set_attmode(s, 0)) + .menu(&["View", "Attribute Display", "Off"]) + .params("{} (ATTDISP OFF: ATTMODE 0) → {attmode}"), + ] } pub(super) fn attsync_spec() -> CommandSpec { @@ -84,7 +100,7 @@ impl Interactive for AttdispM { /// The attributes `ins` gets from the definitions `defs` of its block `blk`: placed, styled and /// shown as defined, keeping the values of attributes whose tag (any case) is still defined. /// Properties set on single attributes (layer, colour, …) go back to the definitions', as in AutoCAD. -fn synced(blk: &Block, defs: &[&Attrib], ins: &Insert) -> Vec { +pub(super) fn synced(blk: &Block, defs: &[&Attrib], ins: &Insert) -> Vec { let m = ins.transform(blk.base.xy()); defs.iter() .map(|ad| { @@ -119,12 +135,16 @@ fn references<'a>(ents: impl Iterator>, name: &str) -> Ve /// Apply `f` to every reference of block `name` (in model space, layouts and other blocks); /// returns how many references there were. fn update_references(d: &mut cadcraft_doc::Drawing, name: &str, mut f: impl FnMut(&mut Insert)) -> usize { - let mut n = 0; - let mut update = |e: &mut Entity| { + update_reference_entities(d, name, |e| { if let EntityKind::Insert(ins) = &mut e.kind { f(ins); } - }; + }) +} + +/// [`update_references`], with `f` given the whole object. +pub(super) fn update_reference_entities(d: &mut cadcraft_doc::Drawing, name: &str, mut update: impl FnMut(&mut Entity)) -> usize { + let mut n = 0; let spaces: Vec = std::iter::once(cadcraft_doc::Space::Model).chain(d.layouts.iter().map(|l| cadcraft_doc::Space::Paper(l.name.clone()))).collect(); for sp in spaces { @@ -169,18 +189,22 @@ pub(super) fn rename_tag(s: &mut Session, name: &str, old: &str, new: &str) -> R })) } -/// Synchronize every reference of block `name` (in model space, layouts and other blocks). -/// Returns the block's name as defined and how many references were updated. -pub(super) fn sync(s: &mut Session, name: &str) -> Result<(String, usize)> { - let blk = s.doc()?.block(name).cloned().ok_or_else(|| EngineError::Other(format!("Block \"{name}\" not found.")))?; - let defs: Vec<&Attrib> = blk - .entities +/// The attribute definitions of `blk` that references carry (constant ones are not). +pub(super) fn attdefs(blk: &Block) -> Vec<&Attrib> { + blk.entities .iter() .filter_map(|e| match &e.kind { EntityKind::AttDef(a) if !a.constant => Some(a), _ => None, }) - .collect(); + .collect() +} + +/// Synchronize every reference of block `name` (in model space, layouts and other blocks). +/// Returns the block's name as defined and how many references were updated. +pub(super) fn sync(s: &mut Session, name: &str) -> Result<(String, usize)> { + let blk = s.doc()?.block(name).cloned().ok_or_else(|| EngineError::Other(format!("Block \"{name}\" not found.")))?; + let defs = attdefs(&blk); let d = s.doc_mut()?; let n = update_references(d, &blk.name, |ins| ins.attribs = synced(&blk, &defs, ins)); Ok((blk.name.clone(), n)) diff --git a/crates/engine/src/cmd/blocks/blockreplace.rs b/crates/engine/src/cmd/blocks/blockreplace.rs new file mode 100644 index 000000000..03bac655d --- /dev/null +++ b/crates/engine/src/cmd/blocks/blockreplace.rs @@ -0,0 +1,236 @@ +//! BLOCKREPLACE and -BLOCKREPLACE (Express Tools): every reference of one block, in model space, +//! the layouts and other blocks, becomes a reference of another block at the same insertion +//! point, scale and rotation. The unreferenced block definition can then be purged. + +use cadcraft_doc::{Drawing, Entity, EntityKind}; +use serde_json::{Value, json}; + +use super::attribs::{attdefs, synced, update_reference_entities}; +use super::purge::{override_arrows, used_blocks}; +use super::references_block; +use crate::cmd::curves::KW; +use crate::cmd::machines::{SelOutcome, SelectPhase}; +use crate::cmd::{CommandSpec, bad, str_param}; +use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step}; + +pub(super) fn blockreplace_spec() -> CommandSpec { + CommandSpec::new("blockreplace", "Replace Block...", run_blockreplace) + .menu(&["Tools", "Express Tools", "Replace Block..."]) + .alias(&["-blockreplace"]) + .params("{block (the block whose references are replaced), with (the replacement block), purge?: bool (default false: remove the replaced block's definition when nothing references it any more)} → {block, with, replaced, purged}") + .interactive(|_| Ok(Box::new(BlockreplaceM::default()))) +} + +/// The defined name of block `name` (names are case-insensitive), when it is a named block of +/// this drawing: not anonymous and not one an xref brings in (`X|A`), which is never saved. +fn named_block(d: &Drawing, name: &str) -> Option { + d.block(name.trim()).filter(|b| !b.anonymous && !b.name.starts_with('*') && !b.name.contains('|')).map(|b| b.name.clone()) +} + +/// Why the references of block `old` cannot be replaced: a dynamic block's references keep their +/// property values in data that names the block. +fn not_replaceable(d: &Drawing, old: &str) -> Option { + cadcraft_doc::dynblock::dynamic_block(d, old).map(|_| format!("Block \"{old}\" is a dynamic block; its references cannot be replaced.")) +} + +/// Why block `new` cannot replace block `old`: references of `old` inside `new` would make `new` +/// reference itself. +fn cannot_replace_with(d: &Drawing, old: &str, new: &str) -> Option { + let blk = d.block(new)?; + references_block(d, &blk.entities.iter().map(|e| (**e).clone()).collect::>(), old) + .then(|| format!("Block \"{new}\" references \"{old}\", so it cannot replace it.")) +} + +/// Replace every reference of `old` by a reference of `new`; with `purge`, remove `old`'s +/// definition when nothing references it afterwards. Returns (references replaced, purged). +fn replace(s: &mut Session, old: &str, new: &str, purge: bool) -> Result<(usize, bool)> { + let cmd = "blockreplace"; + if s.state()?.block_edit.is_some() { + return Err(bad(cmd, "the Block Editor is open; close it with BCLOSE first")); + } + let d = s.doc()?; + if d.refedit.is_some() { + return Err(bad(cmd, "a block is being edited in place; finish with REFCLOSE first")); + } + if let Some(n) = [old, new].into_iter().find(|n| n.contains('|')) { + return Err(bad(cmd, format!("Block \"{}\" comes from an xref; bind the xref first.", n.trim()))); + } + let old = named_block(d, old).ok_or_else(|| bad(cmd, format!("Block \"{}\" not found.", old.trim())))?; + let new = named_block(d, new).ok_or_else(|| bad(cmd, format!("Block \"{}\" not found.", new.trim())))?; + if old.eq_ignore_ascii_case(&new) { + return Err(bad(cmd, "the replacement block must be a different block")); + } + if let Some(why) = not_replaceable(d, &old).or_else(|| cannot_replace_with(d, &old, &new)) { + return Err(bad(cmd, why)); + } + let blk = d.block(&new).cloned().ok_or_else(|| bad(cmd, format!("Block \"{new}\" not found.")))?; + let defs = attdefs(&blk); + let scale = d.current_anno_scale(); + let d = s.doc_mut()?; + let n = update_reference_entities(d, &old, |e| { + if let EntityKind::Insert(ins) = &mut e.kind { + ins.block.clone_from(&blk.name); + ins.attribs = synced(&blk, &defs, ins); + } + // A reference of an annotative block is annotative, as INSERT makes it. + if !blk.annotative { + e.common.anno_scales.clear(); + } else if e.common.anno_scales.is_empty() { + e.common.anno_scales = vec![scale.clone()]; + } + }); + let key = d.blocks.keys().find(|k| k.eq_ignore_ascii_case(&old)).cloned(); + let purged = purge && !referenced(d, &old) && key.is_some_and(|k| d.blocks.remove(&k).is_some()); + Ok((n, purged)) +} + +/// Whether anything still names block `name`: what PURGE counts as in use, and the dimensions, +/// multileaders and block references of every other block definition, used or not. +fn referenced(d: &Drawing, name: &str) -> bool { + if used_blocks(d).contains(&name.to_ascii_uppercase()) { + return true; + } + let names = |e: &Entity| -> Vec { + match &e.kind { + EntityKind::Insert(i) => vec![i.block.clone()], + EntityKind::Dimension(dm) => dm.block.iter().cloned().chain(override_arrows(dm)).collect(), + EntityKind::MLeader(m) => m.block.iter().map(|b| b.block.clone()).collect(), + _ => Vec::new(), + } + }; + d.blocks + .values() + .filter(|b| !b.name.eq_ignore_ascii_case(name)) + .flat_map(|b| b.entities.iter()) + .any(|e| names(e).iter().any(|n| n.trim().eq_ignore_ascii_case(name))) +} + +fn run_blockreplace(s: &mut Session, p: &Value) -> Result { + let cmd = "blockreplace"; + let old = str_param(p, "block").ok_or_else(|| bad(cmd, "`block` (the block to replace) is required"))?; + let new = str_param(p, "with").ok_or_else(|| bad(cmd, "`with` (the replacement block) is required"))?; + let purge = match p.get("purge") { + None | Some(Value::Null) => false, + Some(Value::Bool(b)) => *b, + Some(v) => return Err(bad(cmd, format!("`purge` must be true or false, not {v}"))), + }; + let (old, new) = (old.to_string(), new.to_string()); + let (n, purged) = replace(s, &old, &new, purge)?; + let d = s.doc()?; + let defined = |name: &str| named_block(d, name).unwrap_or_else(|| name.trim().to_string()); + Ok(json!({ "block": defined(&old), "with": defined(&new), "replaced": n, "purged": purged })) +} + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +enum Stage { + #[default] + Old, + New, + Select, + Purge, +} + +#[derive(Default)] +struct BlockreplaceM { + stage: Stage, + /// The stage a picked reference answers. + back: Stage, + sel: SelectPhase, + old: String, + new: String, +} + +impl BlockreplaceM { + /// Take `name` as the answer to the current name prompt. A block that cannot take part is + /// refused here, so the prompt asks again. + fn take(&mut self, s: &Session, stage: Stage, name: &str) -> Result<()> { + let d = s.doc()?; + let name = named_block(d, name).ok_or_else(|| EngineError::Other(format!("Block \"{}\" not found.", name.trim())))?; + if stage == Stage::Old { + if let Some(why) = not_replaceable(d, &name) { + return Err(EngineError::Other(why)); + } + self.old = name; + self.stage = Stage::New; + } else { + if name.eq_ignore_ascii_case(&self.old) { + return Err(EngineError::Other("The replacement block must be a different block.".into())); + } + if let Some(why) = cannot_replace_with(d, &self.old, &name) { + return Err(EngineError::Other(why)); + } + self.new = name; + self.stage = Stage::Purge; + } + Ok(()) + } + + fn finish(&mut self, s: &mut Session, purge: bool) -> Result { + let (n, purged) = replace(s, &self.old, &self.new, purge)?; + s.echo(format!( + "{n} reference{} of block {} replaced with {}.{}", + if n == 1 { "" } else { "s" }, + self.old, + self.new, + if purged { format!(" Block {} purged.", self.old) } else { String::new() } + )); + Ok(Step::Done) + } +} + +impl Interactive for BlockreplaceM { + fn name(&self) -> &'static str { + "BLOCKREPLACE" + } + fn prompt(&self, _s: &Session) -> Prompt { + match self.stage { + Stage::Old => Prompt::new("Enter the name of the block to replace", Accept::TEXT).kw(&["?", "Select"]), + Stage::New => Prompt::new(format!("Enter the name of the replacement block for {}", self.old), Accept::TEXT).kw(&["?", "Select"]), + Stage::Select => Prompt::new("Select a block reference", Accept::SELECT), + Stage::Purge => Prompt::new(format!("Purge the unreferenced block definition {}?", self.old), KW).kw(&["Yes", "No"]).default("Yes"), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + match self.stage { + stage @ (Stage::Old | Stage::New) => match &i { + Input::Keyword(k) if k == "?" => { + let d = s.doc()?; + let mut names: Vec = d.blocks.keys().filter_map(|k| named_block(d, k)).collect(); + names.sort_by_key(|n| n.to_ascii_lowercase()); + s.echo(if names.is_empty() { "No blocks defined.".to_string() } else { format!("Blocks: {}", names.join(", ")) }); + } + Input::Keyword(k) if k == "Select" => { + self.back = stage; + self.sel = SelectPhase::single(); + self.stage = Stage::Select; + } + Input::Text(t) if !t.trim().is_empty() => self.take(s, stage, t)?, + Input::Enter | Input::Cancel => return Ok(Step::Done), + _ => {} + }, + Stage::Select => match self.sel.feed(s, &i)? { + SelOutcome::More => {} + SelOutcome::Empty => self.stage = self.back, + SelOutcome::Done(hs) => { + s.set_selection(Vec::new()); + self.sel = SelectPhase::single(); + let block = hs.iter().find_map(|h| match s.doc().ok()?.entity(*h).map(|e| &e.kind) { + Some(EntityKind::Insert(ins)) => Some(ins.block.clone()), + _ => None, + }); + match block { + Some(b) => self.take(s, self.back, &b)?, + None => return Err(EngineError::Other("That object is not a block reference.".into())), + } + } + }, + Stage::Purge => match &i { + Input::Keyword(k) if k == "No" => return self.finish(s, false), + Input::Keyword(_) | Input::Enter => return self.finish(s, true), + Input::Cancel => return Ok(Step::Done), + _ => return Err(EngineError::Other("Invalid option keyword.".into())), + }, + } + Ok(Step::Continue) + } +} diff --git a/crates/engine/src/cmd/blocks/purge.rs b/crates/engine/src/cmd/blocks/purge.rs index 73c0ddc9b..68aea3c36 100644 --- a/crates/engine/src/cmd/blocks/purge.rs +++ b/crates/engine/src/cmd/blocks/purge.rs @@ -119,7 +119,7 @@ fn entities(d: &Drawing) -> impl Iterator impl Iterator + '_ { +pub(in crate::cmd) fn override_arrows(dm: &cadcraft_doc::Dimension) -> impl Iterator + '_ { dm.overrides .iter() .filter(|(k, _)| cadcraft_doc::DimStyle::field_name(k).is_some_and(|f| f.starts_with("arrowBlock") || f == "leaderArrowBlock")) diff --git a/crates/engine/src/cmd/blocks/xref/load.rs b/crates/engine/src/cmd/blocks/xref/load.rs index efe9143a8..fac289f23 100644 --- a/crates/engine/src/cmd/blocks/xref/load.rs +++ b/crates/engine/src/cmd/blocks/xref/load.rs @@ -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)); + } } diff --git a/crates/engine/src/cmd/constraints.rs b/crates/engine/src/cmd/constraints.rs index 54af88c80..b37ff9882 100644 --- a/crates/engine/src/cmd/constraints.rs +++ b/crates/engine/src/cmd/constraints.rs @@ -14,7 +14,8 @@ use cadcraft_geom::{Line, Vec2}; use serde_json::{Value, json}; use super::machines::{SelOutcome, SelectPhase}; -use super::*; +use super::{CommandSpec, bad, bool_or, f64_or, point_value, str_param, targets}; + use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step}; macro_rules! gc { @@ -327,7 +328,7 @@ fn add_constraint(s: &mut Session, c: Constraint) -> Result { let keep: Vec = c.refs.first().map(|r| r.handle).into_iter().collect(); let prefer_move: Vec = c.refs.iter().skip(1).map(|r| r.handle).filter(|h| !keep.contains(h)).collect(); let mut d = s.doc()?.clone(); - let id = cs::add(&mut d, c, &cs::SolveOptions { keep, prefer_move }).map_err(err)?; + let id = cs::add(&mut d, c, &cs::SolveOptions { keep, prefer_move, ..Default::default() }).map_err(err)?; *s.doc_mut()? = d; Ok(id) } @@ -1119,7 +1120,7 @@ fn infer_after(s: &mut Session, before: &Drawing) { return; } let mut nd = d.clone(); - let opts = cs::SolveOptions { keep: others, prefer_move: placed }; + let opts = cs::SolveOptions { keep: others, prefer_move: placed, ..Default::default() }; let added = cands.into_iter().take(200).filter(|c| cs::add(&mut nd, c.clone(), &opts).is_ok()).count(); if added > 0 && let Ok(st) = s.state_mut() @@ -1148,7 +1149,8 @@ pub fn after_command(s: &mut Session, before: Option<&Arc>, ok: bool) { let mut d = (*st.doc).clone(); let purged = cs::purge_invalid(&mut d); let restored = cs::restore_fixed(&mut d, before); - let satisfied = cs::all_satisfied(&d); + let anchors = cs::moved_anchors(&d, before); + let satisfied = anchors.is_empty() && cs::all_satisfied(&d); if purged.is_empty() && !restored && satisfied { return; } @@ -1167,9 +1169,25 @@ pub fn after_command(s: &mut Session, before: Option<&Arc>, 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 = 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}")), } } diff --git a/crates/engine/src/cmd/draw.rs b/crates/engine/src/cmd/draw.rs index ce8c8f7d1..6f4b77c84 100644 --- a/crates/engine/src/cmd/draw.rs +++ b/crates/engine/src/cmd/draw.rs @@ -5,10 +5,15 @@ use cadcraft_doc::{EntityKind, HAlign, MText, Point, RayLine, Text, VAlign}; use cadcraft_geom::{Arc, Circle, Ellipse, PolyVertex, Polyline, Spline, Vec2, arc_to_bulge, bulge_to_arc}; use serde_json::{Value, json}; -use super::helpers::*; +use super::helpers::{arc, circle, line, lwpoly, polygon_from_edge, polygon_typed_radius_point, polygon_vertices, v3}; + use super::machines::number; use super::pline_opts::{Outcome, PlineAsk}; -use super::*; +use super::{ + CommandSpec, bad, bool_or, distinct_points, f64_or, f64_req, find_command, point_param, point_req, point_value, points_param, size_param, + str_param, +}; + use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step, snap}; /// Every draw command's spec: JSON parameters, menu path, aliases and the interactive prompt diff --git a/crates/engine/src/cmd/draw2.rs b/crates/engine/src/cmd/draw2.rs index 68dfd5dbc..4f52bbef5 100644 --- a/crates/engine/src/cmd/draw2.rs +++ b/crates/engine/src/cmd/draw2.rs @@ -7,9 +7,11 @@ use cadcraft_geom::{Arc, Circle, Ellipse, Line, PolyVertex, Polyline, Spline, TA use serde_json::{Value, json}; use super::curves::{self, Chain, MAX_GEN, TanObj}; -use super::helpers::*; +use super::helpers::{arc, circle, line, lwpoly, rect_vertices, rect_with_corners, v3}; + use super::machines::number; -use super::*; +use super::{CommandSpec, bad, bool_or, f64_or, f64_req, point_param, point_req, point_value, points_param, size_param, str_param, targets}; + use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step}; pub(crate) mod breakline; @@ -1083,7 +1085,9 @@ impl Interactive for RevcloudM { let first_kw: &[&str] = &["Arc length", "Object", "Rectangular", "Polygonal", "Freehand", "Style"]; match (self.kind, self.pts.len()) { (CloudKind::Rect, 0) => Prompt::new("Specify first corner point", Accept::POINT).kw(first_kw).default("Object"), - (CloudKind::Rect, _) => Prompt::new("Specify opposite corner", Accept::POINT).base_opt(self.pts.first().copied()), + (CloudKind::Rect, _) => { + Prompt { corner: true, ..Prompt::new("Specify opposite corner", Accept::POINT).base_opt(self.pts.first().copied()) } + } (CloudKind::Poly, 0) => Prompt::new("Specify start point", Accept::POINT).kw(first_kw).default("Object"), (CloudKind::Poly, 1) => Prompt::new("Specify next point", Accept::POINT).base_opt(self.pts.last().copied()), (CloudKind::Poly, _) => Prompt::new("Specify next point", Accept::POINT).kw(&["Undo"]).base_opt(self.pts.last().copied()), @@ -1570,7 +1574,7 @@ impl Interactive for XlineM2 { // RECTANG with Area / Dimensions / Rotation / Width / Elevation / Thickness // ===================================================================================== -fn rect_kind(first: Vec2, w: f64, h: f64, rot: f64, fillet: f64, chamfer: (f64, f64), width: f64, elevation: f64) -> EntityKind { +fn rect_kind(first: Vec2, (w, h): (f64, f64), rot: f64, fillet: f64, chamfer: (f64, f64), width: f64, elevation: f64) -> EntityKind { let local = rect_with_corners(Vec2::ZERO, Vec2::new(w, h), fillet, chamfer); let vs = local.into_iter().map(|v| PolyVertex { p: first + v.p.rotate(rot), ..v }).collect(); let mut k = lwpoly(vs, true); @@ -1581,25 +1585,10 @@ fn rect_kind(first: Vec2, w: f64, h: f64, rot: f64, fillet: f64, chamfer: (f64, k } -/// Draws a rectangle from the JSON form: `p1` is a corner, or the centre when `center` is -/// true, and the size comes from `p2`, `dimensions` or `area` plus `length`/`breadth`. -pub(crate) fn run_rectang2(s: &mut Session, p: &Value) -> Result { - let a = point_req("rectang", p, "p1")?; - // Centre mode: `p1` is the rectangle's centre, so the rectangle spans it evenly. - let centered = bool_or(p, "center", false); - let rot = f64_or(p, "rotation", 0.0).to_radians(); - let ch = p - .get("chamfer") - .and_then(Value::as_array) - .map(|c| (c.first().and_then(Value::as_f64).unwrap_or(0.0), c.get(1).and_then(Value::as_f64).unwrap_or(0.0))) - .map(|(x, y)| (if x.is_finite() { x } else { 0.0 }, if y.is_finite() { y } else { 0.0 })) - .unwrap_or((0.0, 0.0)); - if ch.0 < 0.0 || ch.1 < 0.0 { - return Err(bad("rectang", "`chamfer` distances must be zero or positive")); - } - let fillet = size_param("rectang", p, "fillet", true)?.unwrap_or(0.0); - let width = size_param("rectang", p, "width", true)?.unwrap_or(0.0); - let (w, h) = if let Some(d) = p.get("dimensions").and_then(Value::as_array) { +/// The width and height of a rectangle from its `dimensions`, its `p2` corner, its `area` with +/// `length` or `breadth`, or its second corner `p2`, rotated by `rot` about `a`. +fn rect_size(p: &Value, a: Vec2, rot: f64, centered: bool) -> Result<(f64, f64)> { + Ok(if let Some(d) = p.get("dimensions").and_then(Value::as_array) { let l = d.first().and_then(Value::as_f64).filter(|v| v.is_finite()).ok_or_else(|| bad("rectang", "dimensions: [length, width]"))?; let wd = d.get(1).and_then(Value::as_f64).filter(|v| v.is_finite()).ok_or_else(|| bad("rectang", "dimensions: [length, width]"))?; let (sx, sy) = match point_param(p, "p2") { @@ -1624,17 +1613,37 @@ pub(crate) fn run_rectang2(s: &mut Session, p: &Value) -> Result { let loc = (b - a).rotate(-rot); // In centre mode the picked point is one corner, mirrored across the centre. if centered { (2.0 * loc.x, 2.0 * loc.y) } else { (loc.x, loc.y) } - }; + }) +} + +/// Draws a rectangle from the JSON form: `p1` is a corner, or the centre when `center` is +/// true, and the size comes from `p2`, `dimensions` or `area` plus `length`/`breadth`. +pub(crate) fn run_rectang2(s: &mut Session, p: &Value) -> Result { + let a = point_req("rectang", p, "p1")?; + // Centre mode: `p1` is the rectangle's centre, so the rectangle spans it evenly. + let centered = bool_or(p, "center", false); + let rot = f64_or(p, "rotation", 0.0).to_radians(); + let ch = p + .get("chamfer") + .and_then(Value::as_array) + .map(|c| (c.first().and_then(Value::as_f64).unwrap_or(0.0), c.get(1).and_then(Value::as_f64).unwrap_or(0.0))) + .map(|(x, y)| (if x.is_finite() { x } else { 0.0 }, if y.is_finite() { y } else { 0.0 })) + .unwrap_or((0.0, 0.0)); + if ch.0 < 0.0 || ch.1 < 0.0 { + return Err(bad("rectang", "`chamfer` distances must be zero or positive")); + } + let fillet = size_param("rectang", p, "fillet", true)?.unwrap_or(0.0); + let width = size_param("rectang", p, "width", true)?.unwrap_or(0.0); + let (w, h) = rect_size(p, a, rot, centered)?; if w.abs() < 1e-12 || h.abs() < 1e-12 || !w.is_finite() || !h.is_finite() { return Err(bad("rectang", "rectangle has no area")); } let first = if centered { a - Vec2::new(w / 2.0, h / 2.0).rotate(rot) } else { a }; - let k = rect_kind(first, w, h, rot, fillet, ch, width, f64_or(p, "elevation", 0.0)); + let k = rect_kind(first, (w, h), rot, fillet, ch, width, f64_or(p, "elevation", 0.0)); let hd = s.add_entity(k)?; + // A rectangle has its own thickness option and does not take THICKNESS. let th = f64_or(p, "thickness", 0.0); - if th != 0.0 { - s.doc_mut()?.modify_entity(hd, |e| e.common.thickness = th)?; - } + s.doc_mut()?.modify_entity(hd, |e| e.common.thickness = th)?; added(hd) } @@ -1682,11 +1691,10 @@ impl RectM2 { return Err(other("The rectangle has no area.")); } let first = self.origin(w, h).unwrap_or(a); - let hd = s.add_entity(rect_kind(first, w, h, self.rotation, self.fillet, self.chamfer, self.width, self.elevation))?; - if self.thickness != 0.0 { - let t = self.thickness; - s.doc_mut()?.modify_entity(hd, |e| e.common.thickness = t)?; - } + let hd = s.add_entity(rect_kind(first, (w, h), self.rotation, self.fillet, self.chamfer, self.width, self.elevation))?; + // A rectangle has its own thickness option and does not take THICKNESS. + let t = self.thickness; + s.doc_mut()?.modify_entity(hd, |e| e.common.thickness = t)?; Ok(Step::Done) } /// The rectangle's local origin (the corner `rect_kind` draws from) for a size: the first @@ -1862,7 +1870,7 @@ impl Interactive for RectM2 { } match self.other_corner(c).and_then(|(w, h)| self.origin(w, h).map(|a| (a, w, h))) { Some((a, w, h)) if w.abs() > 1e-12 && h.abs() > 1e-12 => { - vec![rect_kind(a, w, h, self.rotation, self.fillet, self.chamfer, self.width, self.elevation)] + vec![rect_kind(a, (w, h), self.rotation, self.fillet, self.chamfer, self.width, self.elevation)] } _ => Vec::new(), } diff --git a/crates/engine/src/cmd/draw2/breakline.rs b/crates/engine/src/cmd/draw2/breakline.rs index 37e20a492..c351b0a88 100644 --- a/crates/engine/src/cmd/draw2/breakline.rs +++ b/crates/engine/src/cmd/draw2/breakline.rs @@ -9,10 +9,11 @@ use cadcraft_doc::{Drawing, EntityKind}; use cadcraft_geom::{PolyVertex, Vec2}; use serde_json::Value; +use super::super::machines::number; use super::added; use super::curves::MAX_GEN; -use super::machines::number; -use super::*; +use super::{CommandSpec, line, lwpoly, point_param, point_req, size_param, str_param}; + use crate::cmd::modify2::as_lwpoly; use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step}; diff --git a/crates/engine/src/cmd/draw2/mline.rs b/crates/engine/src/cmd/draw2/mline.rs index 2f1b7edc6..8e6958abb 100644 --- a/crates/engine/src/cmd/draw2/mline.rs +++ b/crates/engine/src/cmd/draw2/mline.rs @@ -9,9 +9,11 @@ use cadcraft_doc::{ use cadcraft_geom::{Line, Vec2, line_line_infinite}; use serde_json::{Value, json}; +use super::super::machines::number; +use super::super::{distinct_points, ok}; use super::curves::{self, MAX_GEN}; -use super::machines::number; -use super::*; +use super::{CommandSpec, bad, bool_or, f64_or, point_req, points_param, str_param}; + use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step}; pub fn specs() -> Vec { diff --git a/crates/engine/src/cmd/draworder.rs b/crates/engine/src/cmd/draworder.rs index fab673353..8020dc520 100644 --- a/crates/engine/src/cmd/draworder.rs +++ b/crates/engine/src/cmd/draworder.rs @@ -8,7 +8,8 @@ use cadcraft_doc::{EntityKind, Handle}; use serde_json::{Value, json}; use super::machines::{SelOutcome, SelectPhase, SelectRun}; -use super::*; +use super::{CommandSpec, EngineError, bad, ok, targets}; + use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step}; pub fn specs() -> Vec { diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 822ef8760..3a4c43e73 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -5,7 +5,8 @@ use cadcraft_geom::{Bounds2, Mat3, Vec2}; use serde_json::{Value, json}; use super::machines::{SelOutcome, SelectPhase, SelectRun}; -use super::*; +use super::{CommandSpec, EngineError, bad, bool_or, clipboard, f64_or, has_selection, point_param, point_req, points_param, str_param, targets}; + use crate::undoctl::Control; use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step}; @@ -26,6 +27,10 @@ pub fn specs() -> Vec { .key("Cmd+X") .params("{handles?}") .interactive(|_| Ok(Box::new(SelectRun::new("cutclip", "CUTCLIP")))), + CommandSpec::new("cutbase", "Cut with Base Point", run_cutbase) + .menu(&["Edit", "Cut with Base Point"]) + .params("{handles?, base: [x,y]}") + .interactive(|_| Ok(Box::new(CopyBaseM { cut: true, ..CopyBaseM::default() }))), CommandSpec::new("copyclip", "Copy", run_copyclip) .menu(&["Edit", "Copy"]) .key("Cmd+C") @@ -434,9 +439,11 @@ impl Interactive for MredoM { } } +/// Copy the objects of `hs` to the clipboard, each once however often `hs` lists it. fn copy_to_clip(s: &mut Session, hs: &[Handle], base: Option) -> Result { let d = s.doc()?; - let ents: Vec = hs.iter().filter_map(|h| d.entity(*h).map(|e| (**e).clone())).collect(); + let mut seen = std::collections::HashSet::new(); + let ents: Vec = hs.iter().filter(|h| seen.insert(**h)).filter_map(|h| d.entity(*h).map(|e| (**e).clone())).collect(); let b = ents.iter().fold(Bounds2::EMPTY, |acc, e| acc.union(&entity_bounds(d, e, 0))); let base = base.unwrap_or(if b.min.is_finite() { b.min } else { Vec2::ZERO }); let n = ents.len(); @@ -451,22 +458,59 @@ fn run_copyclip(s: &mut Session, p: &Value) -> Result { } fn run_copybase(s: &mut Session, p: &Value) -> Result { + let base = point_req("copybase", p, "base")?; let hs = targets(s, p)?; - let base = point_param(p, "base"); - let n = copy_to_clip(s, &hs, base)?; + let n = copy_to_clip(s, &hs, Some(base))?; Ok(json!({ "copied": n })) } -fn run_cut(s: &mut Session, p: &Value) -> Result { - let mut hs = targets(s, p)?; - hs.retain(|h| !super::curves::is_locked(s, *h)); - let n = copy_to_clip(s, &hs, None)?; +/// Copy the objects that are not on locked layers to the clipboard and erase them. Returns how +/// many were cut and the message to show: how many were on a locked layer, and that nothing was +/// cut when that is all of them (the clipboard then keeps what it held). +fn cut_to_clip(s: &mut Session, hs: &[Handle], base: Option) -> Result<(usize, Option)> { + let mut seen = std::collections::HashSet::new(); + let live: Vec = { + let d = s.doc()?; + hs.iter().copied().filter(|h| seen.insert(*h) && d.entity(*h).is_some()).collect() + }; + let (hs, locked): (Vec, Vec) = live.into_iter().partition(|h| !super::curves::is_locked(s, *h)); + let note = match locked.len() { + 0 => None, + 1 => Some("1 object was on a locked layer.".to_string()), + n => Some(format!("{n} objects were on a locked layer.")), + }; + if hs.is_empty() { + let msg = note.map_or_else(|| "Nothing was cut.".to_string(), |m| format!("{m} Nothing was cut.")); + return Ok((0, Some(msg))); + } + let n = copy_to_clip(s, &hs, base)?; let d = s.doc_mut()?; for h in &hs { d.remove_entity(*h); } s.set_selection(Vec::new()); - Ok(json!({ "cut": n })) + Ok((n, note)) +} + +fn cut_result(n: usize, msg: Option) -> Value { + let mut r = json!({ "cut": n }); + if let Some(m) = msg { + r["message"] = json!(m); + } + r +} + +fn run_cut(s: &mut Session, p: &Value) -> Result { + let hs = targets(s, p)?; + let (n, msg) = cut_to_clip(s, &hs, None)?; + Ok(cut_result(n, msg)) +} + +fn run_cutbase(s: &mut Session, p: &Value) -> Result { + let base = point_req("cutbase", p, "base")?; + let hs = targets(s, p)?; + let (n, msg) = cut_to_clip(s, &hs, Some(base))?; + Ok(cut_result(n, msg)) } /// Paste the clipboard moved by `offset` (the clipboard brings the layers, styles and blocks its @@ -649,23 +693,31 @@ impl Interactive for SelectM { } } -/// COPYBASE: base point first, then "Select objects:" (skipped with a pickfirst selection). +/// COPYBASE and CUTBASE: base point first, then "Select objects:" (skipped with a pickfirst +/// selection). CUTBASE also erases the objects it copies. #[derive(Default)] struct CopyBaseM { base: Option, sel: SelectPhase, + cut: bool, } impl CopyBaseM { fn copy(&self, s: &mut Session, hs: &[Handle]) -> Result { - copy_to_clip(s, hs, self.base)?; + if self.cut { + if let (_, Some(m)) = cut_to_clip(s, hs, self.base)? { + s.echo(m); + } + } else { + copy_to_clip(s, hs, self.base)?; + } Ok(Step::Done) } } impl Interactive for CopyBaseM { fn name(&self) -> &'static str { - "COPYBASE" + if self.cut { "CUTBASE" } else { "COPYBASE" } } fn begin(&mut self, s: &mut Session) -> Result { self.sel = SelectPhase::begin(s); diff --git a/crates/engine/src/cmd/exchange.rs b/crates/engine/src/cmd/exchange.rs index da842c6f0..f9acc909e 100644 --- a/crates/engine/src/cmd/exchange.rs +++ b/crates/engine/src/cmd/exchange.rs @@ -18,7 +18,8 @@ use cadcraft_render::raster::{RasterOptions, View}; use serde_json::{Value, json}; use super::file::{base64_decode, base64_encode, io}; -use super::*; +use super::{CommandSpec, bad, point_value, str_param}; + use crate::{EngineError, Result, Session}; /// Formats EXPORT writes (the io layer picks the writer from the extension). @@ -557,7 +558,10 @@ mod tests { use cadcraft_geom::Vec3; use super::super::file::{IoHooks, set_io}; + + use super::super::resolve_alias; use super::*; + use serde_json::json; static DELIVERED: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 3bb9f870d..95975a4e2 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -11,7 +11,8 @@ use cadcraft_doc::Drawing; pub use cadcraft_dxf::Version; use serde_json::{Value, json}; -use super::*; +use super::{CommandSpec, EngineError, Interactive, always, bad, bool_or, find_command, ok, str_param}; + use crate::{Accept, Input, Prompt, Result, Session, Step}; /// The rules the Command Aliases dialog checks before it sends an alias to ALIASEDIT. @@ -362,6 +363,7 @@ fn run_open(s: &mut Session, p: &Value) -> Result { 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 { let i = p.get("index").and_then(Value::as_u64).map(|i| i as usize).unwrap_or(s.active); if i >= s.docs.len() { @@ -376,13 +378,16 @@ fn run_close(s: &mut Session, p: &Value) -> Result { 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 { s.cancel(); s.docs.clear(); s.active = 0; + s.bump_doc_epoch(); ok() } @@ -487,6 +492,7 @@ fn drawing_version(s: &Session, path: &str) -> Version { if is_dxf(path) { v } else { v.max(Version::R2004) } } +/// `document.switch index`: require the index, cancel the running command, activate that drawing and bump the epoch even if it was already active (screenshot identity). fn run_switch(s: &mut Session, p: &Value) -> Result { let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("document.switch", "`index` is required"))? as usize; if i >= s.docs.len() { @@ -494,6 +500,11 @@ fn run_switch(s: &mut Session, p: &Value) -> Result { } s.cancel(); s.active = i; + // Bump even when `i` is already active. This is intentionally conservative: the command + // cancels the active command, and a screenshot client can only see observed state, not + // whether the switch was a no-op. Rejecting an outstanding capture is safer than returning + // an image whose queueing identity the client cannot distinguish from a real switch. + s.bump_doc_epoch(); ok() } @@ -721,6 +732,7 @@ mod tests { #[cfg(test)] mod pr193_tests { use super::*; + use crate::Session; #[test] fn failed_store_keeps_unsaved_changes_and_delivered_save_clears_them() { diff --git a/crates/engine/src/cmd/file/compare.rs b/crates/engine/src/cmd/file/compare.rs index 15fb17ec1..295b472b1 100644 --- a/crates/engine/src/cmd/file/compare.rs +++ b/crates/engine/src/cmd/file/compare.rs @@ -2,7 +2,7 @@ //! drawing). Objects are matched by geometry and properties first (preferring the same handle), //! then by handle: the same handle with other geometry or properties is a change. What is left //! is only in this drawing or only in the other. The comparison stays on the session for the -//! canvas overlay and next/previous navigation until COMPARECLOSE. +//! canvas overlay, next/previous navigation and COMPAREEXPORT until COMPARECLOSE. use std::collections::HashMap; use std::hash::{Hash, Hasher}; @@ -12,10 +12,13 @@ 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}; +mod export; + /// Objects compared per drawing; more are left out (and the result says so). pub const MAX_OBJECTS: usize = 200_000; /// Objects listed per kind of difference in the JSON result. @@ -36,6 +39,9 @@ pub struct Comparison { pub other_name: String, /// The other drawing (its objects are drawn where they differ). pub other: Arc, + /// The other drawing's file, when it has one: COMPAREEXPORT resolves its relative xref + /// paths from that file's folder. + pub other_path: Option, /// Only in this drawing (handles here). pub added: Vec, /// Only in the other drawing (handles there). @@ -71,6 +77,7 @@ pub fn specs() -> Vec { CommandSpec::new("compare.next", "Next Change", |s, _| step(s, 1)).params("→ {region, of}: zoom to the next area with changes").noundo(), CommandSpec::new("compare.previous", "Previous Change", |s, _| step(s, -1)).params("→ {region, of}").noundo(), CommandSpec::new("compareclose", "Close Comparison", run_close).params("{}: end the comparison and its overlay").noundo(), + export::spec(), ] } @@ -133,11 +140,23 @@ struct Signer<'a> { d: &'a Drawing, blocks: HashMap, work: usize, + /// When set, a block's signature also covers its base point, units, annotative and overlay + /// flags and xref file, with relative xref, image and data link paths taken from the folder + /// given, and the + /// signatures of a multileader and a dimension cover their blocks'. Each object in a block + /// then also counts with its visibility, transparency, thickness and extrusion, and an + /// anonymous block's signature leaves out its name (a dimension also leaves its block's name + /// out of its own geometry; an INSERT or MLEADER keeps it). + frames: Option>, } impl<'a> Signer<'a> { fn new(d: &'a Drawing) -> Self { - Signer { d, blocks: HashMap::new(), work: 0 } + Signer { d, blocks: HashMap::new(), work: 0, frames: None } + } + /// A signer whose block signatures also cover what changes how a reference draws the block. + fn with_frames(d: &'a Drawing, folder: Option<&'a std::path::Path>) -> Self { + Signer { frames: Some(folder), ..Signer::new(d) } } fn geometry(&mut self, k: &EntityKind, depth: usize) -> u64 { let mut h = std::collections::hash_map::DefaultHasher::new(); @@ -156,6 +175,18 @@ impl<'a> Signer<'a> { if let EntityKind::Insert(i) = k { self.block(&i.block, depth).hash(&mut h); } + if self.frames.is_some() + && let EntityKind::MLeader(m) = k + && let Some(b) = &m.block + { + self.block(&b.block, depth).hash(&mut h); + } + if self.frames.is_some() + && let EntityKind::Dimension(dm) = k + && let Some(b) = &dm.block + { + self.block(b, depth).hash(&mut h); + } h.finish() } fn block(&mut self, name: &str, depth: usize) -> u64 { @@ -164,17 +195,42 @@ impl<'a> Signer<'a> { return *v; } let mut h = std::collections::hash_map::DefaultHasher::new(); - key.hash(&mut h); + let block = self.d.block(name); + // With frames, an anonymous block (`*D3`, `*U7`) is signed by its contents only: the + // same block can have another generated name in another drawing. Only a dimension's + // geometry leaves the name out, so this matches renamed `*D` blocks, not `*U` references. + if !(self.frames.is_some() && block.is_some_and(|b| b.anonymous)) { + key.hash(&mut h); + } if depth < MAX_BLOCK_DEPTH - && let Some(b) = self.d.block(name) + && let Some(b) = block { + if let Some(folder) = self.frames { + let mut xref = b.xref_path.clone(); + if let Some(x) = xref.as_mut() { + export::rebase_path(x, folder); + } + (format!("{:?}", b.base), b.units, b.annotative, b.xref_overlay, xref).hash(&mut h); + } for e in b.entities.iter() { if self.work >= MAX_BLOCK_WORK { break; } self.work += 1; - self.geometry(&e.kind, depth + 1).hash(&mut h); + match self.frames { + // An image's file and a table's data link, from the folder given. + Some(folder) if matches!(e.kind, EntityKind::Image(_) | EntityKind::Table(_)) => { + let mut k = e.kind.clone(); + export::rebase(&mut k, folder); + self.geometry(&k, depth + 1).hash(&mut h); + } + _ => self.geometry(&e.kind, depth + 1).hash(&mut h), + } properties(e).hash(&mut h); + if self.frames.is_some() { + let c = &e.common; + format!("{:?}|{}|{}|{:?}", c.transparency, c.visible, (c.thickness * 1e6).round(), c.extrusion).hash(&mut h); + } } } let v = h.finish(); @@ -311,6 +367,7 @@ fn listed(d: &Drawing, hs: &[Handle]) -> Vec { fn run_compare(s: &mut Session, p: &Value) -> Result { let (other, name) = other_drawing(s, p)?; + let other_path = export::source_path(s, p, &other); let st = s.state()?; let (uid, space, cur) = (st.uid, st.space.clone(), st.doc.clone()); if Arc::ptr_eq(&cur, &other) { @@ -361,7 +418,7 @@ fn run_compare(s: &mut Session, p: &Value) -> Result { "message": message, }); let changed = changed.into_iter().map(|(a, b, _, _)| (a, b)).collect(); - s.compare = Some(Comparison { uid, space, other_name: name, other, added, removed, changed, common, truncated, regions, at: None }); + s.compare = Some(Comparison { uid, space, other_name: name, other, other_path, added, removed, changed, common, truncated, regions, at: None }); s.touch(); Ok(out) } diff --git a/crates/engine/src/cmd/file/compare/export.rs b/crates/engine/src/cmd/file/compare/export.rs new file mode 100644 index 000000000..f3801049d --- /dev/null +++ b/crates/engine/src/cmd/file/compare/export.rs @@ -0,0 +1,684 @@ +//! COMPAREEXPORT: the comparison as a new drawing (a snapshot). The drawings are compared again +//! when the command runs, so edits made since COMPARE count. The new drawing's model space holds +//! copies of the other drawing's objects that differ, then copies of the current drawing's +//! objects in the compared tab, as two groups, `COMPARE_COMPARED` and `COMPARE_CURRENT`. Objects +//! only in this drawing or changed in it are drawn in the overlay's green, objects only in the +//! other drawing or changed there in its red, and objects in both in gray (color 8). A block +//! reference, dimension or multileader refers to a copy of its block whose objects all have that +//! color (`COMPARE_CURRENT_`, `COMPARE_COMPARED_`, `COMPARE_COMMON_`; for a +//! dimension's `*D` block, another `*D` block), and a table or tolerance uses a recolored copy +//! of its style; xrefs keep their own colors. The new +//! drawing has this drawing's layers, styles and blocks, plus the layers and styles of the other +//! drawing that this one lacks and the blocks its copied objects refer to; an anonymous block of +//! the other drawing whose name this one uses is copied under a free name. Relative xref, image +//! and data link paths become absolute paths from the folder of the drawing they came from. It +//! opens untitled as `compare_ vs .dwg`, with unsaved changes; saving it asks for a +//! file name. + +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use cadcraft_doc::color::{Color, Rgb}; +use cadcraft_doc::{Block, CellStyle, DimStyle, Drawing, Entity, EntityKind, EntityStore, Group, Handle, MAX_BLOCK_DEPTH, TableStyle}; +use serde_json::{Value, json}; + +use super::super::super::{CommandSpec, bad}; +use super::{Comparison, MAX_OBJECTS}; +use crate::{Result, Session}; + +/// Only in this drawing, or changed in it (the overlay's color). +const THIS: Color = Color::True(Rgb(0x4c, 0xd9, 0x7b)); +/// Only in the other drawing, or changed there. +const OTHER: Color = Color::True(Rgb(0xff, 0x5c, 0x5c)); +/// In both drawings. +const COMMON: Color = Color::Index(8); +/// Block definitions copied from the other drawing (the ones they reference included), and +/// recolored copies of blocks and styles, per export. +const MAX_BLOCKS: usize = 10_000; +/// Objects copied into block definitions (the other drawing's blocks and the recolored +/// copies), per export. +const MAX_COPIED: usize = 1_000_000; + +pub(super) fn spec() -> CommandSpec { + CommandSpec::new("compareexport", "Export Comparison", run) + .params( + "{} → {index, title, current, compared: group names, counts: {current, compared}, truncated, message}: the comparison \ + as a new drawing, opened untitled", + ) + .noundo() +} + +/// What [`snapshot`] made: the drawing, the two group names, how many objects each holds, +/// whether a drawing had more objects than were compared, whether the other drawing had more +/// blocks to copy than [`MAX_BLOCKS`], and whether a block was left in its own colors. +struct Snapshot { + drawing: Drawing, + current: String, + compared: String, + counts: (usize, usize), + truncated: bool, + blocks_left_out: bool, + uncolored: bool, +} + +fn run(s: &mut Session, _p: &Value) -> Result { + let st = s.state()?; + let (uid, cur, title, cur_folder) = (st.uid, st.doc.clone(), st.title.clone(), folder_of(st.path.as_deref())); + let c = s.compare.as_ref().filter(|c| c.uid == uid).ok_or_else(|| bad("compareexport", "no comparison: run COMPARE first"))?; + let name = format!("compare_{} vs {}.dwg", stem(&title), stem(&c.other_name)); + let folders = Folders { here: cur_folder, there: folder_of(c.other_path.as_deref()) }; + let Snapshot { drawing, current, compared, counts, truncated, blocks_left_out, uncolored } = snapshot(&cur, c, &folders); + let i = s.open_drawing(drawing, &name, None); + // Not saved yet: closing it asks to save it under a name. + let st = s.state_mut()?; + st.saved = Arc::new((*st.doc).clone()); + let mut notes = Vec::new(); + if truncated { + notes.push(format!("only the first {MAX_OBJECTS} objects of each drawing were compared")); + } + if blocks_left_out { + notes.push("some blocks of the other drawing were too many or too large to copy".to_string()); + } + if uncolored { + notes.push("some blocks keep their own colors".to_string()); + } + let notes = if notes.is_empty() { String::new() } else { format!(" ({})", notes.join("; ")) }; + Ok(json!({ + "index": i, + "title": name, + "current": current, + "compared": compared, + "counts": { "current": counts.0, "compared": counts.1 }, + "truncated": truncated || blocks_left_out || uncolored, + "message": format!("The comparison was exported to the new drawing {name}{notes}."), + })) +} + +/// The file of the drawing COMPARE compares with: the `path` it was read from, or the open +/// drawing's own file. `None` for `{data}` and for an open drawing never saved. +pub(super) fn source_path(s: &Session, p: &Value, other: &Arc) -> Option { + match p.get("path").and_then(Value::as_str) { + Some(path) if p.get("document").is_none_or(Value::is_null) => Some(path.to_string()), + _ => s.docs.iter().find(|d| Arc::ptr_eq(&d.doc, other)).and_then(|d| d.path.clone()), + } +} + +/// The folders of the two drawings' files. The snapshot has no folder of its own, so the +/// relative file paths it copies (xrefs, images, table data links) become absolute paths from +/// the folder of the drawing they came from. +struct Folders { + here: Option, + there: Option, +} + +/// The absolute folder of the file at `path`. +fn folder_of(path: Option<&str>) -> Option { + let parent = Path::new(path?).parent()?; + let parent = if parent.as_os_str().is_empty() { Path::new(".") } else { parent }; + Some(std::path::absolute(parent).unwrap_or_else(|_| parent.to_path_buf())) +} + +/// `p` as an absolute path from `folder` when it is relative. +pub(super) fn rebase_path(p: &mut String, folder: Option<&Path>) { + if let Some(folder) = folder + && !p.trim().is_empty() + && Path::new(p.as_str()).is_relative() + { + let joined = folder.join(p.as_str()); + *p = std::path::absolute(&joined).unwrap_or(joined).to_string_lossy().to_string(); + } +} + +/// Rebase the file an object refers to: an image's file, a table's data link. +pub(super) fn rebase(k: &mut EntityKind, folder: Option<&Path>) { + match k { + EntityKind::Image(i) => rebase_path(&mut i.path, folder), + EntityKind::Table(t) => { + if let Some(l) = t.data_link.as_mut() { + rebase_path(&mut l.path, folder); + } + } + _ => {} + } +} + +/// Rebase a block's xref path and the files its objects refer to. +fn rebase_block(b: &mut Block, folder: Option<&Path>) { + if let Some(x) = b.xref_path.as_mut() { + rebase_path(x, folder); + } + let mut store = EntityStore::new(); + for e in b.entities.iter() { + let mut e = (**e).clone(); + rebase(&mut e.kind, folder); + store.push(e); + } + b.entities = store; +} + +/// Whether a block holds a relative file path that [`rebase_block`] would change. +fn has_relative_paths(b: &Block) -> bool { + let rel = |p: &str| !p.trim().is_empty() && Path::new(p).is_relative(); + b.xref_path.as_deref().is_some_and(rel) + || b.entities.iter().any(|e| match &e.kind { + EntityKind::Image(i) => rel(&i.path), + EntityKind::Table(t) => t.data_link.as_ref().is_some_and(|l| rel(&l.path)), + _ => false, + }) +} + +/// A drawing title without its file extension. +fn stem(title: &str) -> String { + std::path::Path::new(title).file_stem().map(|n| n.to_string_lossy().to_string()).filter(|n| !n.is_empty()).unwrap_or_else(|| title.to_string()) +} + +fn snapshot(cur: &Drawing, c: &Comparison, folders: &Folders) -> Snapshot { + let other: &Drawing = &c.other; + let mut d = cur.clone(); + if folders.here.is_some() { + for b in d.blocks.values_mut().filter(|b| has_relative_paths(b)) { + rebase_block(Arc::make_mut(b), folders.here.as_deref()); + } + } + d.model = EntityStore::new(); + for l in &mut d.layouts { + l.entities = EntityStore::new(); + } + d.foreign = Arc::default(); + d.groups.clear(); + d.constraints.clear(); + // The per-object constraint bar and dimension exceptions name objects the copy doesn't have. + d.parametric.settings.bar_exceptions.clear(); + d.parametric.settings.dim_exceptions.clear(); + d.hidden_objects.clear(); + d.read_notes.clear(); + d.refedit = None; + d.skipped_records.clear(); + d.handseed = d.handseed.max(other.handseed); + + merge(&mut d.layers, &other.layers, |t| &t.name); + merge(&mut d.linetypes, &other.linetypes, |t| &t.name); + merge(&mut d.text_styles, &other.text_styles, |t| &t.name); + merge(&mut d.dim_styles, &other.dim_styles, |t| &t.name); + merge(&mut d.mleader_styles, &other.mleader_styles, |t| &t.name); + merge(&mut d.table_styles, &other.table_styles, |t| &t.name); + merge(&mut d.mline_styles, &other.mline_styles, |t| &t.name); + + // Compared again, so edits made since COMPARE ran are classified as they are now. + let diff = super::diff(cur, other, &c.space); + let ours: HashSet = diff.added.iter().copied().chain(diff.changed.iter().map(|(a, ..)| *a)).collect(); + let here: Vec<(&Entity, Color)> = cur + .space(&c.space) + .into_iter() + .flat_map(EntityStore::iter) + .filter(|e| !matches!(e.kind, EntityKind::Viewport(_))) + .take(MAX_OBJECTS) + .map(|e| (e.as_ref(), if ours.contains(&e.handle) { THIS } else { COMMON })) + .collect(); + // In the other drawing's draw order. + let theirs: HashSet = diff.removed.iter().copied().chain(diff.changed.iter().map(|(_, b, ..)| *b)).collect(); + let there: Vec<(&Entity, Color)> = other + .space(&c.space) + .into_iter() + .flat_map(EntityStore::iter) + .filter(|e| theirs.contains(&e.handle) && !matches!(e.kind, EntityKind::Viewport(_))) + .map(|e| (e.as_ref(), OTHER)) + .collect(); + let mut tinted = Tinted::default(); + let (renames, blocks_left_out) = copy_blocks(&mut d, other, there.iter().map(|(e, _)| *e), folders.there.as_deref(), &mut tinted); + + // The other drawing's objects first, so this drawing's are drawn over them. + let compared = add_group(&mut d, "COMPARE_COMPARED", &there, &renames, folders.there.as_deref(), &mut tinted); + let current = add_group(&mut d, "COMPARE_CURRENT", &here, &Renames::new(), folders.here.as_deref(), &mut tinted); + let uncolored = tinted.uncolored; + Snapshot { drawing: d, current, compared, counts: (here.len(), there.len()), truncated: diff.truncated, blocks_left_out, uncolored } +} + +/// Add the entries of `from` whose names `to` lacks (compared without letter case). +fn merge(to: &mut Vec, from: &[T], name: impl Fn(&T) -> &String) { + for t in from { + if !to.iter().any(|x| name(x).eq_ignore_ascii_case(name(t))) { + to.push(t.clone()); + } + } +} + +/// Add copies of `objs` to model space with new handles, their block references renamed by +/// `renames` and their files rebased on `folder`, each painted in its comparison color, and +/// group them as `name` (or `name_2`… when that group name is taken). Returns the group's name. +fn add_group(d: &mut Drawing, name: &str, objs: &[(&Entity, Color)], renames: &Renames, folder: Option<&Path>, tinted: &mut Tinted) -> String { + let mut members = Vec::new(); + for (e, color) in objs { + d.ensure_layer(&e.common.layer); + let h = d.new_handle(); + let mut copy = Entity { handle: h, ..(*e).clone() }; + detach(&mut copy.kind); + rebase(&mut copy.kind, folder); + rename_refs(&mut copy.kind, renames); + paint(d, &mut copy, *color, tinted, 0); + d.model.push(copy); + members.push(h); + } + let name = free_name(name, d.groups.len(), |n| d.groups.iter().any(|g| g.name.eq_ignore_ascii_case(n))); + if !members.is_empty() { + d.groups.push(Group { name: name.clone(), description: String::new(), selectable: true, members }); + } + name +} + +/// Drop the links of an associative dimension or hatch to the objects it follows: the copies +/// have new handles, so the links would name objects the snapshot does not have. +fn detach(k: &mut EntityKind) { + match k { + EntityKind::Dimension(dm) => dm.assoc.clear(), + EntityKind::Hatch(h) => { + h.associative = false; + h.loops.iter_mut().for_each(|l| l.sources.clear()); + } + _ => {} + } +} + +/// The block names an object refers to. +fn block_refs(k: &EntityKind) -> Vec { + match k { + EntityKind::Insert(i) => vec![i.block.clone()], + EntityKind::Dimension(dm) => dm.block.iter().cloned().collect(), + EntityKind::MLeader(m) => m.block.iter().map(|b| b.block.clone()).collect(), + _ => Vec::new(), + } +} + +/// Call `f` on each block name an object refers to. +fn each_ref(k: &mut EntityKind, mut f: impl FnMut(&mut String)) { + match k { + EntityKind::Insert(i) => f(&mut i.block), + EntityKind::Dimension(dm) => { + if let Some(n) = dm.block.as_mut() { + f(n); + } + } + EntityKind::MLeader(m) => { + if let Some(b) = m.block.as_mut() { + f(&mut b.block); + } + } + _ => {} + } +} + +/// Point the block references of an object at the names its blocks were copied under. +fn rename_refs(k: &mut EntityKind, renames: &Renames) { + each_ref(k, |n| { + if let Some(new) = renames.get(&n.to_ascii_uppercase()) { + n.clone_from(new); + } + }); +} + +/// Recolored copies of blocks and styles made so far, and whether a block was left in its own +/// colors because the nesting or copy limit was reached. +#[derive(Default)] +struct Tinted { + /// (comparison tag, "block", "table style" or "dimension style", upper-case name) → the + /// copy's name. + names: HashMap<(&'static str, &'static str, String), String>, + uncolored: bool, + /// Objects copied into block definitions so far, counted against [`MAX_COPIED`]. + copied: usize, + next: NextFree, + /// The drawing's block names in upper case, filled on the first block copy. Only + /// [`tinted_block`] adds blocks after that, and it adds their names here too. + blocks: Option>, +} + +/// The next index to try for each name base. Names are only added while a snapshot is built, +/// so an index found taken stays taken, and each search starts where the last one for the same +/// base stopped: a name costs O(1) amortised instead of a scan from 1. +#[derive(Default)] +struct NextFree(HashMap<(&'static str, String), usize>); + +impl NextFree { + /// The first of `name(i)` for `i` from 1 that `taken` doesn't hold, skipping the indices + /// earlier searches for `base` in namespace `kind` passed. `n` is how many names there are, + /// so one of `name(1)`…`name(n + 1)` is free. The caller adds the returned name. + fn find(&mut self, kind: &'static str, base: &str, n: usize, name: impl Fn(usize) -> String, taken: impl Fn(&str) -> bool) -> String { + let key = (kind, base.to_ascii_uppercase()); + let start = self.0.get(&key).copied().unwrap_or(1); + let i = (start..=n.saturating_add(1)).find(|&i| !taken(&name(i))).unwrap_or_else(|| n.saturating_add(2).max(start)); + self.0.insert(key, i.saturating_add(1)); + name(i) + } +} + +/// `base` for 1, `base_2`, `base_3`… after it. +fn suffixed(base: &str, i: usize) -> String { + if i == 1 { base.to_string() } else { format!("{base}_{i}") } +} + +/// The word a comparison color adds to the names of its recolored copies. +fn tag(color: Color) -> &'static str { + if color == THIS { + "CURRENT" + } else if color == OTHER { + "COMPARED" + } else { + "COMMON" + } +} + +/// `base`, or `base_2`, `base_3`… when `taken` holds that name. `n` is how many names there are. +fn free_name(base: &str, n: usize, taken: impl Fn(&str) -> bool) -> String { + (1..=n + 1).map(|i| suffixed(base, i)).find(|c| !taken(c)).unwrap_or_else(|| suffixed(base, n + 2)) +} + +/// Give an object its comparison color: its own color, its attributes' colors, and, through +/// recolored copies of the blocks and styles it refers to, the color of everything they draw. +fn paint(d: &mut Drawing, e: &mut Entity, color: Color, tinted: &mut Tinted, depth: usize) { + e.common.color = color; + let attribs = match &mut e.kind { + EntityKind::Insert(i) => Some(&mut i.attribs), + EntityKind::MLeader(m) => m.block.as_mut().map(|b| &mut b.attribs), + _ => None, + }; + for a in attribs.into_iter().flatten() { + a.props.color = Some(color); + if let Some(ml) = a.multiline.as_mut() { + ml.mtext.contents = without_color_codes(&ml.mtext.contents); + } + } + // Colors an object carries apart from its own: inline MTEXT colors (in multiline attributes + // too), a dimension style's DIMCLRD, DIMCLRE and DIMCLRT, gradient colors, multiline element + // colors, the style colors of a table or a tolerance (through a recolored copy of the style) + // and inline cell colors. Hatch backgrounds, multiline fills and table cell fills are left out. + match &mut e.kind { + EntityKind::MText(t) => t.contents = without_color_codes(&t.contents), + EntityKind::AttDef(a) => { + if let Some(ml) = a.multiline.as_mut() { + ml.mtext.contents = without_color_codes(&ml.mtext.contents); + } + } + EntityKind::MLeader(m) => { + if let Some(t) = m.text.as_mut() { + t.contents = without_color_codes(&t.contents); + } + } + EntityKind::Dimension(dm) => { + if let Ok(by_block) = serde_json::to_value(Color::ByBlock) { + for var in ["DIMCLRD", "DIMCLRE", "DIMCLRT"] { + dm.overrides.retain(|k, _| DimStyle::field_name(k) != DimStyle::field_name(var)); + dm.overrides.insert(var.into(), by_block.clone()); + } + } + } + EntityKind::Tolerance(t) => { + if let Some(style) = tinted_dim_style(d, &t.style, color, tinted) { + t.style = style; + } + } + EntityKind::Hatch(h) => { + if let Some(g) = h.gradient.as_mut() { + (g.color1, g.color2) = (color, color); + } + h.background = None; + } + EntityKind::MLine(m) => { + for el in &mut m.def.elements { + el.color = Color::ByBlock; + } + m.def.fill = None; + } + EntityKind::Table(t) => { + for cell in t.cells.iter_mut().flatten() { + cell.text = without_color_codes(&cell.text); + } + if let Some(style) = tinted_table_style(d, &t.style, color, tinted) { + t.style = style; + } + } + _ => {} + } + if let EntityKind::Dimension(dm) = &mut e.kind { + // A dimension whose block can't be copied (past the limits) is drawn again from its + // geometry: its DIMCLR* overrides are ByBlock, so the original block would keep its colors. + dm.block = dm.block.take().and_then(|n| tinted_block(d, &n, color, tinted, depth)); + return; + } + each_ref(&mut e.kind, |n| { + if let Some(copy) = tinted_block(d, n, color, tinted, depth) { + *n = copy; + } + }); +} + +/// The name of a copy of dimension style `name` that draws lines and text in the object's +/// color, made on first use. `None` when the drawing has no such style. +fn tinted_dim_style(d: &mut Drawing, name: &str, color: Color, tinted: &mut Tinted) -> Option { + let key = (tag(color), "dimension style", name.to_ascii_uppercase()); + if let Some(n) = tinted.names.get(&key) { + return Some(n.clone()); + } + let st = d.dim_styles.iter().find(|s| s.name.eq_ignore_ascii_case(name))?.clone(); + let base = format!("COMPARE_{}_{}", tag(color), st.name); + let new = tinted.next.find( + "dimension style", + &base, + d.dim_styles.len(), + |i| suffixed(&base, i), + |n| d.dim_styles.iter().any(|s| s.name.eq_ignore_ascii_case(n)), + ); + d.dim_styles.push(DimStyle { + name: new.clone(), + dim_line_color: Color::ByBlock, + ext_line_color: Color::ByBlock, + text_color: Color::ByBlock, + ..st + }); + tinted.names.insert(key, new.clone()); + Some(new) +} + +/// The name of a copy of table style `name` whose cell styles draw text and borders in the +/// table's color and fill no cells, made on first use. `None` when the drawing has no such style. +fn tinted_table_style(d: &mut Drawing, name: &str, color: Color, tinted: &mut Tinted) -> Option { + let key = (tag(color), "table style", name.to_ascii_uppercase()); + if let Some(n) = tinted.names.get(&key) { + return Some(n.clone()); + } + let st = d.table_styles.iter().find(|s| s.name.eq_ignore_ascii_case(name))?.clone(); + let base = format!("COMPARE_{}_{}", tag(color), st.name); + let new = tinted.next.find( + "table style", + &base, + d.table_styles.len(), + |i| suffixed(&base, i), + |n| d.table_styles.iter().any(|s| s.name.eq_ignore_ascii_case(n)), + ); + let names = + [CellStyle::TITLE, CellStyle::HEADER, CellStyle::DATA].into_iter().map(str::to_string).chain(st.cell_styles.iter().map(|c| c.name.clone())); + let mut cell_styles: Vec = Vec::new(); + for n in names { + if cell_styles.iter().any(|c| c.name.eq_ignore_ascii_case(&n)) { + continue; + } + let mut cs = st.cell_style(&n); + cs.name = n; + cs.text_color = Color::ByBlock; + cs.fill = None; + let b = &mut cs.borders; + for border in [&mut b.top, &mut b.inside_h, &mut b.bottom, &mut b.left, &mut b.inside_v, &mut b.right] { + border.color = Color::ByBlock; + } + cell_styles.push(cs); + } + d.table_styles.push(TableStyle { name: new.clone(), cell_styles, ..st }); + tinted.names.insert(key, new.clone()); + Some(new) +} + +/// MTEXT contents without inline color codes (`\C1;`, `\c255;`), so the text takes the +/// object's color. An escaped backslash (`\\`) stays. +fn without_color_codes(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut it = s.chars().peekable(); + while let Some(c) = it.next() { + if c != '\\' { + out.push(c); + continue; + } + match it.peek().copied() { + Some('C' | 'c') => { + for d in it.by_ref() { + if d == ';' { + break; + } + } + } + Some(n) => { + out.push('\\'); + out.push(n); + it.next(); + } + None => out.push('\\'), + } + } + out +} + +/// The name of a copy of block `name` whose objects all have `color`, made on first use. `None` +/// (the reference keeps the original block) for an xref, a missing block, or past the nesting +/// and copy limits. +fn tinted_block(d: &mut Drawing, name: &str, color: Color, tinted: &mut Tinted, depth: usize) -> Option { + let key = (tag(color), "block", name.to_ascii_uppercase()); + if let Some(n) = tinted.names.get(&key) { + return Some(n.clone()); + } + let b = d.block(name)?.clone(); + if b.xref_path.is_some() { + return None; + } + if depth >= MAX_BLOCK_DEPTH || tinted.names.len() >= MAX_BLOCKS || tinted.copied.saturating_add(b.entities.len()) > MAX_COPIED { + tinted.uncolored = true; + return None; + } + tinted.copied += b.entities.len(); + // A dimension's block copy stays a `*D` block, so saving keeps its geometry instead of + // drawing the dimension again. + let dim_block = b.anonymous && b.name.get(..2).is_some_and(|p| p.eq_ignore_ascii_case("*D")); + let Tinted { next, blocks, .. } = tinted; + let taken = blocks.get_or_insert_with(|| d.blocks.keys().map(|k| k.to_ascii_uppercase()).collect()); + let is_taken = |n: &str| taken.contains(&n.to_ascii_uppercase()); + let new = if dim_block { + next.find("block", "*D", d.blocks.len(), |i| format!("*D{i}"), is_taken) + } else { + let base = format!("COMPARE_{}_{}", tag(color), b.name.trim_start_matches('*')); + next.find("block", &base, d.blocks.len(), |i| suffixed(&base, i), is_taken) + }; + taken.insert(new.to_ascii_uppercase()); + tinted.names.insert(key, new.clone()); + // Hold the name while the nested copies pick theirs. + d.blocks.insert(new.clone(), Arc::new(Block::new(&new))); + let mut copy = Block { name: new.clone(), anonymous: dim_block, entities: EntityStore::new(), ..(*b).clone() }; + for e in b.entities.iter() { + let h = d.new_handle(); + let mut e = Entity { handle: h, ..(**e).clone() }; + detach(&mut e.kind); + paint(d, &mut e, color, tinted, depth + 1); + copy.entities.push(e); + } + d.blocks.insert(new.clone(), Arc::new(copy)); + Some(new) +} + +/// The name each block copied from the other drawing has in the snapshot, by its upper-case +/// name there. +type Renames = HashMap; + +/// Copy into `d` the blocks of `other` that `objs` refer to, directly or through other blocks. +/// A named block `d` already has keeps `d`'s definition when it draws the same thing there (the +/// same objects, base point, units, flags and xref file, in it and in the blocks it refers to); +/// otherwise the other drawing's block is copied as `PART_2` (or `PART_3`…). An anonymous block +/// (`*D3`, `*U7`…) whose name `d` already uses is copied under the next free name with the same +/// prefix. Copied +/// objects get new handles, and relative file paths are rebased on `folder`. The flag is true +/// when more than [`MAX_BLOCKS`] blocks or [`MAX_COPIED`] objects were needed and the rest were +/// left out. +fn copy_blocks<'a>( + d: &mut Drawing, + other: &Drawing, + objs: impl Iterator, + folder: Option<&Path>, + tinted: &mut Tinted, +) -> (Renames, bool) { + let mut frontier: Vec = objs.flat_map(|e| block_refs(&e.kind)).collect(); + let mut seen = HashSet::new(); + let mut needed: Vec> = Vec::new(); + let mut left_out = false; + // `d`'s relative paths were made absolute from its own folder before the copy. + let (mut mine, mut theirs) = (super::Signer::with_frames(d, None), super::Signer::with_frames(other, folder)); + // Whether `d`'s block `n` draws what `other`'s block `b` draws. Past the signers' work limit + // the signatures no longer cover every object, so the blocks count as different. + let mut same = |n: &str, b: &Block| { + !b.anonymous + && d.block(n).is_some() + && mine.block(n, 0) == theirs.block(n, 0) + && mine.work < super::MAX_BLOCK_WORK + && theirs.work < super::MAX_BLOCK_WORK + }; + while let Some(n) = frontier.pop() { + let key = n.to_ascii_uppercase(); + if seen.contains(&key) { + continue; + } + if seen.len() >= MAX_BLOCKS { + left_out |= other.block(&n).is_some_and(|b| !same(&n, b)); + continue; + } + seen.insert(key); + let Some(b) = other.block(&n) else { continue }; + if same(&n, b) { + continue; + } + if tinted.copied.saturating_add(b.entities.len()) > MAX_COPIED { + left_out = true; + continue; + } + tinted.copied += b.entities.len(); + frontier.extend(b.entities.iter().flat_map(|e| block_refs(&e.kind))); + needed.push(b.clone()); + } + // Names `d` does not use stay; the others get free names, avoiding the kept ones. + let mut renames = Renames::new(); + let mut taken: HashSet = d.blocks.keys().map(|k| k.to_ascii_uppercase()).collect(); + let mut next = NextFree::default(); + for b in needed.iter().filter(|b| d.block(&b.name).is_none()) { + taken.insert(b.name.to_ascii_uppercase()); + renames.insert(b.name.to_ascii_uppercase(), b.name.clone()); + } + for b in needed.iter().filter(|b| d.block(&b.name).is_some()) { + let is_taken = |n: &str| taken.contains(&n.to_ascii_uppercase()); + let free = if b.anonymous { + let prefix: String = b.name.chars().take_while(|c| *c == '*' || c.is_ascii_alphabetic()).collect(); + next.find("anonymous block", &prefix, taken.len(), |i| format!("{prefix}{i}"), is_taken) + } else { + next.find("block", &b.name, taken.len(), |i| suffixed(&b.name, i), is_taken) + }; + taken.insert(free.to_ascii_uppercase()); + renames.insert(b.name.to_ascii_uppercase(), free); + } + for b in needed { + let Some(name) = renames.get(&b.name.to_ascii_uppercase()).cloned() else { continue }; + let mut copy = Block { name: name.clone(), entities: EntityStore::new(), ..(*b).clone() }; + for e in b.entities.iter() { + let h = d.new_handle(); + let mut e = Entity { handle: h, ..(**e).clone() }; + detach(&mut e.kind); + rename_refs(&mut e.kind, &renames); + copy.entities.push(e); + } + rebase_block(&mut copy, folder); + d.blocks.insert(name, Arc::new(copy)); + } + (renames, left_out) +} diff --git a/crates/engine/src/cmd/file/pgp.rs b/crates/engine/src/cmd/file/pgp.rs index e502ad80a..515feac7f 100644 --- a/crates/engine/src/cmd/file/pgp.rs +++ b/crates/engine/src/cmd/file/pgp.rs @@ -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; diff --git a/crates/engine/src/cmd/file/template.rs b/crates/engine/src/cmd/file/template.rs index f5b29e450..49ce172d0 100644 --- a/crates/engine/src/cmd/file/template.rs +++ b/crates/engine/src/cmd/file/template.rs @@ -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> { diff --git a/crates/engine/src/cmd/gripcmds.rs b/crates/engine/src/cmd/gripcmds.rs index cf637d9c8..a7ba2cdd6 100644 --- a/crates/engine/src/cmd/gripcmds.rs +++ b/crates/engine/src/cmd/gripcmds.rs @@ -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}; diff --git a/crates/engine/src/cmd/groups.rs b/crates/engine/src/cmd/groups.rs index 42addaef6..85b859f90 100644 --- a/crates/engine/src/cmd/groups.rs +++ b/crates/engine/src/cmd/groups.rs @@ -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; diff --git a/crates/engine/src/cmd/groups/dash.rs b/crates/engine/src/cmd/groups/dash.rs index 902121e33..b131f7ff6 100644 --- a/crates/engine/src/cmd/groups/dash.rs +++ b/crates/engine/src/cmd/groups/dash.rs @@ -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}; diff --git a/crates/engine/src/cmd/groups/pick.rs b/crates/engine/src/cmd/groups/pick.rs index c18ab96ee..7a9833876 100644 --- a/crates/engine/src/cmd/groups/pick.rs +++ b/crates/engine/src/cmd/groups/pick.rs @@ -7,7 +7,9 @@ use std::collections::{HashMap, HashSet}; use cadcraft_doc::Handle; use serde_json::{Value, json}; -use super::super::*; +use super::super::{CommandSpec, bad}; + +use super::super::always; use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step}; pub fn specs() -> Vec { diff --git a/crates/engine/src/cmd/hatch.rs b/crates/engine/src/cmd/hatch.rs index 8b33cc716..d0221a00a 100644 --- a/crates/engine/src/cmd/hatch.rs +++ b/crates/engine/src/cmd/hatch.rs @@ -11,11 +11,13 @@ use serde_json::{Value, json}; use super::helpers::lwpoly; use super::*; + use crate::{Accept, EngineError, Input, Interactive, Picker, Prompt, Result, Session, Step}; mod assoc; mod edit; mod pick; +use super::has_doc; pub(crate) use assoc::update as refill_associative; pub use pick::{HatchPicks, PickMode}; diff --git a/crates/engine/src/cmd/hatch/patfile.rs b/crates/engine/src/cmd/hatch/patfile.rs index 7ffe54724..7a4a727d0 100644 --- a/crates/engine/src/cmd/hatch/patfile.rs +++ b/crates/engine/src/cmd/hatch/patfile.rs @@ -13,7 +13,7 @@ use cadcraft_doc::library::{PatternLine, pattern, standard_patterns}; use serde_json::{Value, json}; use super::super::helpers::support_text; -use super::*; +use super::{Result, Session, bad, bool_or}; /// Most user patterns held at once. pub const MAX_USER_PATTERNS: usize = 4096; diff --git a/crates/engine/src/cmd/inquiry.rs b/crates/engine/src/cmd/inquiry.rs index 1f7f6f2ab..4d82d5c5c 100644 --- a/crates/engine/src/cmd/inquiry.rs +++ b/crates/engine/src/cmd/inquiry.rs @@ -6,7 +6,8 @@ use cadcraft_doc::{Entity, EntityKind, Handle}; use cadcraft_geom::{Polyline, Vec2, Vec3}; use serde_json::{Value, json}; -use super::*; +use super::{CommandSpec, bad, bool_or, f64_req, point_req, point_value, points_param, targets}; + use crate::units::{format_angle, format_distance}; use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step}; @@ -401,8 +402,17 @@ pub(super) fn area_of(pts: &[Vec2]) -> (f64, f64) { (a, per) } +/// `points` as given, `None` when it is absent; a `points` that is not a list of points is an +/// error, never a fall back to the selection. +fn given_points(p: &Value, cmd: &str) -> Result>> { + match p.get("points") { + None | Some(Value::Null) => Ok(None), + Some(_) => points_param(p, "points").map(Some).ok_or_else(|| bad(cmd, "`points` must be a list of [x, y] points")), + } +} + fn run_area(s: &mut Session, p: &Value) -> Result { - let (a, per) = if let Some(pts) = points_param(p, "points") { + let (a, per) = if let Some(pts) = given_points(p, "area")? { area_of(&pts) } else { let h = targets(s, p)?.first().copied().ok_or_else(|| bad("area", "`points` or `handle` is required"))?; @@ -415,6 +425,37 @@ fn area_text(s: &Session, a: f64, per: f64) -> String { format!("Area = {}, Perimeter = {}", fmt_d(s, a), fmt_d(s, per)) } +/// MEASUREGEOM's Volume option: the area of a polygon of three or more points, or of a 2D object +/// as AREA measures it (an open polyline as if closed), times the absolute value of a height. +pub(super) fn run_volume(s: &mut Session, p: &Value) -> Result { + let a = if let Some(pts) = given_points(p, "volume.measure")? { + if pts.len() < 3 { + return Err(bad("volume.measure", "`points` needs 3 or more [x,y] points")); + } + area_of(&pts).0 + } else { + let h = targets(s, p)?.first().copied().ok_or_else(|| bad("volume.measure", "`points` or `handle` is required"))?; + object_area(s, h)?.0 + }; + let height = f64_req("volume.measure", p, "height")?; + let v = a * height.abs(); + if !v.is_finite() { + return Err(bad("volume.measure", "the volume is too large")); + } + Ok(json!({ "volume": v, "area": a, "height": height, "message": volume_text(s, v) })) +} + +fn volume_text(s: &Session, v: f64) -> String { + format!("Volume = {}", fmt_volume(s, v)) +} + +/// Format a volume as a number in the drawing's `LUPREC`. Engineering and architectural units +/// show it as a decimal number, not in feet and inches. +fn fmt_volume(s: &Session, v: f64) -> String { + let (u, p) = s.doc().map(|d| (d.header.i64("LUNITS", 2), d.header.i64("LUPREC", 4))).unwrap_or((2, 4)); + format_distance(v, if matches!(u, 3 | 4) { 2 } else { u }, p) +} + /// Area and perimeter of a closed object (AREA's Object option). pub(super) fn object_area(s: &Session, h: Handle) -> Result<(f64, f64)> { let d = s.doc()?; @@ -889,9 +930,19 @@ pub(super) struct AreaM { object: bool, /// Asking for the length of a segment (the Length option). length: bool, + /// Measuring volumes (MEASUREGEOM's Volume option): each area is multiplied by the absolute value of a height. + volume: bool, + /// Areas measured in volume mode that still wait for their height, oldest first. + pending: Vec, + /// The first point of a height given by two points. + height_from: Option, } impl AreaM { + /// MEASUREGEOM's Volume option: AREA's prompts, then a height for each area. + pub(super) fn volume() -> Self { + AreaM { volume: true, ..AreaM::default() } + } fn mode_prefix(&self) -> &'static str { match self.mode { AreaMode::Single => "", @@ -899,8 +950,100 @@ impl AreaM { AreaMode::Subtract => "(SUBTRACT mode) ", } } + /// The Add and Subtract keywords: "Add area" and "Subtract area", or "Add volume" and + /// "Subtract volume". + fn mode_keywords(&self) -> (&'static str, &'static str) { + if self.volume { ("Add volume", "Subtract volume") } else { ("Add area", "Subtract area") } + } + /// The running total with `v` added or subtracted; in single mode, the total as it is. + fn total_with(&self, v: f64) -> f64 { + match self.mode { + AreaMode::Single => self.total, + AreaMode::Add => self.total + v, + AreaMode::Subtract => self.total - v, + } + } + /// Input while a height is pending: typed, or given by two points (their distance). + fn height_input(&mut self, s: &mut Session, i: Input) -> Step { + match (i, self.height_from) { + (Input::Point(p), None) => self.height_from = Some(p), + (Input::Point(p), Some(from)) => { + self.height_from = None; + return self.height(s, p.dist(from)); + } + (Input::Text(t), None) => match crate::units::parse_distance(&t).filter(|v| v.is_finite()) { + Some(h) => return self.height(s, h), + None => s.echo("Requires a distance."), + }, + (Input::Enter, None) => s.echo("Requires a height."), + (Input::Enter, Some(_)) => s.echo("Requires a second point."), + (Input::Text(_), Some(_)) => s.echo("Requires a point."), + _ => {} + } + Step::Continue + } + + /// Input while the objects to measure are picked. + fn object_input(&mut self, s: &mut Session, i: Input) -> Step { + match i { + Input::Pick(hs) => { + for h in hs { + match object_area(s, h) { + Ok((a, per)) => { + if self.measured(s, a, per) == Step::Done { + return Step::Done; + } + // A single volume takes the first object with an area. + if self.volume && self.mode == AreaMode::Single { + break; + } + } + Err(_) => s.echo("The selected object has no area."), + } + } + } + Input::Enter => { + self.object = false; + if self.mode == AreaMode::Single { + return Step::Done; + } + } + Input::Text(_) | Input::Keyword(_) => s.echo("*Invalid selection*"), + _ => {} + } + Step::Continue + } + + /// Measure the oldest pending area's volume with height `h`. + fn height(&mut self, s: &mut Session, h: f64) -> Step { + match self.pending.first().map(|a| a * h.abs()) { + Some(v) if v.is_finite() && self.total_with(v).is_finite() => { + self.pending.remove(0); + self.volume_measured(s, v) + } + _ => { + s.echo("The volume is too large."); + Step::Continue + } + } + } + /// Report a volume (and the running total in add/subtract mode); ends the command in single mode. + fn volume_measured(&mut self, s: &mut Session, v: f64) -> Step { + s.echo(volume_text(s, v)); + if self.mode == AreaMode::Single { + return Step::Done; + } + self.total = self.total_with(v); + s.echo(format!("Total volume = {}", fmt_volume(s, self.total))); + Step::Continue + } /// Report an area (and the running total in add/subtract mode); ends the command in single mode. + /// In volume mode the area waits for its height instead. fn measured(&mut self, s: &mut Session, a: f64, per: f64) -> Step { + if self.volume { + self.pending.push(a); + return Step::Continue; + } s.echo(area_text(s, a, per)); match self.mode { AreaMode::Single => return Step::Done, @@ -923,9 +1066,15 @@ impl AreaM { impl Interactive for AreaM { fn name(&self) -> &'static str { - "AREA" + if self.volume { "MEASUREGEOM" } else { "AREA" } } fn prompt(&self, _s: &Session) -> Prompt { + if !self.pending.is_empty() { + return match self.height_from { + Some(p) => Prompt::new("Specify second point", Accept::POINT).base(p), + None => Prompt::new(format!("{}Specify height", self.mode_prefix()), Accept::POINT_OR_NUMBER), + }; + } if self.object { return Prompt::new(format!("{}Select objects", self.mode_prefix()), Accept::SELECT); } @@ -933,41 +1082,21 @@ impl Interactive for AreaM { return length_prompt(); } let next = format!("{}Specify next point", self.mode_prefix()); + let (add, subtract) = self.mode_keywords(); match (self.pts.len(), self.mode) { - (0, AreaMode::Single) => { - Prompt::new("Specify first corner point", Accept::POINT).kw(&["Object", "Add area", "Subtract area"]).default("Object") - } - (0, AreaMode::Add) => Prompt::new("Specify first corner point", Accept::POINT).kw(&["Object", "Subtract area"]), - (0, AreaMode::Subtract) => Prompt::new("Specify first corner point", Accept::POINT).kw(&["Object", "Add area"]), + (0, AreaMode::Single) => Prompt::new("Specify first corner point", Accept::POINT).kw(&["Object", add, subtract]).default("Object"), + (0, AreaMode::Add) => Prompt::new("Specify first corner point", Accept::POINT).kw(&["Object", subtract]), + (0, AreaMode::Subtract) => Prompt::new("Specify first corner point", Accept::POINT).kw(&["Object", add]), (1, _) => Prompt::new(next, Accept::POINT).kw(&["Arc", "Length", "Undo"]).base_opt(self.pts.last().copied()), _ => Prompt::new(next, Accept::POINT).kw(&["Arc", "Length", "Undo", "Total"]).default("Total").base_opt(self.pts.last().copied()), } } fn input(&mut self, s: &mut Session, i: Input) -> Result { + if !self.pending.is_empty() { + return Ok(self.height_input(s, i)); + } if self.object { - match i { - Input::Pick(hs) => { - for h in hs { - match object_area(s, h) { - Ok((a, per)) => { - if self.measured(s, a, per) == Step::Done { - return Ok(Step::Done); - } - } - Err(_) => s.echo("The selected object has no area."), - } - } - } - Input::Enter => { - self.object = false; - if self.mode == AreaMode::Single { - return Ok(Step::Done); - } - } - Input::Text(_) | Input::Keyword(_) => s.echo("*Invalid selection*"), - _ => {} - } - return Ok(Step::Continue); + return Ok(self.object_input(s, i)); } if self.length { if let Some(p) = length_input(&self.pts, i)? { @@ -980,8 +1109,8 @@ impl Interactive for AreaM { Input::Point(p) => self.pts.push(p), Input::Keyword(k) => match k.as_str() { "Object" => self.object = true, - "Add area" => self.mode = AreaMode::Add, - "Subtract area" => self.mode = AreaMode::Subtract, + "Add area" | "Add volume" => self.mode = AreaMode::Add, + "Subtract area" | "Subtract volume" => self.mode = AreaMode::Subtract, "Undo" => { self.pts.pop(); } @@ -1011,6 +1140,8 @@ impl Interactive for AreaM { #[cfg(test)] mod tests { + use cadcraft_doc::Handle; + use super::*; /// #1440 review: a value one ulp past its bound (a border rectangle's shoelace area against diff --git a/crates/engine/src/cmd/inquiry/count.rs b/crates/engine/src/cmd/inquiry/count.rs index ea0d5f136..601dce4a1 100644 --- a/crates/engine/src/cmd/inquiry/count.rs +++ b/crates/engine/src/cmd/inquiry/count.rs @@ -9,7 +9,8 @@ use cadcraft_doc::{Drawing, Entity, EntityKind, Handle, Insert, MAX_BLOCK_DEPTH, use cadcraft_geom::{Mat3, Vec2}; use serde_json::{Value, json}; -use super::super::*; +use super::super::{bad, bool_or, point_req, str_param, targets}; + use super::SelfCheck; use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step}; diff --git a/crates/engine/src/cmd/inquiry/loops.rs b/crates/engine/src/cmd/inquiry/loops.rs index 2a452151f..0be936451 100644 --- a/crates/engine/src/cmd/inquiry/loops.rs +++ b/crates/engine/src/cmd/inquiry/loops.rs @@ -13,7 +13,8 @@ use cadcraft_doc::{EntityKind, Handle}; use cadcraft_geom::{Arc as GeoArc, EPS, Vec2}; use serde_json::{Value, json}; -use super::super::*; +use super::super::bad; + use crate::{Result, Session}; const CMD: &str = "loops"; @@ -116,21 +117,7 @@ pub fn run(s: &mut Session, p: &Value) -> Result { // Endpoints closer than the tolerance meet. Transitive components of three or more ends are // branch junctions; a component of exactly two is a simple joint. - let mut parent: Vec = (0..n).collect(); - for i in 0..n { - let Some(a) = ends.get(i) else { continue }; - for j in (i + 1)..n { - let Some(b) = ends.get(j) else { continue }; - if a.at.dist(b.at) <= tol { - let (ra, rb) = (find(&mut parent, i), find(&mut parent, j)); - if ra != rb - && let Some(slot) = parent.get_mut(ra) - { - *slot = rb; - } - } - } - } + let mut parent = merge_ends(&ends, tol); let mut groups: BTreeMap> = BTreeMap::new(); for i in 0..n { let root = find(&mut parent, i); @@ -139,54 +126,8 @@ pub fn run(s: &mut Session, p: &Value) -> Result { // Junctions are reported and their pieces left out of the chains: not resolved. let mut excluded = vec![false; pieces.len()]; - let mut junctions: Vec = Vec::new(); - let mut joints: Vec<(usize, usize)> = Vec::new(); - for group in groups.values() { - match group.as_slice() { - &[a, b] => joints.push((a, b)), - [_, _, ..] => { - let scale = group.len() as f64; - let mut at = Vec2::ZERO; - let mut members: Vec = Vec::new(); - for &e in group { - let Some(end) = ends.get(e) else { continue }; - at += end.at / scale; - let Some(piece) = pieces.get(end.piece) else { continue }; - if !members.contains(&piece.handle) { - members.push(piece.handle); - } - if let Some(slot) = excluded.get_mut(end.piece) { - *slot = true; - } - } - junctions.push(json!({ - "at": [at.x, at.y], - "ends": group.len(), - "members": members.iter().map(|h| h.hex()).collect::>(), - })); - } - _ => {} - } - } - - // A joint links the two ends for the walk. A joint into an excluded piece is not a link, and - // a line whose own ends meet within a huge tolerance is a spur, not a zero-area loop. - let mut link: Vec> = vec![None; n]; - for (a, b) in joints { - let (Some(ea), Some(eb)) = (ends.get(a), ends.get(b)) else { continue }; - if ea.piece == eb.piece && pieces.get(ea.piece).is_some_and(|p| p.arc.is_none()) { - continue; - } - if excluded.get(ea.piece).copied().unwrap_or(true) || excluded.get(eb.piece).copied().unwrap_or(true) { - continue; - } - if let Some(slot) = link.get_mut(a) { - *slot = Some(b); - } - if let Some(slot) = link.get_mut(b) { - *slot = Some(a); - } - } + let (joints, junctions) = sort_groups(&groups, &ends, &pieces, &mut excluded); + let link = joint_links(joints, &ends, &pieces, &excluded); let mut visited = vec![false; pieces.len()]; let mut loops: Vec = Vec::new(); @@ -224,6 +165,84 @@ pub fn run(s: &mut Session, p: &Value) -> Result { } /// A report with nothing analyzed: an empty drawing, or one past the pair cap. +/// Union-find over the ends: ends closer than `tol` share a root. Returns each end's parent. +fn merge_ends(ends: &[End], tol: f64) -> Vec { + let n = ends.len(); + let mut parent: Vec = (0..n).collect(); + for i in 0..n { + let Some(a) = ends.get(i) else { continue }; + for j in (i + 1)..n { + let Some(b) = ends.get(j) else { continue }; + if a.at.dist(b.at) <= tol { + let (ra, rb) = (find(&mut parent, i), find(&mut parent, j)); + if ra != rb + && let Some(slot) = parent.get_mut(ra) + { + *slot = rb; + } + } + } + } + parent +} + +/// Each group of two ends is a joint (end indices); each of three or more is a junction, reported +/// with its pieces marked `excluded`. +fn sort_groups(groups: &BTreeMap>, ends: &[End], pieces: &[Piece], excluded: &mut [bool]) -> (Vec<(usize, usize)>, Vec) { + let mut joints: Vec<(usize, usize)> = Vec::new(); + let mut junctions: Vec = Vec::new(); + for group in groups.values() { + match group.as_slice() { + &[a, b] => joints.push((a, b)), + [_, _, ..] => { + let scale = group.len() as f64; + let mut at = Vec2::ZERO; + let mut members: Vec = Vec::new(); + for &e in group { + let Some(end) = ends.get(e) else { continue }; + at += end.at / scale; + let Some(piece) = pieces.get(end.piece) else { continue }; + if !members.contains(&piece.handle) { + members.push(piece.handle); + } + if let Some(slot) = excluded.get_mut(end.piece) { + *slot = true; + } + } + junctions.push(json!({ + "at": [at.x, at.y], + "ends": group.len(), + "members": members.iter().map(|h| h.hex()).collect::>(), + })); + } + _ => {} + } + } + (joints, junctions) +} + +/// The link each end takes from the joints. A joint into an excluded piece is not a link, and +/// a line whose own ends meet within a huge tolerance is a spur, not a zero-area loop. +fn joint_links(joints: Vec<(usize, usize)>, ends: &[End], pieces: &[Piece], excluded: &[bool]) -> Vec> { + let mut link: Vec> = vec![None; ends.len()]; + for (a, b) in joints { + let (Some(ea), Some(eb)) = (ends.get(a), ends.get(b)) else { continue }; + if ea.piece == eb.piece && pieces.get(ea.piece).is_some_and(|p| p.arc.is_none()) { + continue; + } + if excluded.get(ea.piece).copied().unwrap_or(true) || excluded.get(eb.piece).copied().unwrap_or(true) { + continue; + } + if let Some(slot) = link.get_mut(a) { + *slot = Some(b); + } + if let Some(slot) = link.get_mut(b) { + *slot = Some(a); + } + } + link +} + fn empty_report(offset: usize, tol: f64, pair_tests: usize, pair_tests_needed: usize, pair_limit: usize, capped: bool, skipped: Skipped) -> Value { json!({ "schema": 1, diff --git a/crates/engine/src/cmd/inquiry/regions.rs b/crates/engine/src/cmd/inquiry/regions.rs index 751635730..9e6b11a49 100644 --- a/crates/engine/src/cmd/inquiry/regions.rs +++ b/crates/engine/src/cmd/inquiry/regions.rs @@ -11,7 +11,8 @@ use cadcraft_geom::{Line, Vec2, line_line, point_in_polygon}; use serde_json::{Value, json}; use super::super::machines::{SelOutcome, SelectPhase, SelectRun}; -use super::super::*; +use super::super::{CommandSpec, bad, targets}; + use super::SelfCheck; use crate::{EngineError, Input, Interactive, Prompt, Result, Session, Step}; diff --git a/crates/engine/src/cmd/inquiry/shapes.rs b/crates/engine/src/cmd/inquiry/shapes.rs index b48ffeb15..1591b67b0 100644 --- a/crates/engine/src/cmd/inquiry/shapes.rs +++ b/crates/engine/src/cmd/inquiry/shapes.rs @@ -6,7 +6,9 @@ use cadcraft_doc::{Entity, EntityKind}; use cadcraft_geom::{Bounds2, Polyline, Vec2, intersect_segments}; use serde_json::{Value, json}; -use super::super::*; +use super::super::bad; + +use super::super::point_value; use super::{SelfCheck, area_of, object_area}; use crate::{Result, Session}; diff --git a/crates/engine/src/cmd/inquiry/usage.rs b/crates/engine/src/cmd/inquiry/usage.rs index a2945aa4a..7bf6a2d17 100644 --- a/crates/engine/src/cmd/inquiry/usage.rs +++ b/crates/engine/src/cmd/inquiry/usage.rs @@ -8,8 +8,9 @@ use std::collections::{HashMap, HashSet}; use cadcraft_doc::{Drawing, Entity, EntityKind}; use serde_json::{Value, json}; +use super::super::bad; use super::super::blocks::purge; -use super::super::*; + use crate::{Result, Session}; const CMD: &str = "usage"; @@ -280,11 +281,9 @@ impl Unused { fn item_json( c: Category, name: &str, - entities: usize, - block_entities: usize, + (entities, block_entities): (usize, usize), references: usize, - used: bool, - empty: bool, + (used, empty): (bool, bool), current: Option, unused: &Unused, ) -> Value { @@ -314,13 +313,13 @@ fn items(scan: &Scan, unused: &Unused, d: &Drawing) -> Vec { let (e, b) = scan.layers.get(&l.name.to_ascii_lowercase()).copied().unwrap_or_default(); let uses = e.saturating_add(b); let cur = l.name.eq_ignore_ascii_case(¤t_layer); - out.push(item_json(Category::Layer, &l.name, e, b, 0, uses > 0, uses == 0, Some(cur), unused)); + out.push(item_json(Category::Layer, &l.name, (e, b), 0, (uses > 0, uses == 0), Some(cur), unused)); } for b in d.blocks.values() { let (e, n) = scan.blocks.get(&b.name.to_ascii_lowercase()).copied().unwrap_or_default(); // A definition is used when a reference to it is reachable, as PURGE counts it. let used = scan.used_blocks.contains(&b.name.to_ascii_uppercase()); - out.push(item_json(Category::Block, &b.name, e, n, 0, used, e.saturating_add(n) == 0, None, unused)); + out.push(item_json(Category::Block, &b.name, (e, n), 0, (used, e.saturating_add(n) == 0), None, unused)); } let current_style = d.header.str("TEXTSTYLE", "Standard"); for t in &d.text_styles { @@ -328,7 +327,7 @@ fn items(scan: &Scan, unused: &Unused, d: &Drawing) -> Vec { let r = scan.style_refs.get(&t.name.to_ascii_lowercase()).copied().unwrap_or(0); let uses = e.saturating_add(b).saturating_add(r); let cur = t.name.eq_ignore_ascii_case(¤t_style); - out.push(item_json(Category::TextStyle, &t.name, e, b, r, uses > 0, uses == 0, Some(cur), unused)); + out.push(item_json(Category::TextStyle, &t.name, (e, b), r, (uses > 0, uses == 0), Some(cur), unused)); } let current_linetype = d.header.str("CELTYPE", "ByLayer"); for t in &d.linetypes { @@ -336,7 +335,7 @@ fn items(scan: &Scan, unused: &Unused, d: &Drawing) -> Vec { let r = scan.linetype_refs.get(&t.name.to_ascii_lowercase()).copied().unwrap_or(0); let uses = e.saturating_add(b).saturating_add(r); let cur = t.name.eq_ignore_ascii_case(¤t_linetype); - out.push(item_json(Category::Linetype, &t.name, e, b, r, uses > 0, uses == 0, Some(cur), unused)); + out.push(item_json(Category::Linetype, &t.name, (e, b), r, (uses > 0, uses == 0), Some(cur), unused)); } out } diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index 4f0a58639..582a93635 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -7,7 +7,8 @@ use cadcraft_doc::{Layer, Lineweight}; use serde_json::{Value, json}; use super::machines::SelectRun; -use super::*; +use super::{CommandSpec, bad, bool_or, ok, str_param, targets}; + use crate::{Result, Session}; pub fn specs() -> Vec { diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index a6150ed89..924aed5ee 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -10,6 +10,7 @@ use serde_json::{Value, json}; use super::file::{base64_encode, io}; use super::*; + use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step}; use viewports::MviewM; diff --git a/crates/engine/src/cmd/layout/plotstyle.rs b/crates/engine/src/cmd/layout/plotstyle.rs index b92b15826..daf39ba8d 100644 --- a/crates/engine/src/cmd/layout/plotstyle.rs +++ b/crates/engine/src/cmd/layout/plotstyle.rs @@ -8,7 +8,8 @@ use cadcraft_doc::{Drawing, PlotStyle, PlotStyleKind, PlotStyleTable}; use serde_json::{Value, json}; -use super::*; +use super::super::targets; +use super::{CommandSpec, Result, Session, bad, str_param}; pub fn specs() -> Vec { vec![ diff --git a/crates/engine/src/cmd/layout/viewports.rs b/crates/engine/src/cmd/layout/viewports.rs index 7f47c4e31..e82d881fc 100644 --- a/crates/engine/src/cmd/layout/viewports.rs +++ b/crates/engine/src/cmd/layout/viewports.rs @@ -637,13 +637,13 @@ impl Interactive for MviewM { Stage::Arrange => Prompt::new("Enter viewport arrangement", curves::KW) .kw(&["Horizontal", "Vertical", "Above", "Below", "Left", "Right"]) .default("Right"), - Stage::Opposite(a) => Prompt::new("Specify opposite corner", Accept::POINT).base(*a), + Stage::Opposite(a) => Prompt::new("Specify opposite corner", Accept::POINT).corner(*a), Stage::Polygon(pi) => pi.prompt(), Stage::Object => Prompt::new("Select object to clip viewport", Accept::POINT), Stage::Lock => Prompt::new("Lock the view of viewports?", curves::KW).kw(&["ON", "OFF"]), Stage::RestoreName => Prompt::new("Enter viewport configuration name", Accept::TEXT).kw(&["?"]).default(VportConfig::ACTIVE), Stage::RestoreCorner(_) => Prompt::new("Specify first corner", Accept::POINT).kw(&["Fit"]).default("Fit"), - Stage::RestoreOpposite(_, a) => Prompt::new("Specify opposite corner", Accept::POINT).base(*a), + Stage::RestoreOpposite(_, a) => Prompt::new("Specify opposite corner", Accept::POINT).corner(*a), Stage::Select { .. } => Prompt::new("Select viewports", Accept::POINT), } } diff --git a/crates/engine/src/cmd/mleader_edit.rs b/crates/engine/src/cmd/mleader_edit.rs index 1416d71ab..e8e50bbf8 100644 --- a/crates/engine/src/cmd/mleader_edit.rs +++ b/crates/engine/src/cmd/mleader_edit.rs @@ -9,7 +9,8 @@ use serde_json::{Value, json}; use super::helpers::v3; use super::machines::{SelOutcome, SelectPhase}; -use super::*; +use super::{CommandSpec, bad, point_req, points_param, str_param, targets}; + use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step}; /// Most arrowheads added by one call, and most multileaders aligned / collected by one call. diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 74bc7f697..1856c6a15 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -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; diff --git a/crates/engine/src/cmd/modify.rs b/crates/engine/src/cmd/modify.rs index 059beb6a1..f2116e9d0 100644 --- a/crates/engine/src/cmd/modify.rs +++ b/crates/engine/src/cmd/modify.rs @@ -7,10 +7,12 @@ use cadcraft_geom::{ }; use serde_json::{Value, json}; -use super::helpers::*; +use super::helpers::{arc, circle, line, lwpoly, v3}; + use super::machines::{SelOutcome, SelectPhase, number}; use super::trimextend::{self, TrimM}; -use super::*; +use super::{CommandSpec, bad, bool_or, f64_or, f64_req, point_param, point_req, points_param, str_param, targets}; + use crate::select::{SelectKind, SelectMode}; use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step}; @@ -517,7 +519,7 @@ impl Interactive for StretchM { return m.prompt(); } match (self.phase, self.corner) { - (StretchPhase::Select, Some(c)) => Prompt::new("Specify opposite corner", Accept::POINT).base(c), + (StretchPhase::Select, Some(c)) => Prompt::new("Specify opposite corner", Accept::POINT).corner(c), (StretchPhase::Select, None) => Prompt::new(if self.removing { "Remove objects" } else { "Select objects" }, Accept::POINT), (StretchPhase::Base, _) => Prompt::new("Specify base point", Accept::POINT).kw(&["Displacement"]).default("Displacement"), (StretchPhase::Displacement, _) => Prompt::new("Specify displacement", Accept::POINT).default("0,0"), @@ -1294,6 +1296,128 @@ fn run_chamfer(s: &mut Session, p: &Value) -> Result { // ---------------- explode / array / order / break / join ---------------- +fn explode_block_ref(d: &cadcraft_doc::Drawing, e: &Entity, ins: &cadcraft_doc::Insert) -> Option> { + if let Some(items) = super::array::explode_array(d, e) { + return Some(items); + } + let blk = d.block(&ins.block)?; + let m = ins.transform(blk.base.xy()); + Some( + blk.entities + .iter() + .map(|be| { + let mut ne = (**be).clone(); + ne.kind.transform(&m); + if ne.common.layer == "0" { + ne.common.layer = e.common.layer.clone(); + } + if ne.common.color == cadcraft_color::Color::ByBlock { + ne.common.color = e.common.color; + } + ne + }) + .collect(), + ) +} + +fn explode_mtext(e: &Entity, m: &cadcraft_doc::MText) -> Vec { + cadcraft_fonts::plain_mtext(&m.contents) + .split('\n') + .enumerate() + .map(|(i, l)| { + let at = m.insert.xy() + Vec2::from_angle(m.rotation - std::f64::consts::FRAC_PI_2) * (m.height * (1.0 + 5.0 / 3.0 * i as f64)); + Entity { + handle: Handle(0), + common: e.common.clone(), + kind: EntityKind::Text(cadcraft_doc::Text { + insert: v3(at), + align_pt: None, + height: m.height, + value: l.into(), + rotation: m.rotation, + width_factor: 1.0, + oblique: 0.0, + style: m.style.clone(), + halign: Default::default(), + valign: Default::default(), + }), + } + }) + .collect() +} + +/// The entities of a boundary's lines and arcs, on the object's layer and style. +fn segment_entities(e: &Entity, segs: impl IntoIterator) -> Vec { + segs.into_iter() + .map(|s| { + let k = match s { + Segment::Line(l) => line(l.a, l.b), + Segment::Arc { arc: a, .. } => arc(&a), + }; + Entity { handle: Handle(0), common: e.common.clone(), kind: k } + }) + .collect() +} + +fn explode_dimension(d: &cadcraft_doc::Drawing, e: &Entity, dm: &cadcraft_doc::Dimension) -> Vec { + // The dimension as drawn: its overrides, DIMSCALE (0 = the drawing's) and text font. + let style = d.dim_style(&dm.style).cloned().unwrap_or_default().with_overrides(&dm.overrides); + let g = cadcraft_render::dimension_in(d, dm); + let line_entity = |l: &Vec| { + if l.len() == 2 { + Some(Entity { handle: Handle(0), common: e.common.clone(), kind: line(*l.first()?, *l.get(1)?) }) + } else { + Some(Entity { handle: Handle(0), common: e.common.clone(), kind: lwpoly(l.iter().map(|p| PolyVertex::new(*p)).collect(), false) }) + } + }; + // The text's marks (arc length symbol, DIMINSPECT frame) go after the text background. + let text_marks = &g.text_marks; + let marks = |want: bool| g.lines.iter().enumerate().filter(move |(i, _)| text_marks.contains(i) == want).map(|(_, l)| l); + let mut v: Vec = marks(false).filter_map(&line_entity).collect(); + for t in &g.fills { + if let [a, b, c] = t.as_slice() { + v.push(Entity { + handle: Handle(0), + common: e.common.clone(), + kind: EntityKind::Solid(cadcraft_doc::Solid { corners: [v3(*a), v3(*b), v3(*c), v3(*c)] }), + }); + } + } + // DIMTFILL 2: the text background as a SOLID in DIMTFILLCLR, over the lines and + // arrowheads, under the text and its marks (SOLID corners go 1, 2, 4, 3). + if style.text_fill == 2 + && let [p0, p1, p2, p3] = g.text_fill.as_slice() + { + let fill = style.text_fill_color; + let color = if fill == cadcraft_doc::color::Color::ByBlock { e.common.color } else { fill }; + v.push(Entity { + handle: Handle(0), + common: cadcraft_doc::Common { color, ..e.common.clone() }, + kind: EntityKind::Solid(cadcraft_doc::Solid { corners: [v3(*p0), v3(*p1), v3(*p3), v3(*p2)] }), + }); + } + v.extend(marks(true).filter_map(&line_entity)); + if !g.value.is_empty() { + v.push(Entity { + handle: Handle(0), + common: e.common.clone(), + kind: EntityKind::MText(cadcraft_doc::MText { + insert: v3(g.text_pos), + height: g.text_height, + width: 0.0, + attach: 5, + rotation: g.text_angle, + style: style.text_style.clone(), + contents: g.mtext_contents(), + line_spacing: 1.0, + line_spacing_exact: false, + columns: None, + }), + }); + } + v +} + pub(crate) fn explode_kind(d: &cadcraft_doc::Drawing, e: &Entity) -> Option> { // An annotative object explodes into plain objects as it draws at the current annotation scale. if cadcraft_doc::is_annotative(e) { @@ -1307,42 +1431,9 @@ pub(crate) fn explode_kind(d: &cadcraft_doc::Drawing, e: &Entity) -> Option { let pl = Polyline { vertices: p.vertices.clone(), closed: p.closed }; - Some( - pl.segments() - .into_iter() - .map(|s| { - let k = match s { - Segment::Line(l) => line(l.a, l.b), - Segment::Arc { arc: a, .. } => arc(&a), - }; - Entity { handle: Handle(0), common: e.common.clone(), kind: k } - }) - .collect(), - ) - } - EntityKind::Insert(ins) => { - if let Some(items) = super::array::explode_array(d, e) { - return Some(items); - } - let blk = d.block(&ins.block)?; - let m = ins.transform(blk.base.xy()); - Some( - blk.entities - .iter() - .map(|be| { - let mut ne = (**be).clone(); - ne.kind.transform(&m); - if ne.common.layer == "0" { - ne.common.layer = e.common.layer.clone(); - } - if ne.common.color == cadcraft_color::Color::ByBlock { - ne.common.color = e.common.color; - } - ne - }) - .collect(), - ) + Some(segment_entities(e, pl.segments())) } + EntityKind::Insert(ins) => explode_block_ref(d, e, ins), // A helix explodes into its spline (seen from above). EntityKind::Helix(h) => Some(vec![Entity { handle: Handle(0), common: e.common.clone(), kind: EntityKind::Spline(h.spline()) }]), EntityKind::Polyline3d(p) => Some( @@ -1355,92 +1446,10 @@ pub(crate) fn explode_kind(d: &cadcraft_doc::Drawing, e: &Entity) -> Option Some(super::draw2::mline::explode(e, m)), - EntityKind::MText(m) => Some( - cadcraft_fonts::plain_mtext(&m.contents) - .split('\n') - .enumerate() - .map(|(i, l)| { - let at = m.insert.xy() + Vec2::from_angle(m.rotation - std::f64::consts::FRAC_PI_2) * (m.height * (1.0 + 5.0 / 3.0 * i as f64)); - Entity { - handle: Handle(0), - common: e.common.clone(), - kind: EntityKind::Text(cadcraft_doc::Text { - insert: v3(at), - align_pt: None, - height: m.height, - value: l.into(), - rotation: m.rotation, - width_factor: 1.0, - oblique: 0.0, - style: m.style.clone(), - halign: Default::default(), - valign: Default::default(), - }), - } - }) - .collect(), - ), + EntityKind::MText(m) => Some(explode_mtext(e, m)), // A region explodes into its boundary's lines and arcs. - EntityKind::Region(r) => Some( - r.segments() - .into_iter() - .map(|s| { - let k = match s { - Segment::Line(l) => line(l.a, l.b), - Segment::Arc { arc: a, .. } => arc(&a), - }; - Entity { handle: Handle(0), common: e.common.clone(), kind: k } - }) - .collect(), - ), - EntityKind::Dimension(dm) => { - // The dimension as drawn: its overrides, DIMSCALE (0 = the drawing's) and text font. - let style = d.dim_style(&dm.style).cloned().unwrap_or_default().with_overrides(&dm.overrides); - let g = cadcraft_render::dimension_in(d, dm); - let mut v: Vec = g - .lines - .iter() - .filter_map(|l| { - if l.len() == 2 { - Some(Entity { handle: Handle(0), common: e.common.clone(), kind: line(*l.first()?, *l.get(1)?) }) - } else { - Some(Entity { - handle: Handle(0), - common: e.common.clone(), - kind: lwpoly(l.iter().map(|p| PolyVertex::new(*p)).collect(), false), - }) - } - }) - .collect(); - for t in &g.fills { - if let [a, b, c] = t.as_slice() { - v.push(Entity { - handle: Handle(0), - common: e.common.clone(), - kind: EntityKind::Solid(cadcraft_doc::Solid { corners: [v3(*a), v3(*b), v3(*c), v3(*c)] }), - }); - } - } - if !g.value.is_empty() { - v.push(Entity { - handle: Handle(0), - common: e.common.clone(), - kind: EntityKind::MText(cadcraft_doc::MText { - insert: v3(g.text_pos), - height: g.text_height, - width: 0.0, - attach: 5, - rotation: g.text_angle, - style: style.text_style.clone(), - contents: g.mtext_contents(), - line_spacing: 1.0, - line_spacing_exact: false, - columns: None, - }), - }); - } - Some(v) - } + EntityKind::Region(r) => Some(segment_entities(e, r.segments())), + EntityKind::Dimension(dm) => Some(explode_dimension(d, e, dm)), EntityKind::Tolerance(t) => Some(super::tolerance::explode(d, e, t)), _ => None, } diff --git a/crates/engine/src/cmd/modify2.rs b/crates/engine/src/cmd/modify2.rs index e6493e526..27327b13b 100644 --- a/crates/engine/src/cmd/modify2.rs +++ b/crates/engine/src/cmd/modify2.rs @@ -9,9 +9,11 @@ use cadcraft_geom::{ use serde_json::{Value, json}; use super::curves::{self, Chain, MAX_GEN, TanObj}; -use super::helpers::*; +use super::helpers::{arc, line, lwpoly, v3}; + use super::machines::{SelOutcome, SelectPhase, number}; -use super::*; +use super::{CommandSpec, bad, bool_or, f64_or, f64_req, point_param, point_req, str_param, targets}; + use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step}; mod curve_edits; diff --git a/crates/engine/src/cmd/modify2/fillet_pline.rs b/crates/engine/src/cmd/modify2/fillet_pline.rs index d6e7fa840..6ccbf8483 100644 --- a/crates/engine/src/cmd/modify2/fillet_pline.rs +++ b/crates/engine/src/cmd/modify2/fillet_pline.rs @@ -80,8 +80,7 @@ fn same_polyline( s: &mut Session, h: Handle, pl: &LwPolyline, - p1: Vec2, - p2: Vec2, + (p1, p2): (Vec2, Vec2), r: f64, chamfer: Option, trim: bool, @@ -223,7 +222,7 @@ pub(crate) fn fillet_pline( _ => None, }; let made = match (&e1.kind, &e2.kind) { - (EntityKind::LwPolyline(pl), _) if h1 == h2 => same_polyline(s, h1, pl, p1, p2, r, chamfer, trim)?, + (EntityKind::LwPolyline(pl), _) if h1 == h2 => same_polyline(s, h1, pl, (p1, p2), r, chamfer, trim)?, (EntityKind::LwPolyline(pl), k) if as_line(k).is_some() => { let ln = as_line(k).ok_or_else(|| other("Select a line."))?; polyline_and_line(s, (h1, pl, p1), (h2, ln, p2), true, r, chamfer, trim)? diff --git a/crates/engine/src/cmd/modify2/pedit_vertex.rs b/crates/engine/src/cmd/modify2/pedit_vertex.rs index be0d5d383..70ca06c2a 100644 --- a/crates/engine/src/cmd/modify2/pedit_vertex.rs +++ b/crates/engine/src/cmd/modify2/pedit_vertex.rs @@ -1,7 +1,10 @@ //! PEDIT Edit vertex: Break and Straighten between two marked vertices, Tangent directions //! that Fit honours, and Regen. -use super::*; +use super::super::point_value; +use super::{ + EntityKind, Handle, Input, LwPolyline, PPhase, PeditM, PolyVertex, Result, Session, Value, Vec2, bad, curves, json, other, push_entity, set_kind, +}; fn ordered(pl: &LwPolyline, i: usize, j: usize) -> Result<(usize, usize)> { let n = pl.vertices.len(); diff --git a/crates/engine/src/cmd/modify2/splinedit.rs b/crates/engine/src/cmd/modify2/splinedit.rs index 212433ea7..f5ba767fb 100644 --- a/crates/engine/src/cmd/modify2/splinedit.rs +++ b/crates/engine/src/cmd/modify2/splinedit.rs @@ -6,7 +6,11 @@ //! parametrisation, end tangents and fit tolerance. Control-point edits change the curve //! directly and drop the fit data, as the curve no longer follows it. -use super::*; +use super::super::{point_value, points_param}; +use super::{ + Accept, Entity, EntityKind, Handle, Input, Interactive, LwPolyline, MAX_GEN, PolyVertex, Prompt, Result, SelOutcome, SelectPhase, Session, Space, + Spline, Step, Value, Vec2, bad, bool_or, curves, f64_req, json, line, number, other, point_req, set_kind, str_param, targets, +}; /// The spline after a JSON SPLINEDIT option (`option` plus that option's parameters). pub(super) fn run_splinedit(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/cmd/overkill.rs b/crates/engine/src/cmd/overkill.rs index 92fc2ab7e..abfdff2eb 100644 --- a/crates/engine/src/cmd/overkill.rs +++ b/crates/engine/src/cmd/overkill.rs @@ -13,7 +13,8 @@ use cadcraft_geom::{TAU, Vec2, Vec3, ccw_sweep, norm_angle}; use serde_json::{Value, json}; use super::machines::{SelOutcome, SelectPhase, number}; -use super::*; +use super::{CommandSpec, bad, targets}; + use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step}; pub fn specs() -> Vec { diff --git a/crates/engine/src/cmd/plotters.rs b/crates/engine/src/cmd/plotters.rs index 454b977ae..370f03518 100644 --- a/crates/engine/src/cmd/plotters.rs +++ b/crates/engine/src/cmd/plotters.rs @@ -14,7 +14,8 @@ use cadcraft_doc::{DevicePaper, Orientation, PageSetup, PlotOutput, PlotterConfi use cadcraft_geom::{Bounds2, Vec2}; use serde_json::{Value, json}; -use super::*; +use super::{CommandSpec, Result, always, bad, has_doc, point_req, str_param}; + use crate::{Accept, EngineError, Input, Interactive, Prompt, Session, Step}; /// Where plotter configuration files live. Names are file names ending in @@ -362,10 +363,12 @@ 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), - Some(a) => Prompt::new("Specify opposite corner", Accept::POINT).base(a), + Some(a) => Prompt::new("Specify opposite corner", Accept::POINT).corner(a), } } fn input(&mut self, s: &mut Session, i: Input) -> Result { diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index 7ef3aa687..f9fbf2969 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -10,7 +10,8 @@ use cadcraft_doc::{EntityKind, Lineweight, Transparency}; use cadcraft_geom::Vec3; use serde_json::{Value, json}; -use super::*; +use super::{CommandSpec, always, bad, bool_or, f64_req, find_command, ok, point_req, point_value, str_param, targets}; + use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step}; pub fn specs() -> Vec { @@ -32,6 +33,11 @@ pub fn specs() -> Vec { CommandSpec::new("lweight", "Lineweight...", run_lweight).menu(&["Format", "Lineweight..."]).alias(&["lw", "lineweight"]).params("{lineweight: mm | ByLayer}"), CommandSpec::new("ltscale", "Linetype Scale", run_ltscale).alias(&["lts"]).params("{scale}").interactive(|_| Ok(Box::new(LtscaleM))), CommandSpec::new("units", "Units...", run_units).menu(&["Format", "Units..."]).alias(&["un"]).params("{lunits?: 1..5, luprec?: 0..8, aunits?: 0..4, auprec?: 0..8, insunits?}"), + CommandSpec::new("thickness", "Thickness", run_thickness) + .menu(&["Format", "Thickness"]) + .params("{thickness} (THICKNESS: the 3D thickness given to new lines, arcs, circles, points and polylines; may be negative) → {thickness}") + .transparent() + .interactive(|_| Ok(Box::new(ThicknessM))), CommandSpec::new("limits", "Drawing Limits", run_limits).menu(&["Format", "Drawing Limits"]).params("{min: [x,y], max: [x,y]}"), CommandSpec::new("style", "Text Style...", run_style) .menu(&["Format", "Text Style..."]) @@ -426,7 +432,11 @@ fn run_color(s: &mut Session, p: &Value) -> Result { 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, }?; @@ -434,6 +444,20 @@ pub(crate) fn set_current_color_value(s: &mut Session, v: &Value) -> Option<()> Some(()) } +/// `getvar CECOLOR` as AutoCAD gives it, always text: "BYLAYER", "BYBLOCK", the index ("1" for +/// red) or "RGB:r,g,b". +pub(crate) fn current_color_value(d: &cadcraft_doc::Drawing) -> String { + if let Some(rgb) = d.header.get("CECOLOR_RGB").and_then(|v| v.as_i64()).and_then(|v| u32::try_from(v).ok()) { + let cadcraft_color::Rgb(r, g, b) = cadcraft_color::Rgb::from_u32(rgb); + return format!("RGB:{r},{g},{b}"); + } + match Color::from_aci(d.header.i64("CECOLOR", 256) as i16) { + Color::ByLayer => "BYLAYER".into(), + Color::ByBlock => "BYBLOCK".into(), + other => other.to_aci().to_string(), + } +} + /// A true colour keeps its RGB in CECOLOR_RGB (which wins for new objects); any other colour must /// clear a stale one. fn set_current_color(d: &mut cadcraft_doc::Drawing, c: Color) { @@ -534,6 +558,56 @@ impl Interactive for LtscaleM { } } +/// The largest THICKNESS, either way. +const MAX_THICKNESS: f64 = 1e12; + +fn thickness_in_range(t: f64) -> bool { + t.is_finite() && t.abs() <= MAX_THICKNESS +} + +fn run_thickness(s: &mut Session, p: &Value) -> Result { + let t = f64_req("thickness", p, "thickness")?; + if !thickness_in_range(t) { + return Err(bad("thickness", "thickness is out of range (-1e12 to 1e12)")); + } + s.doc_mut()?.header.set_f64("THICKNESS", t); + Ok(json!({ "thickness": t })) +} + +/// Format > Thickness and typed THICKNESS: "Enter new value for THICKNESS <0.0000>:". +struct ThicknessM; + +impl Interactive for ThicknessM { + fn name(&self) -> &'static str { + "THICKNESS" + } + fn prompt(&self, s: &Session) -> Prompt { + let cur = s.doc().map(|d| d.header.f64("THICKNESS", 0.0)).unwrap_or(0.0); + Prompt::new("Enter new value for THICKNESS", Accept::NUMBER).default(format!("{cur:.4}")) + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + let text = match i { + Input::Enter | Input::Cancel => return Ok(Step::Done), + Input::Text(t) => t, + _ => return Ok(Step::Continue), + }; + match super::machines::number(&text) { + Some(t) if thickness_in_range(t) => { + run_thickness(s, &json!({ "thickness": t }))?; + Ok(Step::Done) + } + Some(_) => { + s.echo("Requires a value between -1e12 and 1e12."); + Ok(Step::Continue) + } + None => { + s.echo("Requires a number."); + Ok(Step::Continue) + } + } + } +} + fn run_units(s: &mut Session, p: &Value) -> Result { let d = s.doc_mut()?; for (k, lo, hi) in [("lunits", 1, 5), ("luprec", 0, 8), ("aunits", 0, 4), ("auprec", 0, 8), ("insunits", 0, 24)] { @@ -1104,10 +1178,9 @@ fn run_rename(s: &mut Session, p: &Value) -> Result { #[cfg(test)] mod tests { + use super::*; use serde_json::json; - use crate::Session; - #[test] fn properties_set_rejects_unloaded_linetype() { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/props/linfile.rs b/crates/engine/src/cmd/props/linfile.rs index 2290051c3..b8fd023f2 100644 --- a/crates/engine/src/cmd/props/linfile.rs +++ b/crates/engine/src/cmd/props/linfile.rs @@ -7,7 +7,8 @@ use serde_json::{Value, json}; use super::super::helpers::support_text; use super::super::qselect::wildcard; -use super::*; +use super::{Result, Session, bad, bool_or, str_param}; + use crate::{Accept, Input, Interactive, Prompt, Step}; /// Names no file may define: they are not linetype definitions. diff --git a/crates/engine/src/cmd/props/matchprop.rs b/crates/engine/src/cmd/props/matchprop.rs index a53f6a5ef..31f4e4951 100644 --- a/crates/engine/src/cmd/props/matchprop.rs +++ b/crates/engine/src/cmd/props/matchprop.rs @@ -6,7 +6,8 @@ use cadcraft_doc::{Entity, EntityKind, Handle}; use serde_json::{Value, json}; -use super::super::*; +use super::super::{bad, targets}; + use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step}; /// Which properties MATCHPROP copies (its Settings). diff --git a/crates/engine/src/cmd/qselect.rs b/crates/engine/src/cmd/qselect.rs index 808952e7a..2c5330371 100644 --- a/crates/engine/src/cmd/qselect.rs +++ b/crates/engine/src/cmd/qselect.rs @@ -5,7 +5,8 @@ use cadcraft_color::Color; use cadcraft_doc::{Drawing, Entity, Handle}; use serde_json::{Value, json}; -use super::*; +use super::{CommandSpec, bad, str_param}; + use crate::{Result, Session}; pub fn specs() -> Vec { @@ -320,9 +321,8 @@ fn run_info(s: &mut Session, p: &Value) -> Result { #[cfg(test)] mod tests { - use serde_json::json; - use super::*; + use serde_json::json; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/selecttools.rs b/crates/engine/src/cmd/selecttools.rs index eef98ffdd..1f742bb11 100644 --- a/crates/engine/src/cmd/selecttools.rs +++ b/crates/engine/src/cmd/selecttools.rs @@ -9,7 +9,8 @@ use cadcraft_doc::{Drawing, Entity, EntityKind, Handle}; use serde_json::{Value, json}; use super::machines::{SelOutcome, SelectPhase, SelectRun}; -use super::*; +use super::{CommandSpec, bad, targets}; + use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step}; /// Most objects one call looks at (a hostile drawing can be huge). diff --git a/crates/engine/src/cmd/settings.rs b/crates/engine/src/cmd/settings.rs index 014e130eb..1cd6cbfaa 100644 --- a/crates/engine/src/cmd/settings.rs +++ b/crates/engine/src/cmd/settings.rs @@ -2,7 +2,8 @@ use serde_json::{Value, json}; -use super::*; +use super::{CommandSpec, always, bad, point_param, str_param}; + use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step}; pub(super) mod iso; diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index be156fb1d..8f7e8f1dd 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -12,7 +12,8 @@ use cadcraft_geom::Vec2; use serde_json::{Value, json}; use super::helpers::v3; -use super::*; +use super::{CommandSpec, bad, point_req, size_param, str_param}; + use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step}; /// Caps for hostile parameters. @@ -141,15 +142,28 @@ fn run_table(s: &mut Session, p: &Value) -> Result { 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 { - 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 { + 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 { + 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")), diff --git a/crates/engine/src/cmd/table/cells.rs b/crates/engine/src/cmd/table/cells.rs index a2efca4d6..d020758d8 100644 --- a/crates/engine/src/cmd/table/cells.rs +++ b/crates/engine/src/cmd/table/cells.rs @@ -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 { - 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 { let id = "tableexport"; let h = table_handle(s, p, id)?; diff --git a/crates/engine/src/cmd/tolerance.rs b/crates/engine/src/cmd/tolerance.rs index 1a33a5670..b4edfe0f9 100644 --- a/crates/engine/src/cmd/tolerance.rs +++ b/crates/engine/src/cmd/tolerance.rs @@ -5,7 +5,8 @@ use cadcraft_geom::{PolyVertex, Vec2}; use serde_json::{Value, json}; use super::helpers::{line, lwpoly, v3}; -use super::*; +use super::{CommandSpec, bad, bool_or, f64_or, point_req, str_param}; + use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step}; pub fn specs() -> Vec { diff --git a/crates/engine/src/cmd/utility.rs b/crates/engine/src/cmd/utility.rs index 89c00f7a6..97678ae24 100644 --- a/crates/engine/src/cmd/utility.rs +++ b/crates/engine/src/cmd/utility.rs @@ -1,10 +1,11 @@ //! Utilities: CAL (expression calculator), FIND (find and replace text), MASSPROP, MEASUREGEOM. use cadcraft_doc::{EntityKind, Handle}; -use cadcraft_geom::Vec2; +use cadcraft_geom::{Segment, Vec2}; use serde_json::{Value, json}; -use super::*; +use super::{CommandSpec, always, bad, bool_or, point_param, points_param, str_param, targets}; + use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step}; pub fn specs() -> Vec { @@ -27,11 +28,24 @@ pub fn specs() -> Vec { .interactive(|_| Ok(Box::::default())), CommandSpec::new("find", "Find...", run_find).menu(&["Edit", "Find..."]).params("{find, replace?, matchCase?: bool, wholeWord?: bool}"), CommandSpec::new("measuregeom", "Measure Geometry", run_measuregeom) - .params("{mode: distance|radius|angle|area, ...}") + .params("{mode: distance|radius|angle|area|volume, ...}") .noundo() .interactive(|_| Ok(Box::::default())), - CommandSpec::new("radius.measure", "Radius", run_radius).menu(&["Tools", "Inquiry", "Radius"]).params("{handle}").noundo(), - CommandSpec::new("angle.measure", "Angle", run_angle).menu(&["Tools", "Inquiry", "Angle"]).params("{vertex, p1, p2} | {h1, h2}").noundo(), + CommandSpec::new("radius.measure", "Radius", run_radius) + .menu(&["Tools", "Inquiry", "Radius"]) + .params("{handle, point?}") + .noundo() + .interactive(|_| Ok(Box::new(RadiusM))), + CommandSpec::new("angle.measure", "Angle", run_angle) + .menu(&["Tools", "Inquiry", "Angle"]) + .params("{vertex, p1, p2} | {h1, h2}") + .noundo() + .interactive(|_| Ok(Box::::default())), + CommandSpec::new("volume.measure", "Volume", super::inquiry::run_volume) + .menu(&["Tools", "Inquiry", "Volume"]) + .params("{points: [[x,y],...] (3 or more) | handle (a closed 2D object), height} → {volume, area, height}") + .noundo() + .interactive(|_| Ok(Box::new(super::inquiry::AreaM::volume()))), ] } @@ -283,11 +297,70 @@ fn run_radius(s: &mut Session, p: &Value) -> Result { let r = match s.doc()?.entity(h).map(|e| e.kind.clone()) { Some(EntityKind::Circle(c)) => c.radius, Some(EntityKind::Arc(a)) => a.radius, - _ => return Err(bad("radius.measure", "select an arc or circle")), + Some(EntityKind::LwPolyline(_)) => { + let at = point_param(p, "point").ok_or_else(|| bad("radius.measure", "`point` on the polyline's arc segment is required"))?; + match super::annotate::segment_at(s, h, at) { + Some(Segment::Arc { arc, .. }) => arc.radius, + _ => return Err(bad("radius.measure", "the polyline segment at `point` is not an arc")), + } + } + _ => return Err(bad("radius.measure", "select an arc, circle or polyline arc")), }; Ok(json!({ "radius": r, "diameter": 2.0 * r, "message": format!("Radius = {r:.4}\nDiameter = {:.4}", 2.0 * r) })) } +/// MEASUREGEOM's Radius option and Tools > Inquiry > Radius: the radius and diameter of a picked +/// arc, circle or polyline arc segment. Tools > Inquiry > Radius measures a selected arc or circle +/// at once. +struct RadiusM; + +impl RadiusM { + /// Measure the arc, circle or polyline arc segment at the picked point; a pick on anything else keeps the prompt. + fn pick(s: &mut Session, i: Input) -> Result { + let Input::Point(p) = i else { return Ok(Step::Done) }; + let Some(h) = super::curves::pick_at(s, p) else { return Ok(Step::Continue) }; + match run_radius(s, &json!({ "handle": h.0, "point": [p.x, p.y] })) { + Ok(r) => { + echo_message(s, &r); + Ok(Step::Done) + } + Err(_) => { + s.echo("Select an arc, circle or polyline arc."); + Ok(Step::Continue) + } + } + } +} + +impl Interactive for RadiusM { + fn name(&self) -> &'static str { + "MEASUREGEOM" + } + fn prompt(&self, _s: &Session) -> Prompt { + Prompt::new("Select arc or circle", Accept::POINT) + } + fn begin(&mut self, s: &mut Session) -> Result { + let Some(h) = s.selection().first().copied() else { return Ok(Step::Continue) }; + match run_radius(s, &json!({ "handle": h.0 })) { + Ok(r) => { + echo_message(s, &r); + Ok(Step::Done) + } + Err(_) => Ok(Step::Continue), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + Self::pick(s, i) + } +} + +/// Echo each line of a result's `message`. +fn echo_message(s: &mut Session, r: &Value) { + for l in r["message"].as_str().unwrap_or_default().lines() { + s.echo(l.to_string()); + } +} + fn run_angle(s: &mut Session, p: &Value) -> Result { let (v, a, b) = if let (Some(v), Some(a), Some(b)) = (point_param(p, "vertex"), point_param(p, "p1"), point_param(p, "p2")) { (v, a, b) @@ -299,17 +372,29 @@ fn run_angle(s: &mut Session, p: &Value) -> Result { Some(EntityKind::Line(l)) => Some((l.a.xy(), l.b.xy())), _ => None, }; - let ((a1, a2), (b1, b2)) = + let (l1, l2) = (line(h1).ok_or_else(|| bad("angle.measure", "h1 is not a line"))?, line(h2).ok_or_else(|| bad("angle.measure", "h2 is not a line"))?); - let (x, _, _) = cadcraft_geom::line_line_infinite(a1, a2, b1, b2).ok_or_else(|| bad("angle.measure", "lines are parallel"))?; - let far = |p: Vec2, q: Vec2| if p.dist(x) > q.dist(x) { p } else { q }; - (x, far(a1, a2), far(b1, b2)) + lines_vertex(l1, l2).ok_or_else(|| bad("angle.measure", "lines are parallel"))? }; - let mut ang = (cadcraft_geom::norm_angle(v.angle_to(b) - v.angle_to(a))).to_degrees(); - if ang > 180.0 { - ang = 360.0 - ang; - } - Ok(json!({ "angle": ang, "message": format!("Angle = {ang:.4}°") })) + let ang = vertex_angle(v, a, b); + Ok(json!({ "angle": ang, "message": angle_text(ang) })) +} + +/// The point where two lines (extended) meet and the end of each line farther from it; `None` when they are parallel. +fn lines_vertex((a1, a2): (Vec2, Vec2), (b1, b2): (Vec2, Vec2)) -> Option<(Vec2, Vec2, Vec2)> { + let (x, _, _) = cadcraft_geom::line_line_infinite(a1, a2, b1, b2)?; + let far = |p: Vec2, q: Vec2| if p.dist(x) > q.dist(x) { p } else { q }; + Some((x, far(a1, a2), far(b1, b2))) +} + +/// The angle at `v` between the directions to `a` and `b`, in degrees from 0 to 180. +fn vertex_angle(v: Vec2, a: Vec2, b: Vec2) -> f64 { + let ang = (cadcraft_geom::norm_angle(v.angle_to(b) - v.angle_to(a))).to_degrees(); + if ang > 180.0 { 360.0 - ang } else { ang } +} + +fn angle_text(ang: f64) -> String { + format!("Angle = {ang:.4}°") } fn run_measuregeom(s: &mut Session, p: &Value) -> Result { @@ -318,11 +403,12 @@ fn run_measuregeom(s: &mut Session, p: &Value) -> Result { "radius" | "r" => run_radius(s, p), "angle" | "a" => run_angle(s, p), "area" | "ar" => s.execute("area", p), - _ => Err(bad("measuregeom", "mode must be distance, radius, angle or area")), + "volume" | "v" => super::inquiry::run_volume(s, p), + _ => Err(bad("measuregeom", "mode must be distance, radius, angle, area or volume")), } } -/// MEASUREGEOM typed or picked: choose what to measure, then continue as DIST, AREA or a radius pick. +/// MEASUREGEOM typed or picked: choose what to measure, then continue as DIST, AREA, a radius pick or an angle pick. #[derive(Default)] struct MeasureM { inner: Option>, @@ -339,7 +425,7 @@ impl Interactive for MeasureM { Some(m) => m.prompt(s), None if self.radius => Prompt::new("Select arc or circle", Accept::POINT), None => Prompt::new("Enter an option", Accept { point: false, number: false, text: false, select: false, enter: true }) - .kw(&["Distance", "Radius", "ARea"]) + .kw(&["Distance", "Radius", "Angle", "ARea", "Volume"]) .default("Distance"), } } @@ -348,24 +434,15 @@ impl Interactive for MeasureM { return m.input(s, i); } if self.radius { - let Input::Point(p) = i else { return Ok(Step::Done) }; - let Some(h) = super::curves::pick_at(s, p) else { return Ok(Step::Continue) }; - match run_radius(s, &json!({ "handle": h.0 })) { - Ok(r) => { - for l in r["message"].as_str().unwrap_or_default().lines() { - s.echo(l.to_string()); - } - return Ok(Step::Done); - } - Err(_) => s.echo("Select an arc or circle."), - } - return Ok(Step::Continue); + return RadiusM::pick(s, i); } match i { Input::Enter => self.inner = Some(Box::new(super::inquiry::DistM::default())), Input::Keyword(k) => match k.as_str() { "Radius" => self.radius = true, + "Angle" => self.inner = Some(Box::::default()), "ARea" => self.inner = Some(Box::new(super::inquiry::AreaM::default())), + "Volume" => self.inner = Some(Box::new(super::inquiry::AreaM::volume())), _ => self.inner = Some(Box::new(super::inquiry::DistM::default())), }, _ => {} @@ -377,6 +454,103 @@ impl Interactive for MeasureM { } } +/// MEASUREGEOM's Angle option and Tools > Inquiry > Angle: the angle of an arc, of two points on a +/// circle about its center, between two lines or polyline line segments, or (Enter) at a vertex +/// between two endpoints. +#[derive(Default)] +struct AngleM { + /// The first line picked. + first: Option<(Vec2, Vec2)>, + /// The vertex and endpoints given so far (Enter, or a circle's center and the point picked on it). + vertex: Option>, +} + +impl AngleM { + fn finish(s: &mut Session, ang: f64) -> Result { + s.echo(angle_text(ang)); + Ok(Step::Done) + } +} + +impl Interactive for AngleM { + fn name(&self) -> &'static str { + "MEASUREGEOM" + } + fn prompt(&self, _s: &Session) -> Prompt { + if let Some(pts) = &self.vertex { + let msg = ["Specify angle vertex", "Specify first angle endpoint", "Specify second angle endpoint"].get(pts.len()).copied().unwrap_or(""); + return Prompt::new(msg, Accept::POINT).base_opt(pts.first().copied()); + } + match self.first { + None => Prompt::new("Select arc, circle, line", Accept::POINT).default("specify vertex"), + Some(_) => Prompt::new("Select second line", Accept::POINT), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + if let Some(pts) = &mut self.vertex { + return match i { + Input::Point(p) => { + pts.push(p); + match pts.as_slice() { + [v, a, b] => Self::finish(s, vertex_angle(*v, *a, *b)), + _ => Ok(Step::Continue), + } + } + Input::Enter => Ok(Step::Cancel), + _ => Ok(Step::Continue), + }; + } + let p = match i { + Input::Enter if self.first.is_none() => { + self.vertex = Some(Vec::new()); + return Ok(Step::Continue); + } + Input::Point(p) => p, + Input::Enter => return Ok(Step::Cancel), + _ => return Ok(Step::Continue), + }; + let Some(h) = super::curves::pick_at(s, p) else { + s.echo("*Invalid selection*"); + return Ok(Step::Continue); + }; + let circle = s + .doc()? + .entity(h) + .and_then(|e| if let EntityKind::Circle(c) = &e.kind { Some(cadcraft_geom::Circle::new(c.center.xy(), c.radius)) } else { None }); + if let Some(c) = circle.filter(|_| self.first.is_none()) { + self.vertex = Some(vec![c.center, c.closest(p)]); + return Ok(Step::Continue); + } + match super::annotate::segment_at(s, h, p) { + Some(Segment::Arc { arc, .. }) if self.first.is_none() => Self::finish(s, arc.sweep().to_degrees()), + Some(Segment::Line(l)) => match self.first { + None => { + self.first = Some((l.a, l.b)); + Ok(Step::Continue) + } + Some(first) => match lines_vertex(first, (l.a, l.b)) { + Some((v, a, b)) => Self::finish(s, vertex_angle(v, a, b)), + None => { + s.echo("The lines are parallel."); + Ok(Step::Done) + } + }, + }, + _ => { + s.echo(if self.first.is_none() { "Select an arc, circle or line." } else { "Select a line." }); + Ok(Step::Continue) + } + } + } + fn preview(&self, _s: &Session, c: Vec2) -> Vec { + match self.vertex.as_deref() { + Some([v]) => vec![super::helpers::line(*v, c)], + Some([v, a]) => vec![super::helpers::line(*v, *a), super::helpers::line(*v, c)], + _ => Vec::new(), + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index e6051a6e9..4586cd988 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -6,7 +6,8 @@ use cadcraft_geom::{Bounds2, Vec2}; use serde_json::{Value, json}; use super::machines::{SelectRun, number}; -use super::*; +use super::{CommandSpec, bad, bool_or, f64_or, f64_req, ok, point_param, point_req, str_param, targets}; + use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step, View}; mod named_views; diff --git a/crates/engine/src/cmd/view/named_views.rs b/crates/engine/src/cmd/view/named_views.rs index 40c25632b..f88d240db 100644 --- a/crates/engine/src/cmd/view/named_views.rs +++ b/crates/engine/src/cmd/view/named_views.rs @@ -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. @@ -279,7 +280,7 @@ impl Interactive for ViewM { Stage::Restore => Prompt::new("Enter view name to restore", Accept::TEXT), Stage::Save | Stage::WindowName => Prompt::new("Enter view name to save", Accept::TEXT), Stage::WindowFirst => Prompt::new("Specify first corner", Accept::POINT), - Stage::WindowSecond(a) => Prompt::new("Specify opposite corner", Accept::POINT).base(a), + Stage::WindowSecond(a) => Prompt::new("Specify opposite corner", Accept::POINT).corner(a), Stage::Settings => Prompt::new("Enter an option", Accept::TEXT).kw(&["Layer snapshot"]), Stage::Snapshot => { let on = if s.settings.view_layer_snapshot { "Yes" } else { "No" }; diff --git a/crates/engine/src/cmd/view/vports.rs b/crates/engine/src/cmd/view/vports.rs index 88ee0a6ed..5b95f720a 100644 --- a/crates/engine/src/cmd/view/vports.rs +++ b/crates/engine/src/cmd/view/vports.rs @@ -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. diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 117db5813..c1eb5a6cc 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -647,6 +647,9 @@ impl Default for MLeaderOptions { pub struct Session { pub docs: Vec, 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, /// 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) -> 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())) } } @@ -885,6 +899,19 @@ impl Session { if d.annotative_by_style(&kind) { common.anno_scales = vec![d.current_anno_scale()]; } + // Lines, arcs, circles, points, polylines and 2D solids take the current 3D thickness + // (THICKNESS). RECTANG sets its own, and text never takes it. + if matches!( + kind, + EntityKind::Line(_) + | EntityKind::Arc(_) + | EntityKind::Circle(_) + | EntityKind::Point(_) + | EntityKind::LwPolyline(_) + | EntityKind::Solid(_) + ) { + common.thickness = Some(d.header.f64("THICKNESS", 0.0)).filter(|t| t.is_finite()).unwrap_or(0.0); + } let h = self.doc_mut()?.add(&space, common, kind)?; Ok(h) } diff --git a/crates/engine/src/lisp/builtins.rs b/crates/engine/src/lisp/builtins.rs index 0fcb22f03..b3829a6a4 100644 --- a/crates/engine/src/lisp/builtins.rs +++ b/crates/engine/src/lisp/builtins.rs @@ -785,6 +785,48 @@ fn glob_cached( Ok(matched) } +/// For the bracket class opening at `pat[p]`, whether `text[t]` is in it: the index of its closing +/// `]` when it is, `None` when it is not or the class is never closed. +fn class_end(text: &[char], pat: &[char], p: usize, t: usize, budget: &mut u64) -> Result, ()> { + let mut close = p; + while let Some(c) = pat.get(close) { + charge_match(budget)?; + if *c == ']' { + break; + } + close += 1; + } + if close == pat.len() { + return Ok(None); + } + let Some(&c) = text.get(t) else { return Ok(None) }; + let set = &pat[p + 1..close]; + let (neg, set) = match set.split_first() { + Some(('~', r)) if !r.is_empty() => (true, r), + _ => (false, set), + }; + let mut hit = false; + let mut i = 0; + while i < set.len() { + charge_match(budget)?; + if set.get(i + 1) == Some(&'-') && i + 2 < set.len() { + if set[i] <= c && c <= set[i + 2] { + hit = true; + } + i += 3; + } else { + if set[i] == c { + hit = true; + } + i += 1; + } + } + if hit == neg { + return Ok(None); + } + Ok(Some(close)) +} + /// Evaluate wildcard tokens and backtracking branches under shared work and recursion limits. fn glob_inner( text: &[char], @@ -797,8 +839,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() { @@ -822,42 +864,7 @@ fn glob_inner( p += 2; } '[' => { - let mut close = p; - while let Some(c) = pat.get(close) { - charge_match(budget)?; - if *c == ']' { - break; - } - close += 1; - } - if close == pat.len() { - return Ok(false); - } - let Some(&c) = text.get(t) else { return Ok(false) }; - let set = &pat[p + 1..close]; - let (neg, set) = match set.split_first() { - Some(('~', r)) if !r.is_empty() => (true, r), - _ => (false, set), - }; - let mut hit = false; - let mut i = 0; - while i < set.len() { - charge_match(budget)?; - if set.get(i + 1) == Some(&'-') && i + 2 < set.len() { - if set[i] <= c && c <= set[i + 2] { - hit = true; - } - i += 3; - } else { - if set[i] == c { - hit = true; - } - i += 1; - } - } - if hit == neg { - return Ok(false); - } + let Some(close) = class_end(text, pat, p, t, budget)? else { return Ok(false) }; t += 1; p = close + 1; } diff --git a/crates/engine/src/lisp/tests.rs b/crates/engine/src/lisp/tests.rs index 820a9812b..a48c04eba 100644 --- a/crates/engine/src/lisp/tests.rs +++ b/crates/engine/src/lisp/tests.rs @@ -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(); diff --git a/crates/engine/src/prompt.rs b/crates/engine/src/prompt.rs index 63883240c..c02551b80 100644 --- a/crates/engine/src/prompt.rs +++ b/crates/engine/src/prompt.rs @@ -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 diff --git a/crates/engine/src/select.rs b/crates/engine/src/select.rs index 7666ab54d..d3db01c9b 100644 --- a/crates/engine/src/select.rs +++ b/crates/engine/src/select.rs @@ -434,7 +434,7 @@ impl SelectMode { }; match last { None => Prompt::new(first, Accept::POINT), - Some(p) if self.is_box() => Prompt::new(next, Accept::POINT).base(p), + Some(p) if self.is_box() => Prompt::new(next, Accept::POINT).corner(p), Some(p) => Prompt::new(next, Accept::POINT).kw(&["Undo"]).base(p), } } diff --git a/crates/engine/src/sysvars.rs b/crates/engine/src/sysvars.rs index 08f684655..e22529aac 100644 --- a/crates/engine/src/sysvars.rs +++ b/crates/engine/src/sysvars.rs @@ -146,6 +146,8 @@ pub fn get(s: &Session, name: &str) -> Option { // 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()), @@ -379,6 +381,13 @@ pub fn set(s: &mut Session, name: &str, v: &Value) -> Result<()> { return r; } let d = s.doc_mut()?; + // A dimension variable takes only the values its style field takes, as in DIMSTYLE and DIMOVERRIDE. + if let Some((_, field)) = cadcraft_doc::DIMVARS.iter().find(|(var, _)| *var == n) { + let mut st = d.dim_style(&d.header.str("DIMSTYLE", "Standard")).cloned().unwrap_or_default(); + if !st.apply_fields(&serde_json::Map::from_iter([((*field).to_string(), v.clone())])).is_empty() { + return Err(bad()); + } + } let val = match v { Value::Number(x) if x.is_i64() => cadcraft_doc::HVal::Int(x.as_i64().unwrap_or(0)), Value::Number(x) => cadcraft_doc::HVal::Real(x.as_f64().filter(|f| f.is_finite()).ok_or_else(bad)?), @@ -440,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)); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index a6c4c41d2..8c590dd97 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1309,6 +1309,64 @@ fn typed_ltscale_asks_for_the_scale() { assert_eq!(s.doc().unwrap().header.f64("LTSCALE", 0.0), 0.1); } +#[test] +fn format_thickness_asks_for_the_thickness() { + let mut s = Session::new(); + s.start("thickness").unwrap(); + assert!(s.prompt_text().contains("Enter new value for THICKNESS <0.0000>"), "{}", s.prompt_text()); + // After text that is not a number, the prompt asks again. + s.cmdline("thick").unwrap(); + assert!(s.current_prompt().is_some(), "asked again"); + assert_eq!(s.log.last().map(String::as_str), Some("Requires a number.")); + // A value out of range says so, and the prompt asks again. + s.cmdline("2e12").unwrap(); + assert!(s.current_prompt().is_some(), "asked again"); + assert_eq!(s.log.last().map(String::as_str), Some("Requires a value between -1e12 and 1e12.")); + assert_eq!(s.doc().unwrap().header.f64("THICKNESS", 0.0), 0.0); + // A negative thickness is accepted. + s.cmdline("-1.5").unwrap(); + assert!(s.current_prompt().is_none()); + assert_eq!(s.doc().unwrap().header.f64("THICKNESS", 0.0), -1.5); + // Typed, Enter keeps the current thickness. + s.cmdline("thickness").unwrap(); + assert!(s.prompt_text().contains("<-1.5000>"), "{}", s.prompt_text()); + s.cmdline("").unwrap(); + assert!(s.current_prompt().is_none()); + assert_eq!(s.doc().unwrap().header.f64("THICKNESS", 0.0), -1.5); + assert!(s.execute("thickness", &json!({"thickness": "thick"})).is_err()); + assert!(s.execute("thickness", &json!({"thickness": 1e13})).is_err()); +} + +#[test] +fn new_objects_take_the_current_thickness() { + let mut s = Session::new(); + s.execute("thickness", &json!({"thickness": 2.5})).unwrap(); + s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap(); + s.execute("arc", &json!({"center": [0, 0], "radius": 3, "start": 0, "end": 90})).unwrap(); + s.execute("circle", &json!({"center": [0, 0], "radius": 5})).unwrap(); + s.execute("point", &json!({"at": [1, 1]})).unwrap(); + s.execute("pline", &json!({"vertices": [[0, 0], [5, 0], [5, 5]]})).unwrap(); + s.execute("polygon", &json!({"sides": 6, "center": [20, 20], "radius": 2})).unwrap(); + s.execute("donut", &json!({"center": [30, 30], "inside": 1, "outside": 2})).unwrap(); + let thick: Vec<(&str, f64)> = s.doc().unwrap().model.iter().map(|e| (e.kind.type_name(), e.common.thickness)).collect(); + assert_eq!(thick.len(), 7, "{thick:?}"); + assert!(thick.iter().all(|(_, t)| *t == 2.5), "{thick:?}"); + // RECTANG keeps its own thickness option, and text does not take THICKNESS. + s.execute("rectang", &json!({"p1": [0, 0], "p2": [4, 4]})).unwrap(); + assert_eq!(s.doc().unwrap().model.last().unwrap().common.thickness, 0.0); + s.execute("rectang", &json!({"p1": [0, 0], "p2": [4, 4], "thickness": 1})).unwrap(); + assert_eq!(s.doc().unwrap().model.last().unwrap().common.thickness, 1.0); + let before = s.doc().unwrap().model.len(); + s.cmdline("rectang 0,0 4,4").unwrap(); + assert_eq!(s.doc().unwrap().model.len(), before + 1); + assert_eq!(s.doc().unwrap().model.last().unwrap().common.thickness, 0.0); + s.execute("text", &json!({"at": [0, 0], "text": "A"})).unwrap(); + assert_eq!(s.doc().unwrap().model.last().unwrap().common.thickness, 0.0); + // An ellipse has no thickness. + s.execute("ellipse", &json!({"center": [0, 0], "major": [4, 0], "ratio": 0.5})).unwrap(); + assert_eq!(s.doc().unwrap().model.last().unwrap().common.thickness, 0.0); +} + fn setvar_prompt_open(s: &Session) -> bool { s.running.as_ref().is_some_and(|r| r.id == "setvar") } diff --git a/crates/engine/src/units.rs b/crates/engine/src/units.rs index 892710890..0a18a0039 100644 --- a/crates/engine/src/units.rs +++ b/crates/engine/src/units.rs @@ -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::().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 { Some(deg.to_radians()) } +/// Minutes or seconds of a D'" angle: a number in `0..=60`. +fn dms_part(t: &str) -> Option { + 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 { let (neg, t) = match t.trim().strip_prefix('-') { @@ -332,11 +345,11 @@ fn parse_dms(t: &str) -> Option { if !rest.is_empty() { let (m, rest2) = rest.split_once('\'').unwrap_or((rest, "")); if !m.trim().is_empty() { - deg += m.trim().parse::().ok()? / 60.0; + deg += dms_part(m)? / 60.0; } let s = rest2.trim().trim_end_matches('"'); if !s.is_empty() { - deg += s.parse::().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"); + } } diff --git a/crates/engine/tests/autosave.rs b/crates/engine/tests/autosave.rs index 0f0ec7da7..da03c0d7d 100644 --- a/crates/engine/tests/autosave.rs +++ b/crates/engine/tests/autosave.rs @@ -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); +} diff --git a/crates/engine/tests/block_editor.rs b/crates/engine/tests/block_editor.rs index 3b1404f1f..711bcd796 100644 --- a/crates/engine/tests/block_editor.rs +++ b/crates/engine/tests/block_editor.rs @@ -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); diff --git a/crates/engine/tests/clipboard.rs b/crates/engine/tests/clipboard.rs index 03ec40842..650d63092 100644 --- a/crates/engine/tests/clipboard.rs +++ b/crates/engine/tests/clipboard.rs @@ -170,6 +170,118 @@ fn pasteblock_makes_a_block_of_the_clipboard() { assert!(d.block("DOOR").is_some() && d.layer("HINGE").is_some()); } +#[test] +fn cutbase_cuts_with_the_given_base_point() { + let mut s = Session::empty(); + let mut da = drawing_a(); + if let Some(l) = da.layer_mut("WALLS") { + l.locked = true; + } + let a = s.open_drawing(da, "A", None); + let b = s.open_drawing(drawing_b(), "B", None); + s.active = a; + let r = s.execute("cutbase", &json!({ "handles": handles(&s), "base": [5, 5] })).unwrap(); + // The wall is on a locked layer, so only the door is cut. + assert_eq!(r, json!({ "cut": 1, "message": "1 object was on a locked layer." })); + assert_eq!(s.clipboard.len(), 1); + assert_eq!(s.clipboard_base, Vec2::new(5.0, 5.0)); + let left = model(&s); + assert_eq!(left.len(), 1); + assert!(matches!(&left[0].kind, EntityKind::Line(_)), "the wall stays"); + + // PASTECLIP places the base point at the insertion point. + s.active = b; + s.execute("pasteclip", &json!({ "at": [105, 5] })).unwrap(); + let pasted = model(&s); + assert!(matches!(&pasted[0].kind, EntityKind::Insert(i) if i.block == "DOOR" && i.insert.x == 115.0 && i.insert.y == 20.0)); + + // Undo in A brings the door back. + s.active = a; + s.execute("u", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().model.len(), 2); +} + +#[test] +fn cutting_only_locked_objects_keeps_the_clipboard() { + let mut s = Session::empty(); + let mut da = drawing_a(); + for l in &mut da.layers { + l.locked = true; + } + s.open_drawing(da, "A", None); + let hs = handles(&s); + s.execute("copyclip", &json!({ "handles": [hs[0]] })).unwrap(); + let held = s.clipboard.clone(); + let r = s.execute("cutclip", &json!({ "handles": hs })).unwrap(); + assert_eq!(r, json!({ "cut": 0, "message": "2 objects were on a locked layer. Nothing was cut." })); + assert_eq!(model(&s).len(), 2, "nothing was erased"); + assert_eq!(s.clipboard.len(), 1, "the clipboard keeps what it held"); + assert_eq!(s.clipboard[0].handle, held[0].handle); + + // CUTBASE at the command line says so too. + s.start("cutbase").unwrap(); + s.input(cadcraft_engine::Input::Point(Vec2::new(3.0, 4.0))).unwrap(); + let wall = s.doc().unwrap().model.iter().next().unwrap().handle; + s.input(cadcraft_engine::Input::Pick(vec![wall])).unwrap(); + s.input(cadcraft_engine::Input::Enter).unwrap(); + assert!(s.running.is_none()); + assert!(s.log.iter().any(|l| l.contains("1 object was on a locked layer. Nothing was cut.")), "{:?}", s.log); + assert_eq!(s.clipboard.len(), 1); + assert_eq!(model(&s).len(), 2); +} + +#[test] +fn cutbase_and_copybase_need_a_base_point_and_take_each_object_once() { + let mut s = Session::empty(); + s.open_drawing(drawing_a(), "A", None); + let hs = handles(&s); + for cmd in ["cutbase", "copybase"] { + for p in [json!({ "handles": hs }), json!({ "handles": hs, "base": [5] })] { + let e = s.execute(cmd, &p).unwrap_err().to_string(); + assert!(e.contains("`base` ([x, y]) is required"), "{cmd}: {e}"); + } + } + assert_eq!(model(&s).len(), 2, "nothing was cut"); + assert!(s.clipboard.is_empty(), "nothing was copied"); + + // A handle listed twice is copied and cut once. + let twice = json!({ "handles": [hs[1], hs[1]], "base": [0, 0] }); + assert_eq!(s.execute("copybase", &twice).unwrap(), json!({ "copied": 1 })); + assert_eq!(s.clipboard.len(), 1); + assert_eq!(s.execute("cutbase", &twice).unwrap(), json!({ "cut": 1 })); + assert_eq!(s.clipboard.len(), 1); + assert_eq!(model(&s).len(), 1); +} + +#[test] +fn cutbase_asks_for_the_base_point_then_the_objects() { + let mut s = Session::empty(); + s.open_drawing(drawing_a(), "A", None); + let hs: Vec = s.doc().unwrap().model.iter().map(|e| e.handle).collect(); + let undo_before = s.state().unwrap().undo.len(); + s.start("cutbase").unwrap(); + assert!(s.prompt_text().contains("Specify base point"), "{}", s.prompt_text()); + s.input(cadcraft_engine::Input::Point(Vec2::new(3.0, 4.0))).unwrap(); + assert!(s.prompt_text().contains("Select objects"), "{}", s.prompt_text()); + s.input(cadcraft_engine::Input::Pick(vec![hs[1]])).unwrap(); + s.input(cadcraft_engine::Input::Enter).unwrap(); + assert!(s.running.is_none()); + assert_eq!(s.clipboard.len(), 1); + assert_eq!(s.clipboard_base, Vec2::new(3.0, 4.0)); + assert_eq!(model(&s).iter().map(|e| e.handle).collect::>(), [hs[0]]); + assert_eq!(s.state().unwrap().undo.len(), undo_before + 1, "one undo step"); + + // With a pickfirst selection, the base point is the only prompt. + s.set_selection(vec![hs[0]]); + s.start("cutbase").unwrap(); + assert!(s.prompt_text().contains("Specify base point"), "{}", s.prompt_text()); + s.input(cadcraft_engine::Input::Point(Vec2::new(1.0, 2.0))).unwrap(); + assert!(s.running.is_none()); + assert!(s.doc().unwrap().model.is_empty()); + assert_eq!(s.clipboard_base, Vec2::new(1.0, 2.0)); + assert!(s.selection().is_empty()); +} + /// A stand-in for the DXF writer and reader: "writes" the drawing to memory. static WRITTEN: Mutex> = Mutex::new(None); diff --git a/crates/engine/tests/compare.rs b/crates/engine/tests/compare.rs index bbaeca3b0..0e4486ccb 100644 --- a/crates/engine/tests/compare.rs +++ b/crates/engine/tests/compare.rs @@ -104,16 +104,40 @@ fn navigates_areas_and_closes() { assert!(s.compare.is_none()); } -#[test] -fn compares_a_file_read_through_the_io_hooks() { - // Test file format: the bytes are "x" — a drawing holding a line from 0,0 to x,0. +/// The file-reading hook both tests here share (it is set once per process). Test file format: +/// "xref" is a drawing with a reference to xref Y at `y.dwg`; a number x is a drawing holding a +/// line from 0,0 to x,0. +fn hooks() { fn read(bytes: &[u8], _name: &str) -> Result { - let x: f64 = String::from_utf8_lossy(bytes).trim().parse().map_err(|_| "bad test file")?; let mut d = Drawing::new_imperial(); + if bytes == b"xref" { + let mut y = Block::new("Y"); + y.xref_path = Some("y.dwg".into()); + d.blocks.insert("Y".into(), Arc::new(y)); + let insert = Insert { + block: "Y".into(), + insert: Vec3::ZERO, + scale: Vec3::new(1.0, 1.0, 1.0), + rotation: 0.0, + attribs: Vec::new(), + cols: 1, + rows: 1, + col_spacing: 0.0, + row_spacing: 0.0, + }; + d.add(&Space::Model, Common::default(), EntityKind::Insert(insert)).map_err(|e| e.to_string())?; + return Ok(d); + } + let x: f64 = String::from_utf8_lossy(bytes).trim().parse().map_err(|_| "bad test file")?; d.add(&Space::Model, Common::default(), line(0.0, 0.0, x, 0.0)).map_err(|e| e.to_string())?; Ok(d) } set_io(IoHooks { read, write: |_, _| Err("no writer in this test".into()), plot: None }); +} + +#[test] +fn compares_a_file_read_through_the_io_hooks() { + hooks(); let dir = std::env::temp_dir().join(format!("cadcraft-compare-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("other.dxf"); @@ -194,3 +218,44 @@ fn derived_dimension_fields_are_not_differences() { let r = s.execute("compare", &json!({"document": 0})).unwrap(); assert_eq!(counts(&r), (0, 0, 0, 1), "{r}"); } + +/// COMPAREEXPORT (#2049): an xref path relative to the compared file keeps that file's folder. +#[test] +fn relative_xref_paths_of_a_compared_file_take_that_files_folder() { + hooks(); + let dir = std::path::PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join(format!("compareexport-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("other.dxf"); + std::fs::write(&path, "xref").unwrap(); + // Named by its absolute path and by a path relative to the working directory. + let mut names = vec![path.to_string_lossy().to_string()]; + names.extend(relative_to_cwd(&path)); + for name in names { + let mut s = Session::new(); + s.execute("compare", &json!({"path": name})).unwrap(); + // The compared file's xref is only there; the copy keeps the file's folder. + s.execute("compareexport", &json!({})).unwrap(); + let got = s.doc().unwrap().block("Y").unwrap().xref_path.clone().unwrap(); + let got = std::path::Path::new(&got); + assert!(got.is_absolute(), "{got:?}, compared as {name}"); + assert_eq!(got.file_name(), Some(std::ffi::OsStr::new("y.dwg"))); + assert_eq!(got.parent().unwrap().canonicalize().unwrap(), dir.canonicalize().unwrap(), "compared as {name}"); + } + std::fs::remove_dir_all(&dir).ok(); +} + +/// `p` as a path relative to the working directory (`..` steps up to the shared folder), when +/// the two share a root. +fn relative_to_cwd(p: &std::path::Path) -> Option { + use std::path::Component; + let cwd = std::env::current_dir().ok()?; + let (a, b): (Vec, Vec) = (cwd.components().collect(), p.components().collect()); + let shared = a.iter().zip(&b).take_while(|(x, y)| x == y).count(); + if shared == 0 { + return None; + } + let mut rel = std::path::PathBuf::new(); + a[shared..].iter().for_each(|_| rel.push("..")); + b[shared..].iter().for_each(|c| rel.push(c)); + Some(rel.to_string_lossy().to_string()) +} diff --git a/crates/engine/tests/it/attdisp_attsync.rs b/crates/engine/tests/it/attdisp_attsync.rs index bbdbd2152..e079c00ca 100644 --- a/crates/engine/tests/it/attdisp_attsync.rs +++ b/crates/engine/tests/it/attdisp_attsync.rs @@ -59,6 +59,38 @@ fn attdisp_sets_attmode_and_the_display_follows_it() { assert!(s.execute("attdisp", &json!({"mode": "sometimes"})).is_err()); } +#[test] +fn view_attribute_display_menu_sets_attmode() { + let mut s = Session::new(); + for (id, item, mode) in [("attdisp.on", "On", 2), ("attdisp.off", "Off", 0), ("attdisp.normal", "Normal", 1)] { + let spec = cadcraft_engine::find_command(id).unwrap_or_else(|| panic!("no command {id}")); + assert_eq!(spec.menu, ["View", "Attribute Display", item]); + s.start(id).unwrap(); + assert!(s.running.is_none(), "{id}: {}", s.prompt_text()); + assert_eq!(s.doc().unwrap().header.i64("ATTMODE", 1), mode, "{id}"); + } + assert_eq!(s.execute("attdisp.off", &json!({})).unwrap(), json!({"attmode": 0})); + s.undo().unwrap(); + assert_eq!(s.doc().unwrap().header.i64("ATTMODE", 1), 1); +} + +#[test] +fn attdisp_runs_transparently_inside_another_command() { + let mut s = Session::new(); + s.cmdline("LINE 0,0").unwrap(); + s.cmdline("'ATTDISP").unwrap(); + assert_eq!(s.running.as_ref().map(|r| r.id.as_str()), Some("attdisp")); + assert!(s.prompt_text().starts_with(">>"), "{}", s.prompt_text()); + s.cmdline("off").unwrap(); + assert_eq!(s.doc().unwrap().header.i64("ATTMODE", 1), 0); + // LINE prompts again for its next point. + assert_eq!(s.running.as_ref().map(|r| r.id.as_str()), Some("line"), "{}", s.prompt_text()); + s.cmdline("10,0").unwrap(); + s.cmdline("").unwrap(); + assert!(s.running.is_none()); + assert_eq!(s.doc().unwrap().model.iter().count(), 1); +} + #[test] fn attsync_brings_references_in_line_with_the_definitions() { let mut s = Session::new(); diff --git a/crates/engine/tests/it/block_redefine.rs b/crates/engine/tests/it/block_redefine.rs index e5a9cac16..136d2817d 100644 --- a/crates/engine/tests/it/block_redefine.rs +++ b/crates/engine/tests/it/block_redefine.rs @@ -40,6 +40,12 @@ fn a_block_cannot_be_redefined_to_reference_itself() { let r = s.execute("block", &json!({ "name": "a", "base": [0, 0], "handles": [b] })); assert!(r.is_err_and(|e| e.to_string().contains("references itself"))); assert_eq!(contents(&s, "A"), ["line"]); + + // Through a multileader whose content is the block being redefined. + let m = s.execute("mleader", &json!({ "points": [[0, 0], [10, 5]], "block": "A" })).unwrap()["handle"].clone(); + let r = s.execute("block", &json!({ "name": "A", "base": [0, 0], "handles": [m] })); + assert!(r.is_err_and(|e| e.to_string().contains("references itself"))); + assert_eq!(contents(&s, "A"), ["line"]); } #[test] diff --git a/crates/engine/tests/it/blockreplace.rs b/crates/engine/tests/it/blockreplace.rs new file mode 100644 index 000000000..86b8c42e3 --- /dev/null +++ b/crates/engine/tests/it/blockreplace.rs @@ -0,0 +1,223 @@ +//! BLOCKREPLACE (Express Tools): every reference of one block, in model space, the layouts and +//! other blocks, becomes a reference of another at the same insertion point, scale and rotation. + +use cadcraft_engine::doc::foreign::{Foreign, ForeignExtra, ForeignObject}; +use cadcraft_engine::doc::geom::Vec3; +use cadcraft_engine::doc::{Block, Common, Entity, EntityKind, Handle, Insert, RawTag, Space}; +use cadcraft_engine::{Input, Session}; +use serde_json::{Value, json}; + +/// Make block `name` from what is in model space now (the objects are removed). +fn make_block(s: &mut Session, name: &str) { + let hs: Vec = s.doc().unwrap().model.iter().map(|e| e.handle.hex()).collect(); + s.execute("block", &json!({"name": name, "base": [0, 0], "handles": hs, "keep": "delete"})).unwrap(); +} + +/// Block A: a circle and attribute TAG. Block B: a line, attributes TAG and NEW. Block N holds a +/// reference of A. Model space: a reference of A on layer R at (10, 5), scaled 2 and rotated +/// 30°, with TAG "kept", and a reference of N. Layout1: a reference of A. +fn drawing() -> (Session, Handle) { + let mut s = Session::new(); + s.execute("attdef", &json!({"tag": "TAG", "default": "a", "at": [0, 2], "height": 1.0})).unwrap(); + s.execute("circle", &json!({"center": [0, 0], "radius": 1})).unwrap(); + make_block(&mut s, "A"); + s.execute("attdef", &json!({"tag": "TAG", "default": "b", "at": [0, 3], "height": 1.0})).unwrap(); + s.execute("attdef", &json!({"tag": "NEW", "default": "new", "at": [0, 5], "height": 1.0})).unwrap(); + s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap(); + make_block(&mut s, "B"); + s.execute("insert", &json!({"name": "A", "at": [0, 0]})).unwrap(); + make_block(&mut s, "N"); + s.execute("insert", &json!({"name": "N", "at": [50, 0]})).unwrap(); + s.execute("layer.new", &json!({"name": "R", "current": true})).unwrap(); + let r = s.execute("insert", &json!({"name": "A", "at": [10, 5], "scale": 2, "rotation": 30, "attribs": {"TAG": "kept"}})).unwrap(); + let ins = Insert { + block: "A".into(), + insert: Vec3::new(0.0, 0.0, 0.0), + scale: Vec3::new(1.0, 1.0, 1.0), + rotation: 0.0, + attribs: Vec::new(), + cols: 1, + rows: 1, + col_spacing: 0.0, + row_spacing: 0.0, + }; + s.doc_mut().unwrap().add(&Space::Paper("Layout1".into()), Common::default(), EntityKind::Insert(ins)).unwrap(); + (s, Handle::parse_hex(r["handle"].as_str().unwrap()).unwrap()) +} + +/// The block names of the references in `ents`. +fn refs<'a>(ents: impl Iterator>) -> Vec { + ents.filter_map(|e| match &e.kind { + EntityKind::Insert(i) => Some(i.block.clone()), + _ => None, + }) + .collect() +} + +fn insert(s: &Session, h: Handle) -> (Insert, String) { + let e = s.doc().unwrap().entity(h).unwrap(); + let EntityKind::Insert(i) = &e.kind else { panic!("not a block reference") }; + (i.clone(), e.common.layer.clone()) +} + +#[test] +fn blockreplace_replaces_every_reference_and_keeps_its_placement() { + let (mut s, h) = drawing(); + let before = insert(&s, h).0; + let r = s.execute("blockreplace", &json!({"block": "a", "with": "b"})).unwrap(); + assert_eq!(r, json!({"block": "A", "with": "B", "replaced": 3, "purged": false})); + + let (after, layer) = insert(&s, h); + assert_eq!(after.block, "B"); + assert_eq!((after.insert, after.scale, after.rotation, layer.as_str()), (before.insert, before.scale, before.rotation, "R")); + // The replacement's attributes, with the value of the tag both blocks define. + let values: Vec<(String, String)> = after.attribs.iter().map(|a| (a.tag.clone(), a.text.value.clone())).collect(); + assert_eq!(values, [("TAG".to_string(), "kept".to_string()), ("NEW".to_string(), "new".to_string())]); + + let d = s.doc().unwrap(); + assert_eq!(refs(d.model.iter()), ["N", "B"]); + assert_eq!(refs(d.block("N").unwrap().entities.iter()), ["B"]); + assert_eq!(refs(d.space(&Space::Paper("Layout1".into())).unwrap().iter()), ["B"]); + assert!(d.block("A").is_some(), "the definition stays without `purge`"); + + s.execute("undo", &json!({})).unwrap(); + assert_eq!(insert(&s, h).0, before); +} + +#[test] +fn blockreplace_references_follow_the_replacement_blocks_annotative_setting() { + let (mut s, h) = drawing(); + let c = s.execute("circle", &json!({"center": [0, 0], "radius": 2})).unwrap()["handle"].clone(); + s.execute("block", &json!({"name": "AN", "base": [0, 0], "handles": [c], "keep": "delete", "annotative": true})).unwrap(); + let scales = |s: &Session| s.doc().unwrap().entity(h).unwrap().common.anno_scales.len(); + assert_eq!(scales(&s), 0); + s.execute("blockreplace", &json!({"block": "A", "with": "AN"})).unwrap(); + assert_eq!(scales(&s), 1, "a reference of an annotative block supports the current scale"); + s.execute("blockreplace", &json!({"block": "AN", "with": "B"})).unwrap(); + assert_eq!(scales(&s), 0); +} + +#[test] +fn blockreplace_purges_the_replaced_definition_on_request() { + let (mut s, _) = drawing(); + let r = s.execute("blockreplace", &json!({"block": "A", "with": "B", "purge": true})).unwrap(); + assert_eq!(r["purged"], json!(true)); + assert!(s.doc().unwrap().block("A").is_none()); +} + +#[test] +fn blockreplace_keeps_a_definition_an_unused_block_still_names() { + let (mut s, _) = drawing(); + // Block U, never inserted, holds a multileader whose content is block A. + let m = s.execute("mleader", &json!({"points": [[0, 0], [10, 5]], "block": "A"})).unwrap()["handle"].clone(); + s.execute("block", &json!({"name": "U", "base": [0, 0], "handles": [m], "keep": "delete"})).unwrap(); + let r = s.execute("blockreplace", &json!({"block": "A", "with": "B", "purge": true})).unwrap(); + assert_eq!((r["replaced"].clone(), r["purged"].clone()), (json!(3), json!(false))); + assert!(s.doc().unwrap().block("A").is_some()); +} + +#[test] +fn blockreplace_refuses_what_it_cannot_replace() { + let (mut s, _) = drawing(); + let err = |s: &mut Session, p: Value| s.execute("blockreplace", &p).unwrap_err().to_string(); + assert!(err(&mut s, json!({"block": "A", "with": "a"})).contains("different block")); + assert!(err(&mut s, json!({"block": "A", "with": "MISSING"})).contains("not found")); + assert!(err(&mut s, json!({"block": "MISSING", "with": "B"})).contains("not found")); + // N holds a reference of A, so N replacing A would reference itself. + assert!(err(&mut s, json!({"block": "A", "with": "N"})).contains("references")); + // M holds a multileader whose content is N, so M reaches A too. + let m = s.execute("mleader", &json!({"points": [[0, 0], [10, 5]], "block": "N"})).unwrap()["handle"].clone(); + s.execute("block", &json!({"name": "M", "base": [0, 0], "handles": [m], "keep": "delete"})).unwrap(); + assert!(err(&mut s, json!({"block": "A", "with": "M"})).contains("references")); + assert!(err(&mut s, json!({"block": "A", "with": "B", "purge": "yes"})).contains("`purge`")); + assert!(err(&mut s, json!({"with": "B"})).contains("`block`")); + // A block an xref brings in is not saved with the drawing. + s.doc_mut().unwrap().blocks.insert("X|A".into(), std::sync::Arc::new(Block::new("X|A"))); + assert!(err(&mut s, json!({"block": "X|A", "with": "B"})).contains("xref")); + assert!(err(&mut s, json!({"block": "A", "with": "X|A"})).contains("xref")); + assert_eq!(refs(s.doc().unwrap().model.iter()), ["N", "A"]); + // The Block Editor shows the block, not the drawing's references. + s.execute("bedit", &json!({"name": "B"})).unwrap(); + assert!(err(&mut s, json!({"block": "A", "with": "B"})).contains("Block Editor")); +} + +#[test] +fn blockreplace_asks_for_both_blocks_and_whether_to_purge() { + let (mut s, h) = drawing(); + s.cmdline("-blockreplace").unwrap(); + assert!(s.prompt_text().contains("Enter the name of the block to replace or [?/Select]"), "{}", s.prompt_text()); + s.cmdline("?").unwrap(); + assert!(s.log.iter().any(|l| l == "Blocks: A, B, N"), "{:?}", s.log); + s.cmdline("a").unwrap(); + assert!(s.prompt_text().contains("Enter the name of the replacement block for A or [?/Select]"), "{}", s.prompt_text()); + // A cannot replace itself: the prompt asks again. + s.cmdline("A").unwrap(); + assert!(s.prompt_text().contains("replacement block for A"), "{}", s.prompt_text()); + s.input(Input::Cancel).unwrap(); + assert!(s.running.is_none()); + assert_eq!(refs(s.doc().unwrap().model.iter()), ["N", "A"]); + + // The block to replace picked from one of its references; Enter purges it. + s.cmdline("blockreplace").unwrap(); + s.cmdline("Select").unwrap(); + s.input(Input::Pick(vec![h])).unwrap(); + assert!(s.prompt_text().contains("replacement block for A"), "{}", s.prompt_text()); + s.cmdline("B").unwrap(); + assert!(s.prompt_text().contains("Purge the unreferenced block definition A? [Yes/No] "), "{}", s.prompt_text()); + s.cmdline("").unwrap(); + assert!(s.running.is_none()); + assert!(s.log.iter().any(|l| l == "3 references of block A replaced with B. Block A purged."), "{:?}", s.log); + let d = s.doc().unwrap(); + assert!(d.block("A").is_none()); + assert_eq!(refs(d.model.iter()), ["N", "B"]); + assert_eq!(insert(&s, h).0.block, "B"); +} + +#[test] +fn blockreplace_no_keeps_the_replaced_definition() { + let (mut s, _) = drawing(); + s.cmdline("blockreplace").unwrap(); + s.cmdline("A").unwrap(); + s.cmdline("B").unwrap(); + s.cmdline("No").unwrap(); + assert!(s.running.is_none()); + let d = s.doc().unwrap(); + assert!(d.block("A").is_some()); + assert_eq!(refs(d.model.iter()), ["N", "B"]); +} + +#[test] +fn blockreplace_refuses_a_block_as_soon_as_its_name_is_given() { + let (mut s, _) = drawing(); + s.cmdline("blockreplace").unwrap(); + s.cmdline("A").unwrap(); + // N holds a reference of A: refused at once, and the prompt asks for another name. + s.cmdline("N").unwrap(); + assert!(s.log.iter().any(|l| l.contains("Block \"N\" references \"A\"")), "{:?}", s.log); + assert!(s.prompt_text().contains("replacement block for A"), "{}", s.prompt_text()); + s.cmdline("B").unwrap(); + assert!(s.prompt_text().contains("Purge the unreferenced block definition A?"), "{}", s.prompt_text()); + s.input(Input::Cancel).unwrap(); + + // A dynamic block is refused as the block to replace. + let mut d = s.doc().unwrap().clone(); + let mut f = Foreign::default(); + f.records.insert(("BLOCK_RECORD".into(), "A".into()), ForeignExtra { xdict: Some(Handle(0xF00)), ..Default::default() }); + let tags = |t: &[(i32, &str)]| t.iter().map(|(c, v)| RawTag { code: *c, value: (*v).into() }).collect(); + f.objects.push(ForeignObject { + handle: Handle(0xF00), + kind: "DICTIONARY".into(), + tags: tags(&[(100, "AcDbDictionary"), (3, "ACAD_ENHANCEDBLOCK"), (360, "F01")]), + }); + f.objects.push(ForeignObject { handle: Handle(0xF01), kind: "ACAD_EVALUATION_GRAPH".into(), tags: tags(&[(100, "AcDbEvalGraph")]) }); + d.foreign = std::sync::Arc::new(f); + s.open_drawing(d, "dynamic", None); + let e = s.execute("blockreplace", &json!({"block": "A", "with": "B"})).unwrap_err().to_string(); + assert!(e.contains("dynamic block"), "{e}"); + s.cmdline("blockreplace").unwrap(); + s.cmdline("A").unwrap(); + assert!(s.log.iter().any(|l| l.contains("Block \"A\" is a dynamic block")), "{:?}", s.log); + assert!(s.prompt_text().contains("Enter the name of the block to replace"), "{}", s.prompt_text()); + s.input(Input::Cancel).unwrap(); + assert_eq!(refs(s.doc().unwrap().model.iter()), ["N", "A"]); +} diff --git a/crates/engine/tests/it/compare_export.rs b/crates/engine/tests/it/compare_export.rs new file mode 100644 index 000000000..1aee1b9db --- /dev/null +++ b/crates/engine/tests/it/compare_export.rs @@ -0,0 +1,882 @@ +//! COMPAREEXPORT: the comparison as a new drawing, with this drawing's objects in one group and +//! the other drawing's differing objects in another, in the comparison colors. A compared file's +//! relative xref paths, which need the file-reading hook, are tested in `tests/compare.rs`. + +use std::sync::Arc; + +use cadcraft_engine::Session; +use cadcraft_engine::doc::color::{Color, Rgb}; +use cadcraft_engine::doc::geom::Vec3; +use cadcraft_engine::doc::{Block, CellStyle, Common, Drawing, Entity, EntityKind, Handle, Insert, Layer, MLeader, Space}; +use serde_json::json; + +fn insert(block: &str) -> Insert { + Insert { + block: block.into(), + insert: Vec3::ZERO, + scale: Vec3::new(1.0, 1.0, 1.0), + rotation: 0.0, + attribs: Vec::new(), + cols: 1, + rows: 1, + col_spacing: 0.0, + row_spacing: 0.0, + } +} + +fn line(x: f64) -> EntityKind { + EntityKind::Line(cadcraft_engine::doc::Line { a: Vec3::ZERO, b: Vec3::new(x, 0.0, 0.0) }) +} + +fn handle_of(s: &Session, ty: &str) -> String { + s.doc().unwrap().model.iter().find(|e| e.kind.type_name() == ty).unwrap().handle.hex() +} + +/// "Drawing1" holds a line, a circle and a text; "B" is a copy where the circle moved, the text +/// is gone and an arc was added. The comparison runs in "B" against "Drawing1". +fn compared() -> Session { + let mut s = Session::new(); + s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap(); + s.execute("circle", &json!({"center": [5, 5], "radius": 2})).unwrap(); + s.execute("text", &json!({"at": [0, 10], "height": 1, "text": "Note"})).unwrap(); + let d: Drawing = s.doc().unwrap().clone(); + s.open_drawing(d, "B.dxf", None); + let circle = handle_of(&s, "Circle"); + let text = handle_of(&s, "Text"); + s.execute("move", &json!({"handles": [circle], "from": [0, 0], "to": [3, 0]})).unwrap(); + s.execute("erase", &json!({"handles": [text]})).unwrap(); + s.execute("arc", &json!({"center": [20, 0], "radius": 1, "start": 0, "end": 90})).unwrap(); + s.execute("compare", &json!({"document": "Drawing1"})).unwrap(); + s +} + +/// The objects of a group of the active drawing, in group order. +fn members(s: &Session, group: &str) -> Vec> { + let d = s.doc().unwrap(); + let g = d.groups.iter().find(|g| g.name == group).unwrap_or_else(|| panic!("no group {group}")); + g.members.iter().map(|h| d.entity(*h).unwrap().clone()).collect() +} + +/// The (type, color) of each object in a group of the active drawing, sorted. +fn contents(s: &Session, group: &str) -> Vec<(String, Color)> { + let mut v: Vec<(String, Color)> = members(s, group).iter().map(|e| (e.kind.type_name().to_string(), e.common.color)).collect(); + v.sort_by(|a, b| a.0.cmp(&b.0)); + v +} + +/// The x of each line's end in a block of the active drawing. +fn ends(s: &Session, block: &str) -> Vec { + let d = s.doc().unwrap(); + d.block(block) + .unwrap() + .entities + .iter() + .filter_map(|e| match &e.kind { + EntityKind::Line(l) => Some(l.b.x), + _ => None, + }) + .collect() +} + +const GREEN: Color = Color::True(Rgb(0x4c, 0xd9, 0x7b)); +const RED: Color = Color::True(Rgb(0xff, 0x5c, 0x5c)); +const GRAY: Color = Color::Index(8); + +#[test] +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"); + assert_eq!(s.state().unwrap().path, None, "the snapshot is untitled"); + assert!(s.state().unwrap().is_dirty(), "closing the snapshot asks to save it"); + assert_eq!((r["counts"]["current"].as_u64(), r["counts"]["compared"].as_u64()), (Some(3), Some(2)), "{r}"); + assert_eq!(r["truncated"], false); + let (cur, other) = (r["current"].as_str().unwrap(), r["compared"].as_str().unwrap()); + assert_eq!((cur, other), ("COMPARE_CURRENT", "COMPARE_COMPARED")); + // This drawing: the moved circle and the new arc in green, the unchanged line in gray. + assert_eq!(contents(&s, cur), vec![("Arc".into(), GREEN), ("Circle".into(), GREEN), ("Line".into(), GRAY)]); + // The other drawing: the circle where it was and the erased text, in red, in its draw order. + assert_eq!(contents(&s, other), vec![("Circle".into(), RED), ("Text".into(), RED)]); + let order: Vec<&str> = members(&s, other).iter().map(|e| e.kind.type_name()).collect(); + assert_eq!(order, ["Circle", "Text"]); + let circle_there = members(&s, other).iter().find_map(|e| match &e.kind { + EntityKind::Circle(c) => Some(c.center), + _ => None, + }); + assert_eq!(circle_there.map(|c| (c.x, c.y)), Some((5.0, 5.0))); + // Model space holds the other drawing's objects first, then this drawing's. + let d = s.doc().unwrap(); + let order: Vec = d.model.iter().map(|e| e.handle).collect(); + let groups: Vec = members(&s, other).iter().chain(members(&s, cur).iter()).map(|e| e.handle).collect(); + assert_eq!(order, groups); + // Every handle is new and unique. + let mut seen = std::collections::HashSet::new(); + for e in d.model.iter().chain(d.blocks.values().flat_map(|b| b.entities.iter())) { + assert!(seen.insert(e.handle), "handle {} repeats", e.handle.hex()); + } + // The drawing COMPARE ran in is unchanged, and the comparison stays on it. + let back = s.docs.iter().position(|d| d.title == "B.dxf").unwrap(); + assert!(*s.docs[back].doc == source); + assert!(s.compare.is_some()); +} + +#[test] +fn takes_the_other_drawings_layers_and_blocks_it_needs() { + let mut s = Session::new(); + s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap(); + // The other drawing holds a reference to a block this one lacks, on a layer this one lacks. + let mut other = s.doc().unwrap().clone(); + other.handseed = 0x9000; + let mut part = Block::new("PART"); + part.entities.push(Entity::new(Handle(0x8000), EntityKind::Insert(insert("BOLT")))); + let mut bolt = Block::new("BOLT"); + bolt.entities.push(Entity::new(Handle(0x8001), line(1.0))); + other.blocks.insert("PART".into(), Arc::new(part)); + other.blocks.insert("BOLT".into(), Arc::new(bolt)); + other.layers.push(Layer::new("Parts")); + other.add(&Space::Model, Common { layer: "Parts".into(), ..Common::default() }, EntityKind::Insert(insert("PART"))).unwrap(); + // A group of this drawing's name does not carry over, so the snapshot takes the first name. + s.execute("group", &json!({"name": "COMPARE_CURRENT", "handles": [handle_of(&s, "Line")]})).unwrap(); + s.open_drawing(other, "Other", None); + s.execute("document.switch", &json!({"index": 0})).unwrap(); + s.execute("compare", &json!({"document": "Other"})).unwrap(); + let r = s.execute("compareexport", &json!({})).unwrap(); + assert_eq!(r["current"], "COMPARE_CURRENT", "{r}"); + let d = s.doc().unwrap(); + assert_eq!(d.groups.len(), 2); + assert!(d.layer("Parts").is_some()); + assert!(d.block("PART").is_some() && d.block("BOLT").is_some(), "nested blocks are copied"); + assert_eq!(ends(&s, "COMPARE_COMPARED_BOLT"), vec![1.0], "and shown through red copies"); + assert!(d.blocks.values().flat_map(|b| b.entities.iter()).all(|e| e.handle.0 < 0x8000 || e.handle.0 >= 0x9000)); + let refs: Vec = members(&s, "COMPARE_COMPARED") + .iter() + .filter_map(|e| match &e.kind { + EntityKind::Insert(i) => Some(format!("{} on {}", i.block, e.common.layer)), + _ => None, + }) + .collect(); + assert_eq!(refs, vec!["COMPARE_COMPARED_PART on Parts".to_string()]); +} + +#[test] +fn anonymous_blocks_of_the_other_drawing_keep_their_own_definition() { + let mut s = Session::new(); + let mut mine = Block::new("*U1"); + mine.entities.push(Entity::new(Handle(0x700), line(1.0))); + s.doc_mut().unwrap().blocks.insert("*U1".into(), Arc::new(mine)); + s.doc_mut().unwrap().add(&Space::Model, Common::default(), EntityKind::Insert(insert("*U1"))).unwrap(); + // The other drawing uses the same anonymous name for other geometry. + let mut other = Drawing::new_imperial(); + other.handseed = 0x9000; + let mut theirs = Block::new("*U1"); + theirs.entities.push(Entity::new(Handle(0x8000), line(5.0))); + other.blocks.insert("*U1".into(), Arc::new(theirs)); + other.add(&Space::Model, Common::default(), EntityKind::Insert(Insert { insert: Vec3::new(9.0, 9.0, 0.0), ..insert("*U1") })).unwrap(); + s.open_drawing(other, "Other", None); + s.execute("document.switch", &json!({"index": 0})).unwrap(); + s.execute("compare", &json!({"document": "Other"})).unwrap(); + let r = s.execute("compareexport", &json!({})).unwrap(); + assert_eq!(ends(&s, "*U1"), vec![1.0], "this drawing's definition is kept"); + let referenced: Vec = members(&s, r["compared"].as_str().unwrap()) + .iter() + .filter_map(|e| match &e.kind { + EntityKind::Insert(i) => Some(i.block.clone()), + _ => None, + }) + .collect(); + // The other drawing's `*U1` is copied as `*U2`, and the reference shows it through a red copy. + assert_eq!(ends(&s, "*U2"), vec![5.0]); + assert_eq!(referenced, vec!["COMPARE_COMPARED_U2".to_string()], "{r}"); + assert_eq!(ends(&s, "COMPARE_COMPARED_U2"), vec![5.0]); + assert_eq!(ends(&s, "COMPARE_CURRENT_U1"), vec![1.0]); +} + +#[test] +fn a_named_block_defined_differently_in_the_other_drawing_shows_the_other_definition() { + // PART holds a line, a reference to SUB, which holds a line of length 2, and a multileader + // whose content is SYMBOL, which holds one line. + let blocks = |part_end: f64, part_base: f64, sub_base: f64, symbol_end: f64, first: u64| { + let mut part = Block::new("PART"); + part.base = Vec3::new(part_base, 0.0, 0.0); + part.entities.push(Entity::new(Handle(first), line(part_end))); + part.entities.push(Entity::new(Handle(first + 1), EntityKind::Insert(insert("SUB")))); + let leader = MLeader { + leaders: Vec::new(), + landing: Vec3::ZERO, + dogleg: 0.0, + text: None, + style: "Standard".into(), + arrow_size: 1.0, + spline: false, + block: Some(insert("SYMBOL")), + }; + part.entities.push(Entity::new(Handle(first + 2), EntityKind::MLeader(leader))); + let mut sub = Block::new("SUB"); + sub.base = Vec3::new(sub_base, 0.0, 0.0); + sub.entities.push(Entity::new(Handle(first + 3), line(2.0))); + let mut symbol = Block::new("SYMBOL"); + symbol.entities.push(Entity::new(Handle(first + 4), line(symbol_end))); + [part, sub, symbol] + }; + let run = |theirs_end: f64, theirs_base: f64, theirs_sub_base: f64, theirs_symbol_end: f64| { + let mut s = Session::new(); + for b in blocks(1.0, 0.0, 0.0, 3.0, 0x700) { + s.doc_mut().unwrap().blocks.insert(b.name.clone(), Arc::new(b)); + } + s.doc_mut().unwrap().add(&Space::Model, Common::default(), EntityKind::Insert(insert("PART"))).unwrap(); + let mut other = Drawing::new_imperial(); + other.handseed = 0x9000; + for b in blocks(theirs_end, theirs_base, theirs_sub_base, theirs_symbol_end, 0x8000) { + other.blocks.insert(b.name.clone(), Arc::new(b)); + } + // A second reference, so the other drawing has one that differs even when PART does not. + other.add(&Space::Model, Common::default(), EntityKind::Insert(Insert { insert: Vec3::new(9.0, 9.0, 0.0), ..insert("PART") })).unwrap(); + s.open_drawing(other, "Other", None); + s.execute("document.switch", &json!({"index": 0})).unwrap(); + s.execute("compare", &json!({"document": "Other"})).unwrap(); + let r = s.execute("compareexport", &json!({})).unwrap(); + let referenced: Vec = members(&s, r["compared"].as_str().unwrap()) + .iter() + .filter_map(|e| match &e.kind { + EntityKind::Insert(i) => Some(i.block.clone()), + _ => None, + }) + .collect(); + (s, referenced) + }; + // The other drawing's PART draws a longer line: it is copied as PART_2 and shown in red. + let (s, referenced) = run(5.0, 0.0, 0.0, 3.0); + assert_eq!(ends(&s, "PART"), vec![1.0], "this drawing's definition is kept"); + assert_eq!(ends(&s, "PART_2"), vec![5.0]); + assert_eq!(referenced, vec!["COMPARE_COMPARED_PART_2".to_string()]); + assert_eq!(ends(&s, "COMPARE_COMPARED_PART_2"), vec![5.0]); + let d = s.doc().unwrap(); + assert!(d.block("SUB_2").is_none() && d.block("SYMBOL_2").is_none(), "SUB and SYMBOL are the same in both drawings"); + // The blocks a copy of PART refers to. + let refers = |s: &Session| -> Vec { + s.doc() + .unwrap() + .block("PART_2") + .unwrap() + .entities + .iter() + .flat_map(|e| match &e.kind { + EntityKind::Insert(i) => vec![i.block.clone()], + EntityKind::MLeader(m) => m.block.iter().map(|b| b.block.clone()).collect(), + _ => Vec::new(), + }) + .collect() + }; + // Another base point on PART, another base point on the SUB inside it, or another SYMBOL + // in its multileader: PART is copied too, with the blocks that differ. + let (s, referenced) = run(1.0, 3.0, 0.0, 3.0); + assert_eq!(s.doc().unwrap().block("PART_2").map(|b| b.base.x), Some(3.0)); + assert_eq!(referenced, vec!["COMPARE_COMPARED_PART_2".to_string()]); + assert_eq!(refers(&s), ["SUB", "SYMBOL"]); + let (s, _) = run(1.0, 0.0, 4.0, 3.0); + assert_eq!(s.doc().unwrap().block("SUB_2").map(|b| b.base.x), Some(4.0)); + assert_eq!(refers(&s), ["SUB_2", "SYMBOL"]); + let (s, _) = run(1.0, 0.0, 0.0, 6.0); + assert_eq!(ends(&s, "SYMBOL_2"), vec![6.0]); + assert_eq!(refers(&s), ["SUB", "SYMBOL_2"]); + // The same definitions in both drawings: the red reference uses this drawing's PART. + let (s, referenced) = run(1.0, 0.0, 0.0, 3.0); + assert!(s.doc().unwrap().block("PART_2").is_none()); + assert_eq!(referenced, vec!["COMPARE_COMPARED_PART".to_string()]); + assert_eq!(ends(&s, "COMPARE_COMPARED_PART"), vec![1.0]); +} + +#[test] +fn a_named_block_whose_dimension_draws_another_block_shows_the_other_definition() { + use cadcraft_engine::doc::{DimKind, Dimension}; + // PART holds a dimension with the same parameters in both drawings; the block it draws, `*D1`, + // holds a line of another length in the other drawing. + let dim = Dimension { + kind: DimKind::Linear { rotation: 0.0 }, + defpt: Vec3::new(15.0, 5.0, 0.0), + text_mid: Vec3::new(7.0, 5.0, 0.0), + p13: Vec3::ZERO, + p14: Vec3::new(30.0, 0.0, 0.0), + p15: Vec3::ZERO, + p16: Vec3::ZERO, + text: String::new(), + style: "Standard".into(), + measurement: 30.0, + text_rotation: 0.0, + user_text_pos: false, + block: Some("*D1".into()), + overrides: Default::default(), + assoc: Vec::new(), + marks: Default::default(), + }; + let blocks = |dim_end: f64, first: u64| { + let mut part = Block::new("PART"); + part.entities.push(Entity::new(Handle(first), EntityKind::Dimension(dim.clone()))); + let mut drawn = Block::new("*D1"); + drawn.entities.push(Entity::new(Handle(first + 1), line(dim_end))); + [part, drawn] + }; + let mut s = Session::new(); + for b in blocks(1.0, 0x700) { + s.doc_mut().unwrap().blocks.insert(b.name.clone(), Arc::new(b)); + } + s.doc_mut().unwrap().add(&Space::Model, Common::default(), EntityKind::Insert(insert("PART"))).unwrap(); + let mut other = Drawing::new_imperial(); + other.handseed = 0x9000; + for b in blocks(5.0, 0x8000) { + other.blocks.insert(b.name.clone(), Arc::new(b)); + } + other.add(&Space::Model, Common::default(), EntityKind::Insert(Insert { insert: Vec3::new(9.0, 9.0, 0.0), ..insert("PART") })).unwrap(); + s.open_drawing(other, "Other", None); + s.execute("document.switch", &json!({"index": 0})).unwrap(); + s.execute("compare", &json!({"document": "Other"})).unwrap(); + s.execute("compareexport", &json!({})).unwrap(); + assert_eq!(ends(&s, "*D1"), vec![1.0], "this drawing's dimension block is kept"); + let d = s.doc().unwrap(); + let drawn: Vec> = d + .block("PART_2") + .map(|b| { + b.entities + .iter() + .filter_map(|e| match &e.kind { + EntityKind::Dimension(dm) => Some(dm.block.clone()), + _ => None, + }) + .collect() + }) + .unwrap_or_default(); + assert_eq!(drawn, [Some("*D2".to_string())], "the other drawing's PART is copied with its dimension block"); + assert_eq!(ends(&s, "*D2"), vec![5.0]); + // The colored copies of PART refer to colored copies of the dimension block, which are + // anonymous `*D` blocks of their own, not the originals. + let d = s.doc().unwrap(); + let copies: Vec = d + .blocks + .values() + .filter(|b| b.name.starts_with("COMPARE_")) + .flat_map(|b| { + b.entities + .iter() + .filter_map(|e| match &e.kind { + EntityKind::Dimension(dm) => Some(dm.block.clone().unwrap_or_default()), + _ => None, + }) + .collect::>() + }) + .collect(); + assert!(!copies.is_empty(), "PART has colored copies"); + for n in &copies { + assert!(n.starts_with("*D") && !["*D1", "*D2"].contains(&n.as_str()), "{n} in {copies:?}"); + assert!(d.block(n).is_some_and(|b| b.anonymous), "{n} is an anonymous block"); + } +} + +#[test] +fn a_named_block_whose_dimension_block_has_another_generated_name_keeps_this_drawings_definition() { + use cadcraft_engine::doc::{DimKind, Dimension}; + // PART holds the same dimension in both drawings; the block it draws holds the same line but + // is named `*D1` here and `*D3` in the other drawing. + let blocks = |drawn_name: &str, first: u64| { + let dim = Dimension { + kind: DimKind::Linear { rotation: 0.0 }, + defpt: Vec3::new(15.0, 5.0, 0.0), + text_mid: Vec3::new(7.0, 5.0, 0.0), + p13: Vec3::ZERO, + p14: Vec3::new(30.0, 0.0, 0.0), + p15: Vec3::ZERO, + p16: Vec3::ZERO, + text: String::new(), + style: "Standard".into(), + measurement: 30.0, + text_rotation: 0.0, + user_text_pos: false, + block: Some(drawn_name.into()), + overrides: Default::default(), + assoc: Vec::new(), + marks: Default::default(), + }; + let mut part = Block::new("PART"); + part.entities.push(Entity::new(Handle(first), EntityKind::Dimension(dim))); + let mut drawn = Block::new(drawn_name); + drawn.entities.push(Entity::new(Handle(first + 1), line(1.0))); + [part, drawn] + }; + let mut s = Session::new(); + for b in blocks("*D1", 0x700) { + s.doc_mut().unwrap().blocks.insert(b.name.clone(), Arc::new(b)); + } + s.doc_mut().unwrap().add(&Space::Model, Common::default(), EntityKind::Insert(insert("PART"))).unwrap(); + let mut other = Drawing::new_imperial(); + other.handseed = 0x9000; + for b in blocks("*D3", 0x8000) { + other.blocks.insert(b.name.clone(), Arc::new(b)); + } + other.add(&Space::Model, Common::default(), EntityKind::Insert(Insert { insert: Vec3::new(9.0, 9.0, 0.0), ..insert("PART") })).unwrap(); + s.open_drawing(other, "Other", None); + s.execute("document.switch", &json!({"index": 0})).unwrap(); + s.execute("compare", &json!({"document": "Other"})).unwrap(); + s.execute("compareexport", &json!({})).unwrap(); + assert!(s.doc().unwrap().block("PART_2").is_none(), "PART draws the same thing in both drawings"); +} + +#[test] +fn a_named_block_whose_line_is_hidden_or_thicker_in_the_other_drawing_shows_the_other_definition() { + let run = |theirs: Common| { + let part = |common: Common, first: u64| { + let mut part = Block::new("PART"); + let mut e = Entity::new(Handle(first), line(1.0)); + e.common = common; + part.entities.push(e); + part + }; + let mut s = Session::new(); + s.doc_mut().unwrap().blocks.insert("PART".into(), Arc::new(part(Common::default(), 0x700))); + s.doc_mut().unwrap().add(&Space::Model, Common::default(), EntityKind::Insert(insert("PART"))).unwrap(); + let mut other = Drawing::new_imperial(); + other.handseed = 0x9000; + other.blocks.insert("PART".into(), Arc::new(part(theirs, 0x8000))); + other.add(&Space::Model, Common::default(), EntityKind::Insert(Insert { insert: Vec3::new(9.0, 9.0, 0.0), ..insert("PART") })).unwrap(); + s.open_drawing(other, "Other", None); + s.execute("document.switch", &json!({"index": 0})).unwrap(); + s.execute("compare", &json!({"document": "Other"})).unwrap(); + s.execute("compareexport", &json!({})).unwrap(); + s.doc().unwrap().block("PART_2").is_some() + }; + assert!(run(Common { visible: false, ..Common::default() }), "a hidden line"); + assert!(run(Common { thickness: 2.0, ..Common::default() }), "a line with thickness"); + assert!(!run(Common::default()), "the same line"); +} + +#[test] +fn a_named_block_with_the_same_relative_image_path_from_another_folder_shows_the_other_definition() { + // PART holds an image at `a.png` in both drawings; this drawing is unsaved, the other one is + // a file in another folder, so its `a.png` is another file. + let part = |first: u64| { + let at = serde_json::to_value(Vec3::ZERO).unwrap(); + let image = serde_json::from_value(json!({"insert": at, "u": at, "v": at, "size": [1.0, 1.0], "path": "a.png"})).unwrap(); + let mut part = Block::new("PART"); + part.entities.push(Entity::new(Handle(first), EntityKind::Image(image))); + part + }; + let dir = std::env::temp_dir().join("compareexport-image-folder"); + let mut s = Session::new(); + s.doc_mut().unwrap().blocks.insert("PART".into(), Arc::new(part(0x700))); + s.doc_mut().unwrap().add(&Space::Model, Common::default(), EntityKind::Insert(insert("PART"))).unwrap(); + let mut other = Drawing::new_imperial(); + other.handseed = 0x9000; + other.blocks.insert("PART".into(), Arc::new(part(0x8000))); + other.add(&Space::Model, Common::default(), EntityKind::Insert(Insert { insert: Vec3::new(9.0, 9.0, 0.0), ..insert("PART") })).unwrap(); + s.open_drawing(other, "Other", Some(dir.join("other.dxf").to_string_lossy().to_string())); + s.execute("document.switch", &json!({"index": 0})).unwrap(); + s.execute("compare", &json!({"document": "Other"})).unwrap(); + s.execute("compareexport", &json!({})).unwrap(); + let d = s.doc().unwrap(); + let paths: Vec = d + .block("PART_2") + .map(|b| { + b.entities + .iter() + .filter_map(|e| match &e.kind { + EntityKind::Image(i) => Some(i.path.clone()), + _ => None, + }) + .collect() + }) + .unwrap_or_default(); + assert_eq!(paths.len(), 1, "the other drawing's PART is copied"); + assert_eq!(std::path::Path::new(&paths[0]).parent(), Some(std::path::absolute(&dir).unwrap().as_path())); +} + +#[test] +fn a_dimension_block_copy_keeps_its_geometry_when_the_snapshot_is_saved() { + use cadcraft_engine::doc::{DimKind, Dimension}; + // The dimension's block holds a line its parameters would not draw: a block read from a file. + let dim = Dimension { + kind: DimKind::Linear { rotation: 0.0 }, + defpt: Vec3::new(15.0, 5.0, 0.0), + text_mid: Vec3::new(7.0, 5.0, 0.0), + p13: Vec3::ZERO, + p14: Vec3::new(30.0, 0.0, 0.0), + p15: Vec3::ZERO, + p16: Vec3::ZERO, + text: String::new(), + style: "Standard".into(), + measurement: 30.0, + text_rotation: 0.0, + user_text_pos: false, + block: Some("*D1".into()), + overrides: Default::default(), + assoc: Vec::new(), + marks: Default::default(), + }; + let mut s = Session::new(); + let mut drawn = Block::new("*D1"); + drawn.entities.push(Entity::new(Handle(0x701), line(123.0))); + s.doc_mut().unwrap().blocks.insert("*D1".into(), Arc::new(drawn)); + s.doc_mut().unwrap().add(&Space::Model, Common::default(), EntityKind::Dimension(dim)).unwrap(); + s.open_drawing(Drawing::new_imperial(), "Other", None); + s.execute("document.switch", &json!({"index": 0})).unwrap(); + s.execute("compare", &json!({"document": "Other"})).unwrap(); + s.execute("compareexport", &json!({})).unwrap(); + let read = cadcraft_io::read_dxf(cadcraft_io::write_dxf(s.doc().unwrap()).as_bytes()).unwrap(); + let block = read + .model + .iter() + .find_map(|e| match &e.kind { + EntityKind::Dimension(dm) => dm.block.clone(), + _ => None, + }) + .unwrap(); + let ends: Vec = read + .block(&block) + .unwrap() + .entities + .iter() + .filter_map(|e| match &e.kind { + EntityKind::Line(l) => Some(l.b.x), + _ => None, + }) + .collect(); + assert_eq!(ends, [123.0], "the saved dimension block is the copy's"); +} + +#[test] +fn block_contents_take_the_comparison_color() { + let mut s = Session::new(); + let mut part = Block::new("PART"); + part.entities.push(Entity { handle: Handle(0x700), common: Common { color: Color::Index(1), ..Common::default() }, kind: line(1.0) }); + part.entities.push(Entity::new(Handle(0x701), EntityKind::Insert(insert("PART")))); + s.doc_mut().unwrap().blocks.insert("PART".into(), Arc::new(part)); + s.doc_mut().unwrap().add(&Space::Model, Common::default(), EntityKind::Insert(insert("PART"))).unwrap(); + let mut other = s.doc().unwrap().clone(); + other.model = Default::default(); + s.open_drawing(other, "Other", None); + s.execute("document.switch", &json!({"index": 0})).unwrap(); + s.execute("compare", &json!({"document": "Other"})).unwrap(); + let r = s.execute("compareexport", &json!({})).unwrap(); + let objs = members(&s, r["current"].as_str().unwrap()); + let EntityKind::Insert(i) = &objs[0].kind else { panic!("{r}") }; + assert_eq!(i.block, "COMPARE_CURRENT_PART"); + let d = s.doc().unwrap(); + let copy = d.block("COMPARE_CURRENT_PART").unwrap(); + assert!(copy.entities.iter().all(|e| e.common.color == GREEN), "the line inside is green too"); + // The block that refers to itself refers to its own copy, which ends the copying. + assert!(copy.entities.iter().any(|e| matches!(&e.kind, EntityKind::Insert(n) if n.block == "COMPARE_CURRENT_PART"))); + // This drawing's definition is left as it was. + assert_eq!(d.block("PART").unwrap().entities.iter().next().unwrap().common.color, Color::Index(1)); +} + +#[test] +fn colors_carried_apart_from_the_object_color_are_replaced() { + let mut s = Session::new(); + s.execute("mtext", &json!({"at": [0, 0], "height": 1, "width": 20, "text": "a{\\C1;red} \\c16711680;b \\\\C"})).unwrap(); + s.execute("dimlinear", &json!({"p1": [0, 0], "p2": [10, 0], "at": [5, 3]})).unwrap(); + s.execute("table", &json!({"at": [0, 20], "rows": 1, "cols": 1, "cells": [["\\C1;x"]]})).unwrap(); + s.execute("tolerance", &json!({"at": [0, 30], "symbol": "flatness", "tol1": "0.1"})).unwrap(); + s.execute("mline", &json!({"points": [[0, 40], [10, 40]]})).unwrap(); + let d = s.doc_mut().unwrap(); + d.dim_styles[0].dim_line_color = Color::Index(1); + d.dim_styles[0].text_color = Color::Index(5); + let mut data = d.table_styles[0].cell_style(CellStyle::DATA); + (data.text_color, data.fill, data.borders.top.color) = (Color::Index(1), Some(Color::Index(2)), Color::Index(3)); + d.table_styles[0].cell_styles.push(data); + let mline = d.model.iter().find(|e| matches!(e.kind, EntityKind::MLine(_))).unwrap().handle; + d.modify_entity(mline, |e| { + if let EntityKind::MLine(m) = &mut e.kind { + m.def.elements.iter_mut().for_each(|el| el.color = Color::Index(3)); + m.def.fill = Some(Color::Index(4)); + } + }) + .unwrap(); + let other = Drawing::new_imperial(); + s.open_drawing(other, "Other", None); + s.execute("document.switch", &json!({"index": 0})).unwrap(); + s.execute("compare", &json!({"document": "Other"})).unwrap(); + let r = s.execute("compareexport", &json!({})).unwrap(); + let mut kinds = Vec::new(); + let d = s.doc().unwrap(); + for e in members(&s, r["current"].as_str().unwrap()) { + match &e.kind { + EntityKind::MText(t) => assert_eq!(t.contents, "a{red} b \\\\C", "inline colors are taken out"), + EntityKind::Dimension(dm) => { + let st = d.dim_style(&dm.style).unwrap().with_overrides(&dm.overrides); + assert_eq!((st.dim_line_color, st.ext_line_color, st.text_color), (Color::ByBlock, Color::ByBlock, Color::ByBlock)); + } + EntityKind::Table(t) => { + assert_eq!(t.style, "COMPARE_CURRENT_Standard"); + assert_eq!(t.cells.iter().flatten().find(|c| !c.text.is_empty()).map(|c| c.text.as_str()), Some("x")); + let st = d.table_styles.iter().find(|s| s.name == t.style).unwrap(); + let data = st.cell_style(CellStyle::DATA); + assert_eq!((data.text_color, data.fill, data.borders.top.color), (Color::ByBlock, None, Color::ByBlock)); + } + EntityKind::Tolerance(t) => { + assert_eq!(t.style, "COMPARE_CURRENT_Standard"); + let st = d.dim_style(&t.style).unwrap(); + assert_eq!((st.dim_line_color, st.text_color), (Color::ByBlock, Color::ByBlock)); + } + EntityKind::MLine(m) => { + assert!(m.def.elements.iter().all(|el| el.color == Color::ByBlock)); + assert_eq!(m.def.fill, None); + } + _ => continue, + } + kinds.push(e.kind.type_name()); + } + assert_eq!(kinds.len(), 5, "{kinds:?}"); + // This drawing's styles are left as they were. + assert_eq!(d.table_styles[0].cell_style(CellStyle::DATA).text_color, Color::Index(1)); + assert_eq!(d.dim_style("Standard").unwrap().dim_line_color, Color::Index(1)); +} + +#[test] +fn inline_colors_of_multiline_attributes_are_replaced() { + use cadcraft_engine::doc::Attrib; + let attrib = |contents: &str| -> Attrib { + let at = serde_json::to_value(Vec3::ZERO).unwrap(); + serde_json::from_value(json!({ + "tag": "T", + "text": {"insert": at, "height": 1.0, "value": "x"}, + "multiline": {"mtext": {"insert": at, "height": 1.0, "contents": contents}, "readAs": "x"}, + })) + .unwrap() + }; + let mut s = Session::new(); + let mut part = Block::new("PART"); + part.entities.push(Entity::new(Handle(0x700), EntityKind::AttDef(attrib("\\C1;x")))); + s.doc_mut().unwrap().blocks.insert("PART".into(), Arc::new(part)); + let reference = Insert { attribs: vec![attrib("\\C3;x")], ..insert("PART") }; + s.doc_mut().unwrap().add(&Space::Model, Common::default(), EntityKind::Insert(reference)).unwrap(); + s.open_drawing(Drawing::new_imperial(), "Other", None); + s.execute("document.switch", &json!({"index": 0})).unwrap(); + s.execute("compare", &json!({"document": "Other"})).unwrap(); + let r = s.execute("compareexport", &json!({})).unwrap(); + let multiline = |a: &Attrib| a.multiline.as_ref().map(|m| m.mtext.contents.clone()); + let mut blocks = Vec::new(); + for e in members(&s, r["current"].as_str().unwrap()) { + if let EntityKind::Insert(i) = &e.kind { + assert_eq!(i.attribs.iter().map(multiline).collect::>(), [Some("x".to_string())], "the reference's attribute"); + blocks.push(i.block.clone()); + } + } + assert_eq!(blocks.len(), 1); + let d = s.doc().unwrap(); + let defs: Vec> = d + .block(&blocks[0]) + .unwrap() + .entities + .iter() + .filter_map(|e| match &e.kind { + EntityKind::AttDef(a) => Some(multiline(a)), + _ => None, + }) + .collect(); + assert_eq!(defs, [Some("x".to_string())], "the attribute definition in the recolored block"); +} + +#[test] +fn edits_after_compare_count_and_objects_keep_their_layers() { + let mut s = Session::new(); + s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap(); + let copy: Drawing = s.doc().unwrap().clone(); + s.open_drawing(copy, "Other", None); + s.execute("document.switch", &json!({"index": 0})).unwrap(); + s.execute("compare", &json!({"document": "Other"})).unwrap(); + // A line drawn after COMPARE ran is only in this drawing. + s.execute("line", &json!({"points": [[0, 5], [10, 5]]})).unwrap(); + let r = s.execute("compareexport", &json!({})).unwrap(); + let mut got = contents(&s, r["current"].as_str().unwrap()); + got.sort_by_key(|(_, c)| *c == GRAY); + assert_eq!(got, vec![("Line".into(), GREEN), ("Line".into(), GRAY)], "{r}"); + // The copies sit in model space on their own layer, not inside a block. + let d = s.doc().unwrap(); + assert!(d.model.iter().all(|e| e.common.layer == "0" && matches!(e.kind, EntityKind::Line(_)))); +} + +#[test] +fn copies_drop_associations_to_the_source_objects() { + let mut s = Session::new(); + s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap(); + let l = handle_of(&s, "Line"); + s.execute("dimlinear", &json!({"object": l, "at": [5, 3]})).unwrap(); + let assoc = |d: &Drawing| d.model.iter().find_map(|e| if let EntityKind::Dimension(dm) = &e.kind { Some(dm.assoc.len()) } else { None }); + assert!(assoc(s.doc().unwrap()).is_some_and(|n| n > 0), "the dimension follows the line"); + s.open_drawing(Drawing::new_imperial(), "Other", None); + s.execute("document.switch", &json!({"index": 0})).unwrap(); + s.execute("compare", &json!({"document": "Other"})).unwrap(); + s.execute("compareexport", &json!({})).unwrap(); + assert_eq!(assoc(s.doc().unwrap()), Some(0)); +} + +#[test] +fn relative_xref_paths_become_paths_from_the_source_folder() { + let folder = std::env::temp_dir().join("cadcraft-compareexport"); + let host = folder.join("host.dxf"); + let mut d = Drawing::new_imperial(); + let mut x = Block::new("X"); + x.xref_path = Some("parts/x.dwg".into()); + d.blocks.insert("X".into(), Arc::new(x)); + d.add(&Space::Model, Common::default(), EntityKind::Insert(insert("X"))).unwrap(); + let image = cadcraft_engine::doc::Image { path: "img/a.png".into(), ..Default::default() }; + d.add(&Space::Model, Common::default(), EntityKind::Image(image.clone())).unwrap(); + let mut b = Block::new("PIC"); + b.entities.push(Entity::new(Handle(0x900), EntityKind::Image(image))); + d.blocks.insert("PIC".into(), Arc::new(b)); + let mut s = Session::new(); + s.open_drawing(d, "host.dxf", Some(host.to_string_lossy().to_string())); + s.open_drawing(Drawing::new_imperial(), "Other", None); + s.execute("document.switch", &json!({"index": 1})).unwrap(); + s.execute("compare", &json!({"document": "Other"})).unwrap(); + s.execute("compareexport", &json!({})).unwrap(); + let d = s.doc().unwrap(); + let got = d.block("X").unwrap().xref_path.clone().unwrap(); + assert_eq!(std::path::Path::new(&got), folder.join("parts").join("x.dwg")); + // Images in model space and in blocks too. + let want = folder.join("img").join("a.png"); + let images = d.model.iter().chain(d.block("PIC").unwrap().entities.iter()).filter_map(|e| match &e.kind { + EntityKind::Image(i) => Some(i.path.clone()), + _ => None, + }); + let images: Vec = images.collect(); + assert_eq!(images.len(), 2); + assert!(images.iter().all(|p| std::path::Path::new(p) == want), "{images:?}"); +} + +#[test] +fn says_when_blocks_of_the_other_drawing_were_left_out() { + let mut other = Drawing::new_imperial(); + for i in 0..10_001 { + let name = format!("B{i}"); + let mut b = Block::new(&name); + b.entities.push(Entity::new(Handle(0x10_0000 + i), line(1.0))); + other.blocks.insert(name.clone(), Arc::new(b)); + other.add(&Space::Model, Common::default(), EntityKind::Insert(insert(&name))).unwrap(); + } + other.handseed = other.handseed.max(0x20_0000); + let mut s = Session::new(); + s.open_drawing(other, "Other", None); + s.execute("document.switch", &json!({"index": 0})).unwrap(); + s.execute("compare", &json!({"document": "Other"})).unwrap(); + let r = s.execute("compareexport", &json!({})).unwrap(); + assert_eq!(r["truncated"], true, "{}", r["message"]); + assert!(r["message"].as_str().unwrap().contains("some blocks of the other drawing were too many or too large to copy"), "{}", r["message"]); + // The same blocks in this drawing, compared with an empty one: past the limit of recolored + // copies some references keep their blocks' own colors. + let ours = s.docs[1].doc.as_ref().clone(); + let mut s = Session::new(); + s.open_drawing(ours, "Ours", None); + s.execute("compare", &json!({"document": 0})).unwrap(); + let r = s.execute("compareexport", &json!({})).unwrap(); + assert_eq!(r["truncated"], true, "{}", r["message"]); + assert!(r["message"].as_str().unwrap().contains("some blocks keep their own colors"), "{}", r["message"]); +} + +#[test] +fn says_when_blocks_both_drawings_define_differently_were_left_out() { + // Both drawings define B0 to B10000, each with a line of another length, so past the block + // limit the blocks left are ones this drawing defines differently. + let blocks = |end: f64, first: u64, d: &mut Drawing| { + for i in 0..10_001 { + let name = format!("B{i}"); + let mut b = Block::new(&name); + b.entities.push(Entity::new(Handle(first + i), line(end))); + d.blocks.insert(name, Arc::new(b)); + } + }; + let mut other = Drawing::new_imperial(); + blocks(1.0, 0x10_0000, &mut other); + for i in 0..10_001 { + other.add(&Space::Model, Common::default(), EntityKind::Insert(insert(&format!("B{i}")))).unwrap(); + } + other.handseed = other.handseed.max(0x20_0000); + let mut s = Session::new(); + let mut mine = s.doc().unwrap().clone(); + blocks(2.0, 0x30_0000, &mut mine); + mine.handseed = mine.handseed.max(0x40_0000); + s.open_drawing(mine, "Mine", None); + s.open_drawing(other, "Other", None); + s.execute("document.switch", &json!({"index": 1})).unwrap(); + s.execute("compare", &json!({"document": "Other"})).unwrap(); + let r = s.execute("compareexport", &json!({})).unwrap(); + assert!(r["message"].as_str().unwrap().contains("some blocks of the other drawing were too many or too large to copy"), "{}", r["message"]); +} + +#[test] +fn a_dimension_whose_block_is_past_the_nesting_limit_is_drawn_again() { + use cadcraft_engine::doc::{DimKind, Dimension, MAX_BLOCK_DEPTH}; + // B1 holds B2, …, and the innermost block holds a dimension: its `*D1` copy would be past + // the nesting limit. + let mut s = Session::new(); + let d = s.doc_mut().unwrap(); + let mut drawn = Block::new("*D1"); + drawn.entities.push(Entity::new(Handle(0x600), line(30.0))); + d.blocks.insert("*D1".into(), Arc::new(drawn)); + let dim = Dimension { + kind: DimKind::Linear { rotation: 0.0 }, + defpt: Vec3::new(15.0, 5.0, 0.0), + text_mid: Vec3::new(7.0, 5.0, 0.0), + p13: Vec3::ZERO, + p14: Vec3::new(30.0, 0.0, 0.0), + p15: Vec3::ZERO, + p16: Vec3::ZERO, + text: String::new(), + style: "Standard".into(), + measurement: 30.0, + text_rotation: 0.0, + user_text_pos: false, + block: Some("*D1".into()), + overrides: Default::default(), + assoc: Vec::new(), + marks: Default::default(), + }; + for k in 1..=MAX_BLOCK_DEPTH { + let mut b = Block::new(&format!("B{k}")); + let kind = if k == MAX_BLOCK_DEPTH { EntityKind::Dimension(dim.clone()) } else { EntityKind::Insert(insert(&format!("B{}", k + 1))) }; + b.entities.push(Entity::new(Handle(0x700 + k as u64), kind)); + d.blocks.insert(b.name.clone(), Arc::new(b)); + } + d.add(&Space::Model, Common::default(), EntityKind::Insert(insert("B1"))).unwrap(); + s.open_drawing(Drawing::new_imperial(), "Other", None); + s.execute("document.switch", &json!({"index": 0})).unwrap(); + s.execute("compare", &json!({"document": "Other"})).unwrap(); + s.execute("compareexport", &json!({})).unwrap(); + let d = s.doc().unwrap(); + let dims: Vec> = d + .blocks + .values() + .filter(|b| b.name.starts_with("COMPARE_")) + .flat_map(|b| { + b.entities + .iter() + .filter_map(|e| match &e.kind { + EntityKind::Dimension(dm) => Some(dm.block.clone()), + _ => None, + }) + .collect::>() + }) + .collect(); + // The colored copy keeps no block, so it is drawn in the comparison color, not with `*D1`'s. + assert_eq!(dims, [None], "{:?}", d.blocks.keys().collect::>()); +} + +#[test] +fn needs_a_comparison_of_the_current_drawing() { + let mut s = Session::new(); + assert!(s.execute("compareexport", &json!({})).is_err(), "no comparison yet"); + let mut s = compared(); + s.execute("document.switch", &json!({"index": 0})).unwrap(); + assert!(s.execute("compareexport", &json!({})).is_err(), "the comparison belongs to the other drawing"); + s.execute("document.switch", &json!({"index": 1})).unwrap(); + s.execute("compareclose", &json!({})).unwrap(); + assert!(s.execute("compareexport", &json!({})).is_err(), "closed"); + // Typed at the command line it runs too. + let mut s = compared(); + let before = s.docs.len(); + s.cmdline("compareexport").unwrap(); + assert_eq!(s.docs.len(), before + 1); +} diff --git a/crates/engine/tests/it/dim_vars.rs b/crates/engine/tests/it/dim_vars.rs index 018485eea..a2875ee07 100644 --- a/crates/engine/tests/it/dim_vars.rs +++ b/crates/engine/tests/it/dim_vars.rs @@ -100,6 +100,34 @@ fn new_variables_are_style_overrides_and_read_back() { assert_eq!(st.dim_linetype, "DASHED"); } +#[test] +fn text_fill_variables_are_style_overrides() { + let mut s = Session::new(); + set(&mut s, "DIMTFILL", json!(2)); + set(&mut s, "DIMTFILLCLR", json!(3)); + let o = s.doc().unwrap().dim_overrides(); + assert_eq!(o.get("textFill"), Some(&json!(2))); + linear(&mut s, 10.0); + let (dm, g) = last_dim(&s); + let st = DimStyle::default().with_overrides(&dm.overrides); + assert_eq!((st.text_fill, st.text_fill_color), (2, cadcraft_engine::doc::color::Color::Index(3))); + assert_eq!(g.text_fill.len(), 4, "the new dimension has a text background"); + // DIMTFILL takes 0, 1 or 2. + let mut st = DimStyle::default(); + assert_eq!(st.apply_fields(&serde_json::Map::from_iter([("DIMTFILL".to_string(), json!(3))])).len(), 1); + // EXPLODE keeps the background as a SOLID in DIMTFILLCLR, before the text. + let h = s.doc().unwrap().model.last().unwrap().handle.hex(); + s.execute("explode", &json!({ "handles": [h] })).unwrap(); + let d = s.doc().unwrap(); + let solid = d.model.iter().position(|e| matches!(e.kind, EntityKind::Solid(_)) && e.common.color == cadcraft_engine::doc::color::Color::Index(3)); + let mtext = d.model.iter().position(|e| matches!(e.kind, EntityKind::MText(_))); + assert!(solid.is_some() && solid < mtext, "{solid:?}, {mtext:?}"); + // The arrowheads (the other SOLIDs) come before the background, as the dimension draws them. + let arrows: Vec = + d.model.iter().enumerate().filter(|(i, e)| matches!(e.kind, EntityKind::Solid(_)) && Some(*i) != solid).map(|(i, _)| i).collect(); + assert!(!arrows.is_empty() && arrows.iter().all(|i| Some(*i) < solid), "{arrows:?} before {solid:?}"); +} + #[test] fn fit_moves_text_or_arrows_outside() { // 0.5 between the extension lines: the text "0.5000" doesn't fit, the arrows (2 × 0.18) do. @@ -196,6 +224,37 @@ fn text_move_settings() { assert!((dm.defpt.y - (12.0 - 0.18)).abs() < 1e-9, "{:?}", dm.defpt); } +#[test] +fn setvar_refuses_the_values_the_style_refuses() { + let mut s = Session::new(); + set(&mut s, "DIMTMOVE", json!(1)); + let get = |s: &mut Session, name: &str| s.execute("getvar", &json!({ "name": name })).unwrap()["value"].clone(); + for (name, v) in [ + ("DIMTMOVE", json!(3)), + ("DIMATFIT", json!(7)), + ("DIMLWD", json!(500)), + ("DIMJOGANG", json!(120)), + ("DIMFXL", json!(-1)), + ("DIMTXT", json!(0)), + ("DIMDEC", json!(300)), + ("DIMCLRT", json!("no such color")), + ] { + let before = get(&mut s, name); + assert!(s.execute("setvar", &json!({ "name": name, "value": v })).is_err(), "{name} {v}"); + assert_eq!(get(&mut s, name), before, "{name} keeps its value"); + // DIMSTYLE and DIMOVERRIDE refuse the same value. + let mut st = DimStyle::default(); + assert_eq!(st.apply_fields(&serde_json::Map::from_iter([(name.to_string(), v)])).len(), 1, "{name}"); + } + assert_eq!(s.doc().unwrap().dim_overrides().get("textMove"), Some(&json!(1))); + // Values in range still go in, typed as text too. + set(&mut s, "DIMTMOVE", json!("2")); + set(&mut s, "DIMCLRT", json!(3)); + let o = s.doc().unwrap().dim_overrides(); + assert_eq!(o.get("textMove"), Some(&json!(2))); + assert_eq!(o.get("textColor"), serde_json::to_value(cadcraft_engine::doc::color::Color::Index(3)).ok().as_ref(), "{o:?}"); +} + #[test] fn tolerance_justification_angular_zeros_and_arc_symbol() { let mut s = Session::new(); @@ -262,6 +321,69 @@ fn linetypes_and_lineweights_of_dimension_and_extension_lines() { assert!(lws.contains(&0.5) && lws.contains(&0.13), "{lws:?}"); } +fn ignored(r: &Value) -> Vec<&str> { + r["ignored"].as_array().map(|a| a.iter().filter_map(Value::as_str).collect()).unwrap_or_default() +} + +#[test] +fn dimstyle_and_dimoverride_refuse_negative_whole_numbers() { + let mut s = Session::new(); + s.execute("dimstyle", &json!({ "name": "Standard", "DIMTMOVE": 2, "DIMLWD": 50 })).unwrap(); + let r = s + .execute( + "dimstyle", + &json!({ + "name": "Standard", + "DIMDEC": -1, + "DIMTMOVE": -1, + "DIMLUNIT": "-1", + "DIMTDEC": -0.6, + "DIMADEC": -2, + "DIMALTD": "300", + "DIMLWE": "300", + }), + ) + .unwrap(); + let ign = ignored(&r); + for k in ["DIMDEC", "DIMTMOVE", "DIMLUNIT", "DIMTDEC", "DIMADEC", "DIMALTD", "DIMLWE"] { + assert!(ign.contains(&k), "{k}: {r}"); + } + let st = s.doc().unwrap().dim_style("Standard").unwrap().clone(); + let def = DimStyle::default(); + assert_eq!((st.decimals, st.text_move, st.linear_unit, st.tol_decimals), (def.decimals, 2, def.linear_unit, def.tol_decimals)); + assert_eq!((st.angular_decimals, st.alt_decimals, st.ext_lineweight), (def.angular_decimals, def.alt_decimals, def.ext_lineweight)); + // DIMADEC -1 is a documented value (stored as 0); ByLayer lineweights stay -1. + let r = s.execute("dimstyle", &json!({ "name": "Standard", "DIMADEC": -1, "DIMLWD": "ByLayer" })).unwrap(); + assert!(r.get("ignored").is_none(), "{r}"); + assert_eq!(s.doc().unwrap().dim_style("Standard").unwrap().dim_lineweight, -1); + + // DIMOVERRIDE keeps the valid value and leaves the negative one out. + linear(&mut s, 10.0); + let h = s.doc().unwrap().model.last().unwrap().handle.hex(); + s.execute("dimoverride", &json!({ "handles": [h], "DIMDEC": -1, "DIMTMOVE": 1 })).unwrap(); + let (dm, _) = last_dim(&s); + assert!(!dm.overrides.contains_key("decimals"), "{:?}", dm.overrides); + assert_eq!(dm.overrides.get("textMove"), Some(&json!(1))); + assert!(s.execute("dimoverride", &json!({ "handles": [h], "DIMZIN": -3 })).is_err()); +} + +#[test] +fn setvar_negative_whole_numbers_do_not_reach_new_dimensions() { + let mut s = Session::new(); + for (name, v) in [("DIMDEC", json!(-1)), ("DIMLUNIT", json!("-1")), ("DIMTDEC", json!(-0.6))] { + // The header may hold the value; new dimensions keep the style's own. + let _ = s.execute("setvar", &json!({ "name": name, "value": v })); + } + let o = s.doc().unwrap().dim_overrides(); + for f in ["decimals", "linearUnit", "tolDecimals"] { + assert!(!o.contains_key(f), "{f}: {o:?}"); + } + linear(&mut s, 10.0); + let (dm, g) = last_dim(&s); + assert_eq!(g.value, "10.0000"); + assert!(dm.overrides.is_empty(), "{:?}", dm.overrides); +} + #[test] fn leaders_use_the_leader_arrow() { let mut s = Session::new(); diff --git a/crates/engine/tests/it/fix_holds_grips.rs b/crates/engine/tests/it/fix_holds_grips.rs index 9a537964c..515f2c1ef 100644 --- a/crates/engine/tests/it/fix_holds_grips.rs +++ b/crates/engine/tests/it/fix_holds_grips.rs @@ -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:?}"); +} diff --git a/crates/engine/tests/it/inquiry_keywords.rs b/crates/engine/tests/it/inquiry_keywords.rs index 920dd7ee5..5a2c4cb6e 100644 --- a/crates/engine/tests/it/inquiry_keywords.rs +++ b/crates/engine/tests/it/inquiry_keywords.rs @@ -1,4 +1,5 @@ -//! AREA's Object / Add area / Subtract area and DIST's Multiple points, typed as at the command line. +//! AREA's Object / Add area / Subtract area, DIST's Multiple points and MEASUREGEOM's Angle and +//! Volume, typed as at the command line. use cadcraft_engine::Session; use serde_json::json; @@ -74,6 +75,56 @@ fn area_of_an_open_elliptical_arc_is_refused_and_a_full_ellipse_is_measured() { assert!(!has(&log, "Area = "), "{log:?}"); } +#[test] +fn tools_inquiry_radius_asks_for_the_object() { + // Nothing selected: the menu item asks for the arc or circle. + let mut s = Session::new(); + s.cmdline("circle 0,0 2").unwrap(); + s.start("radius.measure").unwrap(); + assert!(s.prompt_text().contains("Select arc or circle"), "{}", s.prompt_text()); + s.cmdline("2,0").unwrap(); + assert!(has(&s.log, "Radius = 2.0000"), "{:?}", s.log); + assert!(has(&s.log, "Diameter = 4.0000"), "{:?}", s.log); + assert!(s.running.is_none(), "{}", s.prompt_text()); + + // A selected arc is measured at once. + let mut s = Session::new(); + let r = s.execute("arc", &json!({"center": [0, 0], "radius": 3, "start": 0, "end": 90})).unwrap(); + s.set_selection(vec![cadcraft_doc::Handle::parse_hex(r["handle"].as_str().unwrap()).unwrap()]); + s.start("radius.measure").unwrap(); + assert!(has(&s.log, "Radius = 3.0000"), "{:?}", s.log); + assert!(s.running.is_none(), "{}", s.prompt_text()); +} + +#[test] +fn measuregeom_radius_measures_a_polyline_arc_segment() { + // A line from 0,0 to 10,0, then a half circle of radius 5 to 10,10 through 15,5. + let pline = json!({"vertices": [[0, 0], {"p": [10, 0], "bulge": 1}, [10, 10]]}); + let mut s = Session::new(); + s.execute("pline", &pline).unwrap(); + s.cmdline("measuregeom").unwrap(); + s.cmdline("r").unwrap(); + // A pick on the line segment keeps the prompt. + s.cmdline("5,0").unwrap(); + assert!(has(&s.log, "Select an arc, circle or polyline arc."), "{:?}", s.log); + assert!(s.running.is_some()); + s.cmdline("15,5").unwrap(); + assert!(has(&s.log, "Radius = 5.0000"), "{:?}", s.log); + assert!(has(&s.log, "Diameter = 10.0000"), "{:?}", s.log); + assert!(s.running.is_none(), "{}", s.prompt_text()); + + // The same pick from Tools > Inquiry > Radius, and the JSON form with the point on the arc. + let mut s = Session::new(); + let h = s.execute("pline", &pline).unwrap()["handle"].clone(); + s.start("radius.measure").unwrap(); + s.cmdline("15,5").unwrap(); + assert!(has(&s.log, "Radius = 5.0000"), "{:?}", s.log); + let r = s.execute("radius.measure", &json!({"handle": h, "point": [15, 5]})).unwrap(); + assert_eq!(r["radius"].as_f64(), Some(5.0), "{r}"); + assert!(s.execute("radius.measure", &json!({"handle": h, "point": [5, 0]})).is_err()); + assert!(s.execute("radius.measure", &json!({"handle": h})).is_err()); +} + #[test] fn measuregeom_typed_asks_for_the_option_then_the_points() { // Distance is the default option (#1103: typed, it used to fail asking for `p1`). @@ -88,6 +139,63 @@ fn measuregeom_typed_asks_for_the_option_then_the_points() { assert!(has(&log, "Area = 6.0000"), "{log:?}"); } +#[test] +fn measuregeom_angle_measures_an_arc_a_circle_two_lines_and_a_vertex() { + let typed = |s: &mut Session, lines: &[&str]| { + for l in lines { + s.cmdline(l).unwrap(); + } + assert!(s.running.is_none(), "{}", s.prompt_text()); + s.log.clone() + }; + + // Arc: the angle between its endpoints about its center. + let mut s = Session::new(); + s.execute("arc", &json!({"center": [0, 0], "radius": 2, "start": 0, "end": 270})).unwrap(); + assert!(has(&typed(&mut s, &["measuregeom", "a", "-2,0"]), "Angle = 270.0000°")); + + // Circle: the pick on the circle and a second point, about the center. + let log = typed(&mut Session::new(), &["circle 0,0 2", "measuregeom", "a", "2,0", "0,5"]); + assert!(has(&log, "Angle = 90.0000°"), "{log:?}"); + + // Two lines that do not meet, and two segments of one polyline. + let mut s = Session::new(); + s.execute("line", &json!({"points": [[1, 0], [10, 0]]})).unwrap(); + s.execute("line", &json!({"points": [[0, 1], [5, 6]]})).unwrap(); + assert!(has(&typed(&mut s, &["measuregeom", "a", "5,0", "2,3"]), "Angle = 45.0000°"), "{:?}", s.log); + let mut s = Session::new(); + s.execute("pline", &json!({"vertices": [[0, 0], [10, 0], [10, 10]]})).unwrap(); + assert!(has(&typed(&mut s, &["measuregeom", "a", "5,0", "10,5"]), "Angle = 90.0000°"), "{:?}", s.log); + let mut s = Session::new(); + s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap(); + s.execute("line", &json!({"points": [[0, 5], [10, 5]]})).unwrap(); + assert!(has(&typed(&mut s, &["measuregeom", "a", "5,0", "5,5"]), "The lines are parallel."), "{:?}", s.log); + + // Enter: a vertex and two endpoints. + let log = typed(&mut Session::new(), &["measuregeom", "a", "", "0,0", "1,0", "-1,1"]); + assert!(has(&log, "Angle = 135.0000°"), "{log:?}"); + + // Empty space is not an object, and the prompt stays. + let (s, log) = run(&["measuregeom", "a", "50,50"]); + assert!(has(&log, "*Invalid selection*"), "{log:?}"); + assert!(s.prompt_text().contains("Select arc, circle, line"), "{}", s.prompt_text()); +} + +#[test] +fn tools_inquiry_angle_asks_for_the_objects() { + let spec = cadcraft_engine::find_command("angle.measure").expect("no command angle.measure"); + assert_eq!(spec.menu, ["Tools", "Inquiry", "Angle"]); + let mut s = Session::new(); + s.cmdline("circle 0,0 2").unwrap(); + s.start("angle.measure").unwrap(); + assert!(s.prompt_text().contains("Select arc, circle, line"), "{}", s.prompt_text()); + for l in ["0,2", "-3,0"] { + s.cmdline(l).unwrap(); + } + assert!(has(&s.log, "Angle = 90.0000°"), "{:?}", s.log); + assert!(s.running.is_none(), "{}", s.prompt_text()); +} + #[test] fn area_of_an_ellipse_reports_its_exact_perimeter() { // References from the arithmetic-geometric mean (Gauss–Kummer), to 12 decimals. A closed-form @@ -103,3 +211,104 @@ fn area_of_an_ellipse_reports_its_exact_perimeter() { let (_, log) = run(&["ellipse c 0,0 10,0 4", "area", "o", "10,0"]); assert!(has(&log, "Perimeter = 46.0262"), "{log:?}"); } + +#[test] +fn measuregeom_volume_multiplies_an_area_by_a_height() { + // Points: a 4 x 3 triangle's area is 6, times a height of 10. + let (s, log) = run(&["measuregeom", "v", "0,0", "4,0", "4,3", "", "10"]); + assert!(has(&log, "Volume = 60.0000"), "{log:?}"); + assert!(s.running.is_none(), "{}", s.prompt_text()); + + // Object (the default): a 10 x 10 square polyline, height 2. + let (s, log) = run(&["rectang 0,0 10,10", "measuregeom", "v", "", "10,5", "2"]); + assert!(has(&log, "Volume = 200.0000"), "{log:?}"); + assert!(s.running.is_none(), "{}", s.prompt_text()); + + // A height that is not a distance is asked for again. + let (s, log) = run(&["measuregeom", "v", "0,0", "4,0", "4,3", "", "tall"]); + assert!(has(&log, "Requires a distance."), "{log:?}"); + assert!(s.prompt_text().contains("Specify height"), "{}", s.prompt_text()); + + // Enter is not a height; two points give the height as their distance. + let (mut s, log) = run(&["measuregeom", "v", "0,0", "4,0", "4,3", "", "", "1,1"]); + assert!(has(&log, "Requires a height."), "{log:?}"); + assert!(s.prompt_text().contains("Specify second point"), "{}", s.prompt_text()); + s.cmdline("7,9").unwrap(); + assert!(has(&s.log, "Volume = 60.0000"), "{:?}", s.log); + assert!(s.running.is_none(), "{}", s.prompt_text()); + + // The JSON forms, by points and by handle. + let mut s = Session::new(); + let r = s.execute("measuregeom", &json!({"mode": "volume", "points": [[0, 0], [4, 0], [4, 3]], "height": 10})).unwrap(); + assert_eq!(r["volume"], json!(60.0)); + let c = s.execute("circle", &json!({"center": [0, 0], "radius": 1})).unwrap(); + let r = s.execute("volume.measure", &json!({"handle": c["handle"], "height": -3})).unwrap(); + assert!((r["volume"].as_f64().unwrap() - 3.0 * std::f64::consts::PI).abs() < 1e-12, "{r}"); + assert!(s.execute("volume.measure", &json!({"points": [[0, 0], [1, 0]], "height": 1})).is_err()); + assert!(s.execute("volume.measure", &json!({"points": [[0, 0], [1, 0], [1, 1]]})).is_err()); +} + +#[test] +fn measure_with_points_that_are_not_points_is_an_error_not_the_selection() { + let mut s = Session::new(); + let c = s.execute("circle", &json!({"center": [0, 0], "radius": 1})).unwrap(); + s.set_selection(vec![cadcraft_engine::doc::Handle::parse_hex(c["handle"].as_str().unwrap()).unwrap()]); + for bad in [json!([[0, 0], [4, 0], "north"]), json!([[0, 0], [4, 0], [4]]), json!(7)] { + assert!(s.execute("volume.measure", &json!({"points": bad, "height": 10})).is_err(), "{bad}"); + assert!(s.execute("area", &json!({"points": bad})).is_err(), "{bad}"); + } + // Without `points`, both measure the selected circle. + let r = s.execute("volume.measure", &json!({"height": 1})).unwrap(); + assert!((r["volume"].as_f64().unwrap() - std::f64::consts::PI).abs() < 1e-12, "{r}"); + let r = s.execute("area", &json!({"points": null})).unwrap(); + assert!((r["area"].as_f64().unwrap() - std::f64::consts::PI).abs() < 1e-12, "{r}"); +} + +#[test] +fn measuregeom_volume_too_large_to_hold_is_refused() { + // A 2e150 square has an area of 4e300; times 1e10 it is past f64's range. + let p = json!([[0, 0], [2e150, 0], [2e150, 2e150], [0, 2e150]]); + let mut s = Session::new(); + assert!(s.execute("volume.measure", &json!({"points": p, "height": 1e10})).is_err()); + assert!(s.execute("volume.measure", &json!({"points": p, "height": 1})).is_ok()); + + // Typed, the height is asked for again. + let (s, log) = run(&["measuregeom", "v", "0,0", "2e150,0", "2e150,2e150", "0,2e150", "", "1e10"]); + assert!(has(&log, "The volume is too large."), "{log:?}"); + assert!(!has(&log, "Volume = "), "{log:?}"); + assert!(s.prompt_text().contains("Specify height"), "{}", s.prompt_text()); + + // Two volumes that each fit can overflow the running total; the second is asked for again. + let sq = ["0,0", "1,0", "1,1", "0,1", ""]; + let mut lines = vec!["measuregeom", "v", "a"]; + lines.extend(sq); + lines.push("1e308"); + lines.extend(sq); + lines.push("1e308"); + let (s, log) = run(&lines); + assert!(has(&log, "The volume is too large."), "{log:?}"); + assert!(!has(&log, "Total volume = 0"), "{log:?}"); + assert!(s.prompt_text().contains("Specify height"), "{}", s.prompt_text()); +} + +#[test] +fn tools_inquiry_volume_adds_and_subtracts_a_running_total() { + let spec = cadcraft_engine::find_command("volume.measure").expect("no command volume.measure"); + assert_eq!(spec.menu, ["Tools", "Inquiry", "Volume"]); + + // Add a 10 x 10 square by points at height 3, subtract a 2 x 2 square object at height 5. + let mut s = Session::new(); + s.cmdline("rectang 20,0 22,2").unwrap(); + s.start("volume.measure").unwrap(); + for l in ["a", "0,0", "10,0", "10,10", "0,10", "", "3", "s", "o", "21,0", "5", "", ""] { + s.cmdline(l).unwrap(); + } + assert!(has(&s.log, "Total volume = 300.0000"), "{:?}", s.log); + assert!(has(&s.log, "Total volume = 280.0000"), "{:?}", s.log); + assert!(s.running.is_none(), "{}", s.prompt_text()); + + // Architectural units show the volume as a number, not as a feet-and-inches length. + let (_, log) = run(&["setvar lunits 4", "measuregeom", "v", "0,0", "10,0", "10,10", "0,10", "", "12"]); + assert!(has(&log, "Volume = 1200"), "{log:?}"); + assert!(!has(&log, "'-"), "{log:?}"); +} diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index 3edc0db95..8babc2c49 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -12,10 +12,12 @@ mod attrib_props; mod attribute_tools; mod block_keep; mod block_redefine; +mod blockreplace; mod break_closed_polyline; mod break_join_locked_layers; mod break_keeps_z; mod burst; +mod compare_export; mod constraint_geometry; mod constraint_infer; mod count; diff --git a/crates/engine/tests/it/setvar_cecolor.rs b/crates/engine/tests/it/setvar_cecolor.rs index a8347ddcb..e37ac2966 100644 --- a/crates/engine/tests/it/setvar_cecolor.rs +++ b/crates/engine/tests/it/setvar_cecolor.rs @@ -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")); } diff --git a/crates/engine/tests/plotters.rs b/crates/engine/tests/plotters.rs index 43268d38a..81aea1221 100644 --- a/crates/engine/tests/plotters.rs +++ b/crates/engine/tests/plotters.rs @@ -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, 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 { 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:?}"); +} diff --git a/crates/engine/tests/stretch_window_ortho.rs b/crates/engine/tests/stretch_window_ortho.rs new file mode 100644 index 000000000..70a7fdbfa --- /dev/null +++ b/crates/engine/tests/stretch_window_ortho.rs @@ -0,0 +1,79 @@ +//! STRETCH's crossing window ignores ortho: the opposite corner is where the cursor is, so the +//! window keeps its area and catches what it crosses. The same holds for every rectangle picked by +//! two corners (PLOTWINDOW, MVIEW, -VIEW Window, REVCLOUD Rectangular). + +use cadcraft_engine::snap::tracking::CursorQuery; +use cadcraft_engine::{Input, Session}; +use cadcraft_geom::Vec2; +use serde_json::json; + +/// Move the cursor to `raw` at the current prompt, as the canvas does. +fn hover(s: &mut Session, raw: Vec2) -> Vec2 { + let p = s.current_prompt().unwrap(); + let q = CursorQuery { base: p.base, deferred: p.deferred, corner: p.corner, aperture: 1e-6, tol: 1e-6, now: 0.0, shift: false }; + let c = s.snap_cursor(raw, &q).point; + s.cursor = c; + c +} + +/// A session with ortho on, object snaps off and a line from 0,0 to 10,0. +fn ortho_session() -> Session { + let mut s = Session::new(); + s.execute("setvar", &json!({ "name": "OSMODE", "value": 0 })).unwrap(); + s.execute("setvar", &json!({ "name": "ORTHOMODE", "value": 1 })).unwrap(); + s.execute("line", &json!({ "points": [[0, 0], [10, 0]] })).unwrap(); + s +} + +/// Pick the window's opposite corner at `to`, stretch by 5,0 and return the extents. +fn stretch_from_corner(mut s: Session, to: Vec2) -> (Vec2, Vec2) { + assert_eq!(s.current_prompt().unwrap().message, "Specify opposite corner"); + let c = hover(&mut s, to); + assert!(c.near(to, 1e-9), "the corner is where the cursor is: {c:?}"); + s.input(Input::Point(c)).unwrap(); + s.input(Input::Enter).unwrap(); + for t in ["0,0", "5,0"] { + s.cmdline(t).unwrap(); + } + assert!(s.current_prompt().is_none(), "STRETCH is done, still at {:?}", s.current_prompt().map(|p| p.message)); + let b = s.doc().unwrap().extents(&s.space()); + (b.min, b.max) +} + +#[test] +fn stretch_implied_window_ignores_ortho() { + let mut s = ortho_session(); + s.cmdline("stretch").unwrap(); + // Right to left: a crossing window. + s.input(Input::Point(Vec2::new(12.0, 1.0))).unwrap(); + assert_eq!(stretch_from_corner(s, Vec2::new(8.0, -1.0)), (Vec2::ZERO, Vec2::new(15.0, 0.0)), "the line's end moved by 5"); +} + +#[test] +fn stretch_crossing_mode_ignores_ortho() { + let mut s = ortho_session(); + for t in ["stretch", "c", "8,-1"] { + s.cmdline(t).unwrap(); + } + assert_eq!(stretch_from_corner(s, Vec2::new(12.0, 1.0)), (Vec2::ZERO, Vec2::new(15.0, 0.0)), "the line's end moved by 5"); +} + +#[test] +fn rectangle_corner_prompts_ignore_ortho() { + for script in [&["plotwindow", "0,0"][..], &["mview", "0,0"], &["-view", "w", "v1", "0,0"], &["revcloud", "r", "0,0"]] { + let mut s = Session::new(); + s.execute("setvar", &json!({ "name": "OSMODE", "value": 0 })).unwrap(); + s.execute("setvar", &json!({ "name": "ORTHOMODE", "value": 1 })).unwrap(); + if script[0] == "mview" { + s.cmdline("layout s Layout1").unwrap(); + } + for t in script { + s.cmdline(t).unwrap(); + } + let p = s.current_prompt().unwrap(); + assert_eq!(p.message, "Specify opposite corner", "{script:?}"); + assert!(p.corner, "{script:?}: the opposite corner ignores ortho"); + let c = hover(&mut s, Vec2::new(10.0, 4.0)); + assert!(c.near(Vec2::new(10.0, 4.0), 1e-9), "{script:?}: the corner is where the cursor is: {c:?}"); + } +} diff --git a/crates/engine/tests/table_malformed_handle.rs b/crates/engine/tests/table_malformed_handle.rs new file mode 100644 index 000000000..849557494 --- /dev/null +++ b/crates/engine/tests/table_malformed_handle.rs @@ -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"); +} diff --git a/crates/fonts/src/complex.rs b/crates/fonts/src/complex.rs index dafabdc7c..a8dd367c3 100644 --- a/crates/fonts/src/complex.rs +++ b/crates/fonts/src/complex.rs @@ -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, rtl: bool) -> Vec { let run = cs.get(r.clone()).unwrap_or_default(); - // Shares the face's bytes instead of copying them. - let bytes: Arc + 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, rtl: bool) -> Vec } 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, rtl: bool) -> Vec 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 diff --git a/crates/geom/src/curve.rs b/crates/geom/src/curve.rs index 72fe3ac0c..2b799e433 100644 --- a/crates/geom/src/curve.rs +++ b/crates/geom/src/curve.rs @@ -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 { diff --git a/crates/geom/src/fillet.rs b/crates/geom/src/fillet.rs index d31757f79..78e1fc93b 100644 --- a/crates/geom/src/fillet.rs +++ b/crates/geom/src/fillet.rs @@ -236,6 +236,46 @@ pub fn tangent_circles(c1: &TanCurve, c2: &TanCurve, r: f64, near: &[Vec2]) -> V if !(r.is_finite() && r >= 0.0) { return Vec::new(); } + let Some(win) = search_window(c1, c2, near) else { return Vec::new() }; + let size = win.width().max(win.height()).max(r).max(1e-9); + let win = win.expand(2.0 * r + 0.25 * size); + let tol = 1e-9 * size.max(1.0); + let (Some(r1), Some(r2)) = (c1.range(&win), c2.range(&win)) else { return Vec::new() }; + let signs: &[(f64, f64)] = if r > 0.0 { &[(1.0, 1.0), (1.0, -1.0), (-1.0, 1.0), (-1.0, -1.0)] } else { &[(0.0, 0.0)] }; + let mut out: Vec = Vec::new(); + let mut work = 16_000_000usize; + for (s1, s2) in signs { + let (d1, d2) = (s1 * r, s2 * r); + let (o1, o2) = (sample(c1, &r1, d1), sample(c2, &r2, d2)); + let search = TanSearch { c1, r1: &r1, d1, c2, r2: &r2, d2, tol }; + // Refine each pair before visiting the next: raw duplicate seeds in an early run must + // not consume all candidate slots. A separate request-wide budget bounds actual work. + for a in &o1 { + for b in &o2 { + if !refine_pair(&search, a, b, &mut work, &mut out) { + return out; + } + } + } + } + out +} + +/// The two curves of one tangent search for one sign choice: their ranges and offsets, and the +/// tolerance. +struct TanSearch<'a> { + c1: &'a TanCurve, + r1: &'a Range, + d1: f64, + c2: &'a TanCurve, + r2: &'a Range, + d2: f64, + tol: f64, +} + +/// The window the tangent search looks in: the pick points and the extents of the curves that +/// are not lines. `None` when it is empty or not finite. +fn search_window(c1: &TanCurve, c2: &TanCurve, near: &[Vec2]) -> Option { let mut win = Bounds2::from_points(near.iter().copied().filter(|p| p.is_finite())); for c in [c1, c2] { match c { @@ -251,47 +291,34 @@ pub fn tangent_circles(c1: &TanCurve, c2: &TanCurve, r: f64, near: &[Vec2]) -> V } } if win.is_empty() || !(win.width().is_finite() && win.height().is_finite()) { - return Vec::new(); + return None; } - let size = win.width().max(win.height()).max(r).max(1e-9); - let win = win.expand(2.0 * r + 0.25 * size); - let tol = 1e-9 * size.max(1.0); - let (Some(r1), Some(r2)) = (c1.range(&win), c2.range(&win)) else { return Vec::new() }; - let signs: &[(f64, f64)] = if r > 0.0 { &[(1.0, 1.0), (1.0, -1.0), (-1.0, 1.0), (-1.0, -1.0)] } else { &[(0.0, 0.0)] }; - let mut out: Vec = Vec::new(); - let mut work = 16_000_000usize; - for (s1, s2) in signs { - let (d1, d2) = (s1 * r, s2 * r); - let (o1, o2) = (sample(c1, &r1, d1), sample(c2, &r2, d2)); - // Refine each pair before visiting the next: raw duplicate seeds in an early run must - // not consume all candidate slots. A separate request-wide budget bounds actual work. - for a in &o1 { - for b in &o2 { - let mut seeds = Vec::new(); - crossings(a, b, &mut seeds, &mut work); - seeds.sort_by(|x, y| x.0.total_cmp(&y.0).then(x.1.total_cmp(&y.1))); - seeds.dedup_by(|x, y| (x.0 - y.0).abs() < 1e-9 && (x.1 - y.1).abs() < 1e-9); - for seed in seeds { - // Newton refinement has a fixed iteration cap and several curve evaluations. - let Some(left) = work.checked_sub(128) else { return out }; - work = left; - let Some((t1, t2)) = refine(c1, &r1, d1, c2, &r2, d2, seed, tol) else { continue }; - let (Some(center), p1, p2) = (c1.offset(t1, d1), c1.point(t1), c2.point(t2)) else { continue }; - let near_dup = |f: &CornerFit| f.center.near(center, tol * 1e3) && f.p1.near(p1, tol * 1e3) && f.p2.near(p2, tol * 1e3); - if !out.iter().any(near_dup) { - out.push(CornerFit { t1, t2, p1, p2, center }); - } - if out.len() >= MAX_SOLUTIONS { - return out; - } - } - if work == 0 { - return out; - } - } + Some(win) +} + +/// Refine the crossings of the samples `a` and `b` into circles, adding the distinct ones to +/// `out`. `false` when the work budget or the solution cap ends the search. +fn refine_pair(search: &TanSearch, a: &[(f64, Vec2)], b: &[(f64, Vec2)], work: &mut usize, out: &mut Vec) -> bool { + let mut seeds = Vec::new(); + crossings(a, b, &mut seeds, work); + seeds.sort_by(|x, y| x.0.total_cmp(&y.0).then(x.1.total_cmp(&y.1))); + seeds.dedup_by(|x, y| (x.0 - y.0).abs() < 1e-9 && (x.1 - y.1).abs() < 1e-9); + for seed in seeds { + // Newton refinement has a fixed iteration cap and several curve evaluations. + let Some(left) = work.checked_sub(128) else { return false }; + *work = left; + let TanSearch { c1, r1, d1, c2, r2, d2, tol } = *search; + let Some((t1, t2)) = refine(c1, r1, d1, c2, r2, d2, seed, tol) else { continue }; + let (Some(center), p1, p2) = (c1.offset(t1, d1), c1.point(t1), c2.point(t2)) else { continue }; + let near_dup = |f: &CornerFit| f.center.near(center, tol * 1e3) && f.p1.near(p1, tol * 1e3) && f.p2.near(p2, tol * 1e3); + if !out.iter().any(near_dup) { + out.push(CornerFit { t1, t2, p1, p2, center }); + } + if out.len() >= MAX_SOLUTIONS { + return false; } } - out + *work != 0 } #[cfg(test)] diff --git a/crates/geom/src/intersect.rs b/crates/geom/src/intersect.rs index 1451a94bd..653cd61c9 100644 --- a/crates/geom/src/intersect.rs +++ b/crates/geom/src/intersect.rs @@ -62,22 +62,29 @@ pub fn circle_circle(c1: &Circle, c2: &Circle) -> Vec { // 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 { 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 { diff --git a/crates/geom/src/property_tests.rs b/crates/geom/src/property_tests.rs index f4ebb55f3..0584c9109 100644 --- a/crates/geom/src/property_tests.rs +++ b/crates/geom/src/property_tests.rs @@ -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 ---------- diff --git a/crates/geom/src/region.rs b/crates/geom/src/region.rs index 98050cfdc..c186534c9 100644 --- a/crates/geom/src/region.rs +++ b/crates/geom/src/region.rs @@ -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], 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], tol: f64) -> Option { let mut segs: Vec<(Vec2, Vec2)> = Vec::new(); let mut owner: Vec = Vec::new(); @@ -94,16 +109,13 @@ fn build(polys: &[Vec], tol: f64) -> Option { ) }; let mut grid: HashMap<(usize, usize), Vec> = HashMap::new(); + let mut long: Vec = 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], tol: f64) -> Option { 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 { @@ -434,6 +447,44 @@ fn islands(g: &Graph, outer: &[usize], p: Vec2) -> Vec { out } +/// The point of the nearest other curve within `snap` of the end `e` of curve `i`, as (curve, +/// segment, parameter, point). `None` when the end touches a curve, or none is near. `Err` when +/// the budget of segment checks runs out. +fn nearest_curve(polys: &[Vec], i: usize, e: Vec2, snap: f64, tol: f64, budget: &mut usize) -> Result, ()> { + let mut best: Option<(f64, usize, usize, f64, Vec2)> = None; + let mut touching = false; + for (j, other) in polys.iter().enumerate() { + if j == i { + continue; + } + for (k, w) in other.windows(2).enumerate() { + if *budget == 0 { + return Err(()); + } + *budget -= 1; + let [a, b] = w else { continue }; + if !(a.is_finite() && b.is_finite()) + || e.x < a.x.min(b.x) - snap + || e.x > a.x.max(b.x) + snap + || e.y < a.y.min(b.y) - snap + || e.y > a.y.max(b.y) + snap + { + continue; + } + let line = Line::new(*a, *b); + let t = line.param_of(e).clamp(0.0, 1.0); + let q = line.at(t); + let d = q.dist(e); + if d <= tol { + touching = true; + } else if d <= snap && best.is_none_or(|(bd, ..)| d < bd) { + best = Some((d, j, k, t, q)); + } + } + } + Ok(best.filter(|_| !touching).map(|(_, j, k, t, q)| (j, k, t, q))) +} + /// The curves with each open end that stops within `snap` of another curve moved onto that /// curve (which gains a vertex there), so the two meet exactly. A curve that is flattened /// into chords (an ellipse, a spline) lies up to the flattening tolerance off the true curve, @@ -461,38 +512,8 @@ pub fn snap_ends(polys: &[Vec], snap: f64, tol: f64) -> Vec> { if !e.is_finite() { continue; } - let mut best: Option<(f64, usize, usize, f64, Vec2)> = None; - let mut touching = false; - for (j, other) in polys.iter().enumerate() { - if j == i { - continue; - } - for (k, w) in other.windows(2).enumerate() { - if budget == 0 { - break 'ends; - } - budget -= 1; - let [a, b] = w else { continue }; - if !(a.is_finite() && b.is_finite()) - || e.x < a.x.min(b.x) - snap - || e.x > a.x.max(b.x) + snap - || e.y < a.y.min(b.y) - snap - || e.y > a.y.max(b.y) + snap - { - continue; - } - let line = Line::new(*a, *b); - let t = line.param_of(e).clamp(0.0, 1.0); - let q = line.at(t); - let d = q.dist(e); - if d <= tol { - touching = true; - } else if d <= snap && best.is_none_or(|(bd, ..)| d < bd) { - best = Some((d, j, k, t, q)); - } - } - } - let Some((_, j, k, t, q)) = best.filter(|_| !touching) else { continue }; + let Ok(near) = nearest_curve(polys, i, e, snap, tol, &mut budget) else { break 'ends }; + let Some((j, k, t, q)) = near else { continue }; let Some(slot) = out.get_mut(i).and_then(|v| if at_end { v.last_mut() } else { v.first_mut() }) else { continue }; *slot = q; let ends = polys.get(j).and_then(|o| o.get(k).zip(o.get(k + 1))); @@ -609,6 +630,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(); diff --git a/crates/geom/src/tests.rs b/crates/geom/src/tests.rs index 8b8a0bad2..915a1071e 100644 --- a/crates/geom/src/tests.rs +++ b/crates/geom/src/tests.rs @@ -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:?}"); +} diff --git a/crates/io/src/dxf_ext.rs b/crates/io/src/dxf_ext.rs index 486a95421..392ee1400 100644 --- a/crates/io/src/dxf_ext.rs +++ b/crates/io/src/dxf_ext.rs @@ -118,6 +118,10 @@ pub(crate) const DIM_CODES: &[(&str, i32, K)] = &[ ("arrowBlock2", 344, K::Block), ("fixedExtLength", 49, K::Real), ("jogAngle", 50, K::Angle), + // DIMTFILL and DIMTFILLCLR (Open Design Specification for .dwg files 5.4.1, DIMSTYLE). In a + // DIMSTYLE record 70 also holds the record's flags, which come first. + ("textFill", 69, K::Int), + ("textFillColor", 70, K::Color), ("angularZeroSuppression", 79, K::Int), ("arcSymbol", 90, K::Int), ("textVerticalPos", 145, K::Real), @@ -143,14 +147,23 @@ pub(crate) const DIMSAH: i32 = 173; /// DIMPOST's group in DIMSTYLE records. pub(crate) const DIMPOST: i32 = 3; -/// Variables newer than R2000: DIMFXL, DIMJOGANG, DIMARCSYM, DIMFXLON, DIMLTYPE, DIMLTEX1, -/// DIMLTEX2. Files of [`NEWER_SINCE`] or later get them as DIMSTYLE groups; R2000 files get them -/// in a style's `CADCRAFT` "DIMVARS" xdata (`1070 ` then the value, like DSTYLE) instead of -/// groups an R2000 reader doesn't expect. The reader takes both. -pub(crate) const NEWER_CODES: &[i32] = &[49, 50, 90, 290, 345, 346, 347]; -/// The first version with [`NEWER_CODES`] as DIMSTYLE groups: all came with AutoCAD 2006, whose -/// files are the 2004 format (AC1018). +/// Variables newer than R2000: DIMFXL, DIMJOGANG, DIMTFILL, DIMTFILLCLR, DIMARCSYM, DIMFXLON, +/// DIMLTYPE, DIMLTEX1, DIMLTEX2. Files of a variable's [`newer_since`] version or later get it as +/// a DIMSTYLE group (R2004, and R2007 for DIMTFILL and DIMTFILLCLR); older files get it in a +/// style's `CADCRAFT` "DIMVARS" xdata (`1070 ` then the value, like DSTYLE) instead of a +/// group an older reader doesn't expect. The reader takes both. +pub(crate) const NEWER_CODES: &[i32] = &[49, 50, 69, 70, 90, 290, 345, 346, 347]; +/// The first version with [`NEWER_CODES`] as DIMSTYLE groups, except DIMTFILL and DIMTFILLCLR +/// (see [`newer_since`]): the others came with AutoCAD 2006, whose files are the 2004 format +/// (AC1018). pub(crate) const NEWER_SINCE: Version = Version::R2004; + +/// The first version that has `code` (one of [`NEWER_CODES`]) as a DIMSTYLE group: R2007 for +/// DIMTFILL and DIMTFILLCLR (Open Design Specification for .dwg files 5.4.1), else +/// [`NEWER_SINCE`]. +pub(crate) fn newer_since(code: i32) -> Version { + if matches!(code, 69 | 70) { Version::R2007 } else { NEWER_SINCE } +} /// Xdata list name of [`NEWER_CODES`] values. pub(crate) const DIMVARS_LIST: &str = "DIMVARS"; diff --git a/crates/io/src/dxf_read.rs b/crates/io/src/dxf_read.rs index 5860b1e03..4c19a1184 100644 --- a/crates/io/src/dxf_read.rs +++ b/crates/io/src/dxf_read.rs @@ -249,6 +249,130 @@ fn entity(kind: &str, tags: &[Tag]) -> Option<(Common, EntityKind)> { } /// Parse one entity while charging materialized table cells to the caller's shared import budget. +/// An ARC, with its angles run the other way in a mirrored OCS. +fn arc(c: &Common, t: &T) -> Arc { + let mut a = Arc { center: ocs(c, t.p(10)), radius: t.fd(40, 1.0).abs(), start: t.fd(50, 0.0).to_radians(), end: t.fd(51, 360.0).to_radians() }; + if c.extrusion.z < 0.0 { + // Mirrored OCS: angles run the other way. + let (s, e) = (std::f64::consts::PI - a.end, std::f64::consts::PI - a.start); + a.start = cadcraft_geom::norm_angle(s); + a.end = cadcraft_geom::norm_angle(e); + } + a +} + +fn ellipse(c: &Common, t: &T) -> Ellipse { + let mut e = Ellipse { + center: t.p(10), + major: t.p(11), + ratio: t.fd(40, 1.0).clamp(1e-9, 1.0), + start: t.fd(41, 0.0), + end: t.fd(42, std::f64::consts::TAU), + }; + if c.extrusion.z < 0.0 && (cadcraft_geom::ccw_sweep(e.start, e.end) - std::f64::consts::TAU).abs() > 1e-9 { + // Centre and major axis are WCS, but the minor axis is extrusion × major: with (0,0,-1) + // it points the other way, so parameter t lands where -t does in the XY plane. A full + // ellipse is symmetric and stays as it is. + let (s, e2) = (-e.end, -e.start); + e.start = cadcraft_geom::norm_angle(s); + e.end = cadcraft_geom::norm_angle(e2); + } + e +} + +/// A LWPOLYLINE, or the region CADCraft wrote as one. +fn lwpolyline(tags: &[Tag], c: &Common, t: &T) -> Option { + let mut verts = Vec::new(); + let mut cur: Option = None; + for tg in tags { + match tg.code { + 10 => { + if let Some(v) = cur.take() { + verts.push(v); + } + cur = Some(PolyVertex { p: Vec2::new(tg.f64(), 0.0), ..Default::default() }); + } + 20 => { + if let Some(v) = cur.as_mut() { + v.p.y = tg.f64(); + } + } + 40 => { + if let Some(v) = cur.as_mut() { + v.start_width = tg.f64(); + } + } + 41 => { + if let Some(v) = cur.as_mut() { + v.end_width = tg.f64(); + } + } + 42 => { + if let Some(v) = cur.as_mut() { + v.bulge = tg.f64(); + } + } + _ => {} + } + } + verts.extend(cur); + let flags = t.i(70).unwrap_or(0); + if c.extrusion.z < 0.0 { + for v in &mut verts { + v.p.x = -v.p.x; + v.bulge = -v.bulge; + } + } + let pl = LwPolyline { vertices: verts, closed: flags & 1 != 0, const_width: t.fd(43, 0.0), elevation: t.fd(38, 0.0), plinegen: flags & 128 != 0 }; + // A region CADCraft wrote as polylines (`dxf_region`). + crate::dxf_region::from_polyline(tags, pl) +} + +fn spline(t: &T) -> Spline { + let degree = t.i(71).unwrap_or(3).clamp(1, 10) as usize; + let knots = t.all_f(40); + let control = t.pts(10); + let weights = t.all_f(41); + let fit = t.pts(11); + let tangent = |c: i32| t.f(c).map(|_| t.p(c).xy()); + let fit_opts = + cadcraft_geom::FitOptions { knots: Default::default(), start_tangent: tangent(12), end_tangent: tangent(13), tolerance: t.fd(44, 0.0) } + .sanitized(); + let closed = t.i(70).unwrap_or(0) & 1 != 0; + let mut sp = Spline { degree, knots, control, weights, fit: fit.clone(), closed, fit_opts }; + if !sp.is_valid() && fit.len() >= 2 { + sp = Spline::fit_with(&fit, sp.closed, fit_opts); + } else if !sp.is_valid() && sp.control.len() >= 2 { + sp = Spline::from_control(sp.control.clone(), degree); + } else if fit.len() >= 3 { + // DXF keeps the knots, not how they were spaced: recover it for later refits. + sp.fit_opts.knots = sp.infer_knot_param(); + } + sp +} + +/// An ATTDEF, whose multiline text is embedded MTEXT after its own groups. +fn attdef(tags: &[Tag], c: &Common) -> Attrib { + // A multiline definition has its text as embedded MTEXT after its own groups. + let (own, embedded) = split_embedded(tags); + let multiline = multiline_attrib(embedded, c); + let own = T(own); + let mut text = text_from(&own); + if let Some(m) = &multiline { + text.value.clone_from(&m.read_as); + } + Attrib { + tag: own.s(2).unwrap_or_default(), + text, + invisible: own.i(70).unwrap_or(0) & 1 != 0, + constant: own.i(70).unwrap_or(0) & 2 != 0, + prompt: own.s(3).unwrap_or_default(), + props: Default::default(), + multiline, + modes: attrib_modes(own.0), + } +} + fn entity_with_table_budget(kind: &str, tags: &[Tag], table_cells_left: &mut usize) -> Option<(Common, EntityKind)> { // The paper-space viewport our writer adds (dxf_write::paper_view) stays implicit. if kind == "VIEWPORT" && crate::dxf_ext::is_paper_view(tags) { @@ -260,138 +384,16 @@ fn entity_with_table_budget(kind: &str, tags: &[Tag], table_cells_left: &mut usi "LINE" => EntityKind::Line(Line { a: t.p(10), b: t.p(11) }), "POINT" => EntityKind::Point(Point { p: t.p(10), angle: t.fd(50, 0.0).to_radians() }), "CIRCLE" => EntityKind::Circle(Circle { center: ocs(&c, t.p(10)), radius: t.fd(40, 1.0).abs() }), - "ARC" => { - let mut a = - Arc { center: ocs(&c, t.p(10)), radius: t.fd(40, 1.0).abs(), start: t.fd(50, 0.0).to_radians(), end: t.fd(51, 360.0).to_radians() }; - if c.extrusion.z < 0.0 { - // Mirrored OCS: angles run the other way. - let (s, e) = (std::f64::consts::PI - a.end, std::f64::consts::PI - a.start); - a.start = cadcraft_geom::norm_angle(s); - a.end = cadcraft_geom::norm_angle(e); - } - EntityKind::Arc(a) - } - "ELLIPSE" => { - let mut e = Ellipse { - center: t.p(10), - major: t.p(11), - ratio: t.fd(40, 1.0).clamp(1e-9, 1.0), - start: t.fd(41, 0.0), - end: t.fd(42, std::f64::consts::TAU), - }; - if c.extrusion.z < 0.0 && (cadcraft_geom::ccw_sweep(e.start, e.end) - std::f64::consts::TAU).abs() > 1e-9 { - // Centre and major axis are WCS, but the minor axis is extrusion × major: with (0,0,-1) - // it points the other way, so parameter t lands where -t does in the XY plane. A full - // ellipse is symmetric and stays as it is. - let (s, e2) = (-e.end, -e.start); - e.start = cadcraft_geom::norm_angle(s); - e.end = cadcraft_geom::norm_angle(e2); - } - EntityKind::Ellipse(e) - } - "LWPOLYLINE" => { - let mut verts = Vec::new(); - let mut cur: Option = None; - for tg in tags { - match tg.code { - 10 => { - if let Some(v) = cur.take() { - verts.push(v); - } - cur = Some(PolyVertex { p: Vec2::new(tg.f64(), 0.0), ..Default::default() }); - } - 20 => { - if let Some(v) = cur.as_mut() { - v.p.y = tg.f64(); - } - } - 40 => { - if let Some(v) = cur.as_mut() { - v.start_width = tg.f64(); - } - } - 41 => { - if let Some(v) = cur.as_mut() { - v.end_width = tg.f64(); - } - } - 42 => { - if let Some(v) = cur.as_mut() { - v.bulge = tg.f64(); - } - } - _ => {} - } - } - verts.extend(cur); - let flags = t.i(70).unwrap_or(0); - if c.extrusion.z < 0.0 { - for v in &mut verts { - v.p.x = -v.p.x; - v.bulge = -v.bulge; - } - } - let pl = LwPolyline { - vertices: verts, - closed: flags & 1 != 0, - const_width: t.fd(43, 0.0), - elevation: t.fd(38, 0.0), - plinegen: flags & 128 != 0, - }; - // A region CADCraft wrote as polylines (`dxf_region`). - crate::dxf_region::from_polyline(tags, pl)? - } - "SPLINE" => { - let degree = t.i(71).unwrap_or(3).clamp(1, 10) as usize; - let knots = t.all_f(40); - let control = t.pts(10); - let weights = t.all_f(41); - let fit = t.pts(11); - let tangent = |c: i32| t.f(c).map(|_| t.p(c).xy()); - let fit_opts = cadcraft_geom::FitOptions { - knots: Default::default(), - start_tangent: tangent(12), - end_tangent: tangent(13), - tolerance: t.fd(44, 0.0), - } - .sanitized(); - let closed = t.i(70).unwrap_or(0) & 1 != 0; - let mut sp = Spline { degree, knots, control, weights, fit: fit.clone(), closed, fit_opts }; - if !sp.is_valid() && fit.len() >= 2 { - sp = Spline::fit_with(&fit, sp.closed, fit_opts); - } else if !sp.is_valid() && sp.control.len() >= 2 { - sp = Spline::from_control(sp.control.clone(), degree); - } else if fit.len() >= 3 { - // DXF keeps the knots, not how they were spaced: recover it for later refits. - sp.fit_opts.knots = sp.infer_knot_param(); - } - EntityKind::Spline(sp) - } + "ARC" => EntityKind::Arc(arc(&c, &t)), + "ELLIPSE" => EntityKind::Ellipse(ellipse(&c, &t)), + "LWPOLYLINE" => lwpolyline(tags, &c, &t)?, + "SPLINE" => EntityKind::Spline(spline(&t)), "HELIX" => EntityKind::Helix(helix(tags)), "RAY" => EntityKind::Ray(RayLine { base: t.p(10), dir: t.p(11) }), "XLINE" => EntityKind::XLine(RayLine { base: t.p(10), dir: t.p(11) }), "TEXT" => EntityKind::Text(text_from(&t)), "MTEXT" => EntityKind::MText(mtext_from(tags, &c)), - "ATTDEF" => { - // A multiline definition has its text as embedded MTEXT after its own groups. - let (own, embedded) = split_embedded(tags); - let multiline = multiline_attrib(embedded, &c); - let own = T(own); - let mut text = text_from(&own); - if let Some(m) = &multiline { - text.value.clone_from(&m.read_as); - } - EntityKind::AttDef(Attrib { - tag: own.s(2).unwrap_or_default(), - text, - invisible: own.i(70).unwrap_or(0) & 1 != 0, - constant: own.i(70).unwrap_or(0) & 2 != 0, - prompt: own.s(3).unwrap_or_default(), - props: Default::default(), - multiline, - modes: attrib_modes(own.0), - }) - } + "ATTDEF" => EntityKind::AttDef(attdef(tags, &c)), "INSERT" => { // A mirrored block reference comes with extrusion (0,0,-1): its insertion point, scale, // rotation and column spacing are in that OCS, where x runs the other way. In the WCS that @@ -734,7 +736,7 @@ fn hatch(tags: &[Tag], t: &T) -> Option { 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, _ => {} @@ -1109,6 +1111,91 @@ fn entity_extras(kind: &str, tags: &[Tag], h: Handle, k: &mut EntityKind, rx: &m /// Parse a run of entity records (ENTITIES section or a block's body) into entities. Records that /// can't be placed are counted in `skipped`. +/// The entity of a POLYLINE, with its mesh faces (advancing `i` past its VERTEX records). `None` +/// for a mesh without faces. +fn polyline_entity(recs: &[(String, Vec)], i: &mut usize, t: &T) -> Option<(Common, EntityKind, Vec)> { + let flags = t.i(70).unwrap_or(0); + let mut pts: Vec<(Vec3, f64, f64, f64)> = Vec::new(); + // Polyface face records (VERTEX 70 bit 128 without 64): vertex indices 71–74. + let mut faces: Vec<[i64; 4]> = Vec::new(); + while let Some((k2, t2)) = recs.get(*i) { + if k2 == "VERTEX" { + let tt = T(t2); + let vflags = tt.i(70).unwrap_or(0); + if vflags & 128 != 0 && vflags & 64 == 0 { + faces.push([71, 72, 73, 74].map(|c| tt.i(c).unwrap_or(0))); + } else if vflags & 16 == 0 { + pts.push((tt.p(10), tt.fd(42, 0.0), tt.fd(40, 0.0), tt.fd(41, 0.0))); + } + *i += 1; + } else { + if k2 == "SEQEND" { + *i += 1; + } + break; + } + } + let c = common(t); + let mut extra: Vec = Vec::new(); + let k = if flags & (16 | 64) != 0 { + // Polyface (64) and polygon (16) meshes become 3D faces; the first takes the + // polyline's place. + let verts: Vec = pts.iter().map(|p| p.0).collect(); + let mut fs = if flags & 64 != 0 { polyface_faces(&verts, &faces) } else { mesh_faces(&verts, t, flags) }.into_iter().map(EntityKind::Face3d); + let first = fs.next()?; + extra.extend(fs); + first + } else if flags & 8 != 0 { + EntityKind::Polyline3d(Polyline3d { points: pts.iter().map(|p| p.0).collect(), closed: flags & 1 != 0 }) + } else { + // A 2D polyline's vertices are in its OCS: mirrored like LWPOLYLINE. + let m = if c.extrusion.z < 0.0 { -1.0 } else { 1.0 }; + EntityKind::LwPolyline(LwPolyline { + vertices: pts.iter().map(|p| PolyVertex { p: Vec2::new(m * p.0.x, p.0.y), bulge: m * p.1, start_width: p.2, end_width: p.3 }).collect(), + closed: flags & 1 != 0, + const_width: t.fd(40, 0.0).min(t.fd(41, 0.0)).max(0.0), + elevation: t.p(10).z, + plinegen: flags & 128 != 0, + }) + }; + Some((c, k, extra)) +} + +/// The ATTRIB records after an INSERT, advancing `i` past them and their SEQEND. +fn insert_attribs(recs: &[(String, Vec)], i: &mut usize, ins_layer: &str) -> Vec { + let mut attribs = Vec::new(); + while let Some((k2, t2)) = recs.get(*i) { + if k2 == "ATTRIB" { + let tt = T(t2); + // A multiline attribute has its text as embedded MTEXT after its own groups. + let (own, embedded) = split_embedded(t2); + let multiline = multiline_attrib(embedded, &common(&tt)); + let own = T(own); + let mut text = text_from(&own); + if let Some(m) = &multiline { + text.value.clone_from(&m.read_as); + } + attribs.push(Attrib { + tag: own.s(2).unwrap_or_default(), + text, + invisible: own.i(70).unwrap_or(0) & 1 != 0, + constant: false, + prompt: String::new(), + props: attrib_props(&tt, ins_layer), + multiline, + modes: attrib_modes(own.0), + }); + *i += 1; + } else { + if k2 == "SEQEND" { + *i += 1; + } + break; + } + } + attribs +} + fn parse_entities( recs: &[(String, Vec)], d: &mut Drawing, @@ -1134,92 +1221,17 @@ fn parse_entities( i += 1; match kind.as_str() { "POLYLINE" => { - let flags = t.i(70).unwrap_or(0); - let mut pts: Vec<(Vec3, f64, f64, f64)> = Vec::new(); - // Polyface face records (VERTEX 70 bit 128 without 64): vertex indices 71–74. - let mut faces: Vec<[i64; 4]> = Vec::new(); - while let Some((k2, t2)) = recs.get(i) { - if k2 == "VERTEX" { - let tt = T(t2); - let vflags = tt.i(70).unwrap_or(0); - if vflags & 128 != 0 && vflags & 64 == 0 { - faces.push([71, 72, 73, 74].map(|c| tt.i(c).unwrap_or(0))); - } else if vflags & 16 == 0 { - pts.push((tt.p(10), tt.fd(42, 0.0), tt.fd(40, 0.0), tt.fd(41, 0.0))); - } - i += 1; - } else { - if k2 == "SEQEND" { - i += 1; - } - break; - } - } - let c = common(&t); - let k = if flags & (16 | 64) != 0 { - // Polyface (64) and polygon (16) meshes become 3D faces; the first takes the - // polyline's place. - let verts: Vec = pts.iter().map(|p| p.0).collect(); - let mut fs = if flags & 64 != 0 { polyface_faces(&verts, &faces) } else { mesh_faces(&verts, &t, flags) } - .into_iter() - .map(EntityKind::Face3d); - let Some(first) = fs.next() else { - *skipped = skipped.saturating_add(1); - continue; - }; - extra.extend(fs); - first - } else if flags & 8 != 0 { - EntityKind::Polyline3d(Polyline3d { points: pts.iter().map(|p| p.0).collect(), closed: flags & 1 != 0 }) - } else { - // A 2D polyline's vertices are in its OCS: mirrored like LWPOLYLINE. - let m = if c.extrusion.z < 0.0 { -1.0 } else { 1.0 }; - EntityKind::LwPolyline(LwPolyline { - vertices: pts - .iter() - .map(|p| PolyVertex { p: Vec2::new(m * p.0.x, p.0.y), bulge: m * p.1, start_width: p.2, end_width: p.3 }) - .collect(), - closed: flags & 1 != 0, - const_width: t.fd(40, 0.0).min(t.fd(41, 0.0)).max(0.0), - elevation: t.p(10).z, - plinegen: flags & 128 != 0, - }) + let Some((c, k, more)) = polyline_entity(recs, &mut i, &t) else { + *skipped = skipped.saturating_add(1); + continue; }; + extra = more; kindent = Some((c, k)); } "INSERT" => { if t.i(66) == Some(1) { - let mut attribs = Vec::new(); let ins_layer = kindent.as_ref().map(|(c, _)| c.layer.clone()).unwrap_or_default(); - while let Some((k2, t2)) = recs.get(i) { - if k2 == "ATTRIB" { - let tt = T(t2); - // A multiline attribute has its text as embedded MTEXT after its own groups. - let (own, embedded) = split_embedded(t2); - let multiline = multiline_attrib(embedded, &common(&tt)); - let own = T(own); - let mut text = text_from(&own); - if let Some(m) = &multiline { - text.value.clone_from(&m.read_as); - } - attribs.push(Attrib { - tag: own.s(2).unwrap_or_default(), - text, - invisible: own.i(70).unwrap_or(0) & 1 != 0, - constant: false, - prompt: String::new(), - props: attrib_props(&tt, &ins_layer), - multiline, - modes: attrib_modes(own.0), - }); - i += 1; - } else { - if k2 == "SEQEND" { - i += 1; - } - break; - } - } + let attribs = insert_attribs(recs, &mut i, &ins_layer); if let Some((_, EntityKind::Insert(ins))) = kindent.as_mut() { ins.attribs = attribs; } @@ -1587,7 +1599,12 @@ fn tables(tags: &[Tag], d: &mut Drawing, rx: &mut Rx) { // Every variable with a DIMSTYLE group code; values the style can't take are // left at their defaults. let mut fields = serde_json::Map::new(); - for tag in tg.iter().take_while(|x| x.code < 1000) { + // The first 70 is the record's flags; a later one is DIMTFILLCLR. + let flags = tg.iter().position(|x| x.code == 70); + for (i, tag) in tg.iter().enumerate().take_while(|(_, x)| x.code < 1000) { + if Some(i) == flags { + continue; + } let Some((field, kind)) = crate::dxf_ext::dim_field(tag.code) else { continue }; if let Some(v) = crate::dxf_ext::decode(kind, tag, &rx.styles, &rx.brs, &rx.ltypes) { fields.insert(field.to_string(), v); @@ -1683,6 +1700,13 @@ pub fn read(bytes: &[u8]) -> Result { read_budgeted(bytes, 4_000_000) } +/// A block definition read from BLOCKS: name, base point, description, entities (owner, paper +/// flag, entity) and block flags. +type BlockDef = (String, Vec3, String, Vec<(Option, bool, Entity)>, i64); + +/// A layout read from OBJECTS: name, tab order, paper block handle and page setup. +type LayoutDef = (String, u32, String, PageSetup); + /// Import DXF sections while sharing one materialized-table-cell budget across blocks and entity sections. fn read_budgeted(bytes: &[u8], mut table_cells_left: usize) -> Result { let tags = cadcraft_dxf::parse(bytes).map_err(|e| IoError::Format(e.to_string()))?; @@ -1695,8 +1719,8 @@ fn read_budgeted(bytes: &[u8], mut table_cells_left: usize) -> Result { // A file without $INSUNITS declares no units (#1209); only new drawings take the template's. d.header.set_i64("INSUNITS", 0); let mut rx = Rx { pstyles: plot_style_names(&secs), ..Rx::default() }; - let mut blocks: Vec<(String, Vec3, String, Vec<(Option, bool, Entity)>, i64)> = Vec::new(); - let mut layout_objs: Vec<(String, u32, String, PageSetup)> = Vec::new(); + let mut blocks: Vec = Vec::new(); + let mut layout_objs: Vec = Vec::new(); let mut entities = Vec::new(); let mut objs = Objects::default(); // Every handle another program's file already uses is taken before anything gets a new one: @@ -1716,73 +1740,109 @@ fn read_budgeted(bytes: &[u8], mut table_cells_left: usize) -> Result { match s.name.as_str() { "HEADER" => header(&s.tags, &mut d), "TABLES" => tables(&s.tags, &mut d, &mut rx), - "BLOCKS" => { - let recs = records(&s.tags); - for (k, tg) in &recs { - let t = T(tg); - if k == "BLOCK" && t.i(70).unwrap_or(0) & 4 != 0 { - rx.xref_paths.insert(t.s(2).unwrap_or_default().to_ascii_uppercase(), t.s(1).unwrap_or_default()); - } - } - let mut n = 0; - let mut i = 0; - while let Some((k, tg)) = recs.get(i) { - if k == "BLOCK" { - let t = T(tg); - let name = t.s(2).unwrap_or_default(); - let base = t.p(10); - let description = t.s(4).unwrap_or_default(); - // Block flags: 4 xref, 8 overlay. - let xref = t.i(70).unwrap_or(0) & 12; - let mut j = i + 1; - while recs.get(j).is_some_and(|(k, _)| k != "ENDBLK") { - j += 1; - } - let body = recs.get(i + 1..j).unwrap_or(&[]); - let ents = parse_entities(body, &mut d, &mut rx, &mut n, &mut table_cells_left); - blocks.push((name, base, description, ents, xref)); - i = j + 1; - } else { - // A record outside any BLOCK ... ENDBLK pair. - if k != "ENDBLK" { - n = n.saturating_add(1); - } - i += 1; - } - } - count_skipped(&mut skipped, "BLOCKS", n); - } + "BLOCKS" => blocks.extend(read_blocks(&s.tags, &mut d, &mut rx, &mut table_cells_left, &mut skipped)), // A damaged file may split its entities over several ENTITIES sections (#865). "ENTITIES" => { let mut n = 0; entities.extend(parse_entities(&records(&s.tags), &mut d, &mut rx, &mut n, &mut table_cells_left)); count_skipped(&mut skipped, "ENTITIES", n); } - "OBJECTS" => { - for (k, tg) in records(&s.tags) { - objs.add(&k, &tg); - if k == "LAYOUT" { - // Fields after the AcDbLayout marker (AcDbPlotSettings also has a code 1). - let start = tg.iter().position(|x| x.code == 100 && x.str() == "AcDbLayout").unwrap_or(0); - let tail = tg.get(start..).unwrap_or(&[]); - let t = T(tail); - // LAYOUT has two 330s: owner dict and the block record; take the last. - let br = tg.iter().rfind(|x| x.code == 330).map(Tag::str).unwrap_or_default(); - let plot = T(tg.get(..start).unwrap_or(&[])); - layout_objs.push((t.s(1).unwrap_or_default(), t.i(71).unwrap_or(0) as u32, br.to_ascii_uppercase(), page_setup(&plot))); - } - } - } + "OBJECTS" => read_objects(&s.tags, &mut objs, &mut layout_objs), _ => {} } } // A file CADCraft wrote names its own next handle (#495): its table records and objects // are numbered above it and rebuilt on save, so following $HANDSEED would drift every save. let own_seed = objs.xrecord_text(crate::dxf_ext::HANDSEED_KEY).and_then(|t| Handle::parse_hex(t.trim())); - // Layouts. + let (paper_block_for, first_layout) = setup_layouts(&mut d, &mut layout_objs, &rx); + place_blocks(&mut d, blocks, &mut rx, &paper_block_for, &first_layout); + place_entities(&mut d, entities, &rx, &paper_block_for, &first_layout); + apply_sortents(&mut d, &objs.sortents, &rx.brs, &paper_block_for, &first_layout); + objs.apply(&mut d, &rx); + crate::dxf_image::resolve(&secs, &mut d); + crate::dxf_mline::resolve(&secs, &mut d); + // Keep handles unique against the header's seed. + let seed = d.header.str("HANDSEED", ""); + if let Some(h) = own_seed.or_else(|| Handle::parse_hex(&seed)) { + d.bump_handseed(Handle(h.0.saturating_sub(1))); + } + if d.layer("0").is_none() { + d.layers.insert(0, Layer::default()); + } + crate::dxf_raw::read(&secs, &mut d); + d.skipped_records = skipped; + Ok(d) +} + +/// Read the BLOCKS section: the xref paths of external blocks, then each block's entities. +fn read_blocks( + tags: &[Tag], + d: &mut Drawing, + rx: &mut Rx, + table_cells_left: &mut usize, + skipped: &mut std::collections::BTreeMap, +) -> Vec { + let recs = records(tags); + for (k, tg) in &recs { + let t = T(tg); + if k == "BLOCK" && t.i(70).unwrap_or(0) & 4 != 0 { + rx.xref_paths.insert(t.s(2).unwrap_or_default().to_ascii_uppercase(), t.s(1).unwrap_or_default()); + } + } + let mut blocks = Vec::new(); + let mut n = 0; + let mut i = 0; + while let Some((k, tg)) = recs.get(i) { + if k == "BLOCK" { + let t = T(tg); + let name = t.s(2).unwrap_or_default(); + let base = t.p(10); + let description = t.s(4).unwrap_or_default(); + // Block flags: 4 xref, 8 overlay. + let xref = t.i(70).unwrap_or(0) & 12; + let mut j = i + 1; + while recs.get(j).is_some_and(|(k, _)| k != "ENDBLK") { + j += 1; + } + let body = recs.get(i + 1..j).unwrap_or(&[]); + let ents = parse_entities(body, d, rx, &mut n, table_cells_left); + blocks.push((name, base, description, ents, xref)); + i = j + 1; + } else { + // A record outside any BLOCK ... ENDBLK pair. + if k != "ENDBLK" { + n = n.saturating_add(1); + } + i += 1; + } + } + count_skipped(skipped, "BLOCKS", n); + blocks +} + +/// Read the OBJECTS section into `objs`, and each LAYOUT into `layout_objs`. +fn read_objects(tags: &[Tag], objs: &mut Objects, layout_objs: &mut Vec) { + for (k, tg) in records(tags) { + objs.add(&k, &tg); + if k == "LAYOUT" { + // Fields after the AcDbLayout marker (AcDbPlotSettings also has a code 1). + let start = tg.iter().position(|x| x.code == 100 && x.str() == "AcDbLayout").unwrap_or(0); + let tail = tg.get(start..).unwrap_or(&[]); + let t = T(tail); + // LAYOUT has two 330s: owner dict and the block record; take the last. + let br = tg.iter().rfind(|x| x.code == 330).map(Tag::str).unwrap_or_default(); + let plot = T(tg.get(..start).unwrap_or(&[])); + layout_objs.push((t.s(1).unwrap_or_default(), t.i(71).unwrap_or(0) as u32, br.to_ascii_uppercase(), page_setup(&plot))); + } + } +} + +/// Add the drawing's layouts, and return the layout each paper-space block belongs to, and the +/// layout that takes paper-space entities without an owner. +fn setup_layouts(d: &mut Drawing, layout_objs: &mut [LayoutDef], rx: &Rx) -> (HashMap, String) { let mut paper_block_for: HashMap = HashMap::new(); layout_objs.sort_by_key(|l| l.1); - for (name, order, br, page) in &layout_objs { + for (name, order, br, page) in layout_objs.iter() { if name.eq_ignore_ascii_case("Model") { continue; } @@ -1801,7 +1861,12 @@ fn read_budgeted(bytes: &[u8], mut table_cells_left: usize) -> Result { let first_tab = d.layouts.iter().min_by_key(|l| l.tab_order).map(|l| l.name.clone()); d.active_layout = active.clone().filter(|a| Some(a) != first_tab.as_ref()); let first_layout = active.or_else(|| d.layouts.first().map(|l| l.name.clone())).unwrap_or_else(|| "Layout1".into()); - // Blocks: model/paper space blocks hold entities in newer files. + (paper_block_for, first_layout) +} + +/// Place the BLOCKS entities: model and paper space blocks go to their space, the rest become +/// block definitions. +fn place_blocks(d: &mut Drawing, blocks: Vec, rx: &mut Rx, paper_block_for: &HashMap, first_layout: &str) { for (name, base, description, ents, xref) in blocks { let up = name.to_ascii_uppercase(); if up == "*MODEL_SPACE" { @@ -1812,7 +1877,7 @@ fn read_budgeted(bytes: &[u8], mut table_cells_left: usize) -> Result { continue; } if up.starts_with("*PAPER_SPACE") { - let lname = paper_block_for.get(&up).cloned().unwrap_or_else(|| first_layout.clone()); + let lname = paper_block_for.get(&up).cloned().unwrap_or_else(|| first_layout.to_string()); if let Some(l) = d.layouts.iter_mut().find(|l| l.name == lname) { for (_, _, e) in ents { l.entities.push(e); @@ -1852,13 +1917,22 @@ fn read_budgeted(bytes: &[u8], mut table_cells_left: usize) -> Result { } d.blocks.insert(name, std::sync::Arc::new(b)); } - // ENTITIES section: model space, or paper space when flagged (67=1). +} + +/// Place the ENTITIES section: model space, or paper space when flagged (67=1). +fn place_entities( + d: &mut Drawing, + entities: Vec<(Option, bool, Entity)>, + rx: &Rx, + paper_block_for: &HashMap, + first_layout: &str, +) { for (owner, paper, e) in entities { d.ensure_layer(&e.common.layer); let owner_name = owner.and_then(|o| rx.brs.get(&o.trim().to_ascii_uppercase()).cloned()).map(|n| n.to_ascii_uppercase()); let to_paper = paper || owner_name.as_deref().is_some_and(|n| n.starts_with("*PAPER_SPACE")); if to_paper { - let lname = owner_name.and_then(|n| paper_block_for.get(&n).cloned()).unwrap_or_else(|| first_layout.clone()); + let lname = owner_name.and_then(|n| paper_block_for.get(&n).cloned()).unwrap_or_else(|| first_layout.to_string()); if let Some(l) = d.layouts.iter_mut().find(|l| l.name == lname) { l.entities.push(e); } @@ -1866,21 +1940,6 @@ fn read_budgeted(bytes: &[u8], mut table_cells_left: usize) -> Result { d.model.push(e); } } - apply_sortents(&mut d, &objs.sortents, &rx.brs, &paper_block_for, &first_layout); - objs.apply(&mut d, &rx); - crate::dxf_image::resolve(&secs, &mut d); - crate::dxf_mline::resolve(&secs, &mut d); - // Keep handles unique against the header's seed. - let seed = d.header.str("HANDSEED", ""); - if let Some(h) = own_seed.or_else(|| Handle::parse_hex(&seed)) { - d.bump_handseed(Handle(h.0.saturating_sub(1))); - } - if d.layer("0").is_none() { - d.layers.insert(0, Layer::default()); - } - crate::dxf_raw::read(&secs, &mut d); - d.skipped_records = skipped; - Ok(d) } fn count_skipped(skipped: &mut std::collections::BTreeMap, section: &str, n: usize) { @@ -1979,6 +2038,8 @@ struct Objects { layout_views: Vec<(String, (Vec2, f64))>, /// SORTENTSTABLE objects: (owning block record handle, entity handle → sort handle). sortents: Vec<(String, HashMap)>, + /// WIPEOUTVARIABLES: the drawing's WIPEOUTFRAME (0 = frames hidden, 1 = shown). + wipeout_frame: Option, } const MAX_OBJECTS: usize = 1_000_000; @@ -2017,6 +2078,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); @@ -2046,10 +2108,7 @@ impl Objects { Some(text) } - fn apply(self, d: &mut Drawing, rx: &Rx) { - 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); - } + fn apply_table_styles(&self, d: &mut Drawing) { // Table styles (named by their ACAD_TABLESTYLE dictionary entries). for (h, tags) in &self.table_styles { let Some(name) = self.names.get(h).filter(|n| !n.is_empty()) else { continue }; @@ -2059,6 +2118,9 @@ impl Objects { None => d.table_styles.push(st), } } + } + + fn apply_table_style_fixes(&self, d: &mut Drawing, rx: &Rx) { for (h, sh) in &rx.table_style_fix { let name = self.names.get(sh).cloned(); // A table from another writer has no scale of its own: its text height is its @@ -2079,6 +2141,61 @@ impl Objects { } }); } + } + + fn apply_mleader_styles(&self, d: &mut Drawing, rx: &Rx) { + // Multileader styles (named by their ACAD_MLEADERSTYLE dictionary entries). + for (h, tags) in &self.mleader_styles { + let Some(name) = self.names.get(h).filter(|n| !n.is_empty()) else { continue }; + let t = T(tags); + let def = MLeaderStyle::default(); + let num = |code: i32, d: f64, min: f64| t.f(code).filter(|v| v.is_finite() && *v >= min).unwrap_or(d); + let text_style = t.s(342).and_then(|sh| rx.styles.get(&sh.trim().to_ascii_uppercase()).cloned()).filter(|n| !n.is_empty()); + let st = MLeaderStyle { + name: name.clone(), + arrow_size: num(44, def.arrow_size, 0.0), + text_height: t.f(45).filter(|v| v.is_finite() && *v > 0.0).unwrap_or(def.text_height), + landing_gap: num(42, def.landing_gap, 0.0), + dogleg: num(43, def.dogleg, 0.0), + text_style: text_style.unwrap_or(def.text_style), + annotative: t.i(296).unwrap_or(0) != 0, + }; + match d.mleader_styles.iter_mut().find(|s| s.name.eq_ignore_ascii_case(name)) { + Some(x) => *x = st, + None => d.mleader_styles.push(st), + } + } + } + + fn apply_groups(&self, d: &mut Drawing) { + // Groups (named by their ACAD_GROUP dictionary entries); members that exist. + for (h, tags) in &self.groups { + let Some(name) = self.names.get(h).filter(|n| !n.is_empty()) else { continue }; + let t = T(tags); + let members: Vec = tags + .iter() + .filter(|x| x.code == 340) + .filter_map(|x| Handle::parse_hex(&x.str())) + .filter(|m| d.entity(*m).is_some()) + .take(MAX_OBJECTS) + .collect(); + let g = Group { name: name.clone(), description: t.s(300).unwrap_or_default(), selectable: t.i(71).unwrap_or(1) != 0, members }; + match d.groups.iter_mut().find(|x| x.name.eq_ignore_ascii_case(name)) { + Some(x) => *x = g, + None => d.groups.push(g), + } + } + } + + 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); + } + self.apply_table_styles(d); + self.apply_table_style_fixes(d, rx); // Parametric constraints and parameters. if let Some((c, p)) = self.xrecord_text(crate::dxf_ext::CONSTRAINTS_KEY).and_then(|t| crate::dxf_ext::parse_constraints(&t)) { d.constraints = c; @@ -2102,50 +2219,14 @@ impl Objects { if let Some(tables) = self.xrecord_text(crate::dxf_ext::PLOT_STYLES_KEY).and_then(|t| crate::dxf_ext::parse_plot_styles(&t)) { d.plot_style_tables = tables; } - // Multileader styles (named by their ACAD_MLEADERSTYLE dictionary entries). - for (h, tags) in &self.mleader_styles { - let Some(name) = self.names.get(h).filter(|n| !n.is_empty()) else { continue }; - let t = T(tags); - let def = MLeaderStyle::default(); - let num = |code: i32, d: f64, min: f64| t.f(code).filter(|v| v.is_finite() && *v >= min).unwrap_or(d); - let text_style = t.s(342).and_then(|sh| rx.styles.get(&sh.trim().to_ascii_uppercase()).cloned()).filter(|n| !n.is_empty()); - let st = MLeaderStyle { - name: name.clone(), - arrow_size: num(44, def.arrow_size, 0.0), - text_height: t.f(45).filter(|v| v.is_finite() && *v > 0.0).unwrap_or(def.text_height), - landing_gap: num(42, def.landing_gap, 0.0), - dogleg: num(43, def.dogleg, 0.0), - text_style: text_style.unwrap_or(def.text_style), - annotative: t.i(296).unwrap_or(0) != 0, - }; - match d.mleader_styles.iter_mut().find(|s| s.name.eq_ignore_ascii_case(name)) { - Some(x) => *x = st, - None => d.mleader_styles.push(st), - } - } + self.apply_mleader_styles(d, rx); crate::dxf_mleader::apply(d, &rx.mleader_fix, &self.names); for (name, view) in &self.layout_views { if let Some(l) = d.layouts.iter_mut().find(|l| l.name == *name) { l.view = Some(*view); } } - // Groups (named by their ACAD_GROUP dictionary entries); members that exist. - for (h, tags) in &self.groups { - let Some(name) = self.names.get(h).filter(|n| !n.is_empty()) else { continue }; - let t = T(tags); - let members: Vec = tags - .iter() - .filter(|x| x.code == 340) - .filter_map(|x| Handle::parse_hex(&x.str())) - .filter(|m| d.entity(*m).is_some()) - .take(MAX_OBJECTS) - .collect(); - let g = Group { name: name.clone(), description: t.s(300).unwrap_or_default(), selectable: t.i(71).unwrap_or(1) != 0, members }; - match d.groups.iter_mut().find(|x| x.name.eq_ignore_ascii_case(name)) { - Some(x) => *x = g, - None => d.groups.push(g), - } - } + self.apply_groups(d); // Standard associativity, for dimensions without CADCraft's exact links. for tags in &self.dimassocs { dimassoc(d, tags); diff --git a/crates/io/src/dxf_write.rs b/crates/io/src/dxf_write.rs index 94567c678..cc4de9a61 100644 --- a/crates/io/src/dxf_write.rs +++ b/crates/io/src/dxf_write.rs @@ -1393,16 +1393,21 @@ fn dim_block_entities(d: &Drawing, dm: &Dimension, layer: &str) -> Vec { // dimension's own colour (#1052). let in_color = |color: Color| Common { layer: layer.into(), color, ..Common::default() }; let (dim_c, ext_c, text_c) = (in_color(style.dim_line_color), in_color(style.ext_line_color), in_color(style.text_color)); - for (i, l) in g.lines.iter().enumerate() { + let line_entities = |i: usize, l: &[Vec2]| -> Vec { let c = match g.line_roles.get(i) { Some(cadcraft_render::LineRole::Ext) => &ext_c, _ => &dim_c, }; - for seg in l.windows(2) { - if let [a, b] = seg { - out.push(Entity { handle: Handle(0), common: c.clone(), kind: EntityKind::Line(Line { a: a.to3(0.0), b: b.to3(0.0) }) }); - } - } + l.windows(2) + .filter_map(|seg| match seg { + [a, b] => Some(Entity { handle: Handle(0), common: c.clone(), kind: EntityKind::Line(Line { a: a.to3(0.0), b: b.to3(0.0) }) }), + _ => None, + }) + .collect() + }; + // The text's marks (arc length symbol, DIMINSPECT frame) go after the text background. + for (i, l) in g.lines.iter().enumerate().filter(|(i, _)| !g.text_marks.contains(i)) { + out.extend(line_entities(i, l)); } for t in &g.fills { if let [a, b, cc] = t.as_slice() { @@ -1413,6 +1418,20 @@ fn dim_block_entities(d: &Drawing, dm: &Dimension, layer: &str) -> Vec { }); } } + // DIMTFILL 2: the text background in DIMTFILLCLR, under the text and its marks (SOLID + // corners go 1, 2, 4, 3). + if style.text_fill == 2 + && let [p0, p1, p2, p3] = g.text_fill.as_slice() + { + out.push(Entity { + handle: Handle(0), + common: in_color(style.text_fill_color), + kind: EntityKind::Solid(Solid { corners: [p0.to3(0.0), p1.to3(0.0), p3.to3(0.0), p2.to3(0.0)] }), + }); + } + for (i, l) in g.lines.iter().enumerate().filter(|(i, _)| g.text_marks.contains(i)) { + out.extend(line_entities(i, l)); + } if !g.value.is_empty() { out.push(Entity { handle: Handle(0), @@ -2290,8 +2309,13 @@ fn tags_for(d: &Drawing, version: Version, target: Version) -> Vec { w.dimval(*code, enc, false); continue; } - // Table groups where the file version has them, else xdata. - if version >= dxf_ext::NEWER_SINCE { + // Table groups where the file version has them, else xdata. DIMTFILL and DIMTFILLCLR + // are written only when set, as other writers do, so a style without a text + // background has a single 70 (its flags). + if version >= dxf_ext::newer_since(*code) { + if matches!(*code, 69 | 70) && v.get(*field) == defaults.get(*field) { + continue; + } w.dimval(*code, enc, false); } else if v.get(*field) != defaults.get(*field) { newer.i(1070, i64::from(*code)); @@ -2349,7 +2373,7 @@ fn tags_for(d: &Drawing, version: Version, target: Version) -> Vec { // ---------------- BLOCKS ---------------- w.s(0, "SECTION"); w.s(2, "BLOCKS"); - let block = |w: &mut W, name: &str, desc: &str, brh: &str, base: Vec3, flags: i64, ents: &mut dyn Iterator, paper: bool| { + let block = |w: &mut W, (name, desc): (&str, &str), brh: &str, base: Vec3, flags: i64, ents: &mut dyn Iterator, paper: bool| { let bh = w.h(); w.s(0, "BLOCK"); w.s(5, bh); @@ -2382,16 +2406,16 @@ fn tags_for(d: &Drawing, version: Version, target: Version) -> Vec { w.s(8, "0"); w.s(100, "AcDbBlockEnd"); }; - block(&mut w, "*Model_Space", "", &ms_br, Vec3::ZERO, 0, &mut std::iter::empty(), false); + block(&mut w, ("*Model_Space", ""), &ms_br, Vec3::ZERO, 0, &mut std::iter::empty(), false); for (i, (n, h, l)) in ps_brs.iter().enumerate() { if i == 0 { - block(&mut w, n, "", h, Vec3::ZERO, 0, &mut std::iter::empty(), true); + block(&mut w, (n, ""), h, Vec3::ZERO, 0, &mut std::iter::empty(), true); } else { - block(&mut w, n, "", h, Vec3::ZERO, 0, &mut sheet_first(l).into_iter(), true); + block(&mut w, (n, ""), h, Vec3::ZERO, 0, &mut sheet_first(l).into_iter(), true); } } for (n, h, b) in &user_blocks { - block(&mut w, n, &b.description, h, b.base, if b.anonymous { 1 } else { 0 }, &mut b.entities.iter().map(|e| e.as_ref()), false); + block(&mut w, (n, &b.description), h, b.base, if b.anonymous { 1 } else { 0 }, &mut b.entities.iter().map(|e| e.as_ref()), false); } // Generated geometry entities (dimension, arrowhead and table blocks) need handles. for (_, _, ents) in dim_defs.iter_mut().chain(arrow_defs.iter_mut()).chain(table_defs.iter_mut()) { @@ -2401,13 +2425,13 @@ fn tags_for(d: &Drawing, version: Version, target: Version) -> Vec { } } for (n, h, ents) in &dim_defs { - block(&mut w, n, "", h, Vec3::ZERO, 1, &mut ents.iter(), false); + block(&mut w, (n, ""), h, Vec3::ZERO, 1, &mut ents.iter(), false); } for (n, h, ents) in &arrow_defs { - block(&mut w, n, "", h, Vec3::ZERO, 0, &mut ents.iter(), false); + block(&mut w, (n, ""), h, Vec3::ZERO, 0, &mut ents.iter(), false); } for (n, h, ents) in &table_defs { - block(&mut w, n, "", h, Vec3::ZERO, 1, &mut ents.iter(), false); + block(&mut w, (n, ""), h, Vec3::ZERO, 1, &mut ents.iter(), false); } w.s(0, "ENDSEC"); @@ -2588,7 +2612,7 @@ fn tags_for(d: &Drawing, version: Version, target: Version) -> Vec { w.s(3, &l.name); w.s(350, layout_handles.get(i).cloned().unwrap_or_default()); } - let layout_obj = |w: &mut W, h: &str, name: &str, tab: i64, brh: &str, flags: i64, page: &PageSetup, view: Option<(Vec2, f64)>| { + let layout_obj = |w: &mut W, h: &str, (name, tab): (&str, i64), brh: &str, flags: i64, page: &PageSetup, view: Option<(Vec2, f64)>| { // Plot scale: a standard scale when fitting or 1:1, otherwise custom (142 paper units // per 143 drawing units). let scale = if page.scale.is_finite() && page.scale > 0.0 { page.scale } else { 1.0 }; @@ -2673,10 +2697,10 @@ fn tags_for(d: &Drawing, version: Version, target: Version) -> Vec { // plot type 5, "Layout information" (DXF Reference, PLOTSETTINGS group 74), which means // nothing for model space: another program's Plot dialog then shows no plot area or scale. let model_page = PageSetup { plot_area: "extents".into(), scale_to_fit: true, ..PageSetup::default() }; - layout_obj(&mut w, &model_layout, "Model", 0, &ms_br, 1, &model_page, None); + layout_obj(&mut w, &model_layout, ("Model", 0), &ms_br, 1, &model_page, None); for (i, (_, brh, l)) in ps_brs.iter().enumerate() { let h = layout_handles.get(i).cloned().unwrap_or_default(); - layout_obj(&mut w, &h, &l.name, i64::from(l.tab_order.max(1)), brh, 1, &l.page, l.view); + layout_obj(&mut w, &h, (&l.name, i64::from(l.tab_order.max(1))), brh, 1, &l.page, l.view); } // Raster image definitions. w.t.extend(crate::dxf_image::objects(&cx.images, &root_dict)); diff --git a/crates/io/src/pdf.rs b/crates/io/src/pdf.rs index a4d9f5ea7..3f35233a8 100644 --- a/crates/io/src/pdf.rs +++ b/crates/io/src/pdf.rs @@ -17,7 +17,7 @@ use std::fmt::Write as _; use cadcraft_color::Rgb; use cadcraft_doc::{Drawing, Handle, PageSetup, Space}; use cadcraft_geom::{Bounds2, Vec2}; -use cadcraft_render::{Kind, Sheet, clip, paper}; +use cadcraft_render::{DPrim, Kind, Sheet, clip, paper}; use serde_json::Value; use craft_pdf::write::backend as kr; @@ -808,7 +808,7 @@ fn krilla_failure(e: KrillaError) -> Failure { /// as text instead: the glyph at font size 1 under its glyph matrix (the matrix axes are one /// em). fn marks(s: &mut Surface<'_>, p: &SheetPage, groups: &Groups, fonts: &mut fonts::Fonts) -> std::result::Result<(), Failure> { - let (list, map) = (&p.list, &p.map); + let list = &p.list; let texts = fonts::texts(list); let white = Rgb(255, 255, 255); let mut batch: Option<(StrokeStyle, PathBuilder)> = None; @@ -829,100 +829,126 @@ fn marks(s: &mut Surface<'_>, p: &SheetPage, groups: &Groups, fonts: &mut fonts: let t = if prim.kind == Kind::Mask { 0 } else { prim.transparency.min(cadcraft_render::MAX_TRANSPARENCY) }; let w = f64::from(prim.lw) * PT_PER_MM; let w = if w.is_finite() && w > 0.0 { w.min(100.0) } else { 0.0 }; + let m = Mark { rgb, t, w }; match prim.kind { - Kind::Polyline | Kind::Infinite { .. } => { - let pts: Vec = match prim.kind { - Kind::Infinite { ray } => match (raw.first(), raw.get(1)) { - (Some(base), Some(dir)) => { - let a = map.pt(*base); - let dir_pt = map.pt(*base + *dir) - a; - match clip::clip_infinite(a, dir_pt, ray, &p.clip_pt) { - Some((x, y)) => vec![x, y], - None => continue, - } - } - _ => continue, - }, - _ => raw.iter().map(|q| map.pt(*q)).collect(), - }; - if pts.len() < 2 { - continue; - } - // With a vertex out of range, keep the pieces inside the plot area, widened so - // a wide stroke just outside it still shows its edge. - let runs = if pts.iter().all(|q| coord(*q).is_some()) { vec![pts] } else { clip::clip_polyline(&pts, &p.clip_pt.expand(w + 1.0)) }; - let style = StrokeStyle { rgb, width: w as f32, transparency: t }; - if t > 0 || batch.as_ref().is_some_and(|(b, _)| *b != style) { - flush(s, &mut batch); - } - for run in runs { - let Some(pts) = run.iter().map(|q| coord(*q)).collect::>>() else { continue }; - // The writer leaves out a path without extent, so a zero-length line is - // painted as the round-cap dot its stroke would make. - if let Some(&(x, y)) = pts.first() - && pts.iter().all(|q| *q == (x, y)) - { - flush(s, &mut batch); - let d = if w > 0.0 { w as f32 } else { HAIRLINE_DOT_PT }; - if let Some(path) = circle_path(x, y, d / 2.0) { - fill_path(s, &path, rgb, t); - } - continue; - } - let (_, pb) = batch.get_or_insert_with(|| (style, PathBuilder::new())); - for (j, (x, y)) in pts.iter().enumerate() { - if j == 0 { - pb.move_to(*x, *y); - } else { - pb.line_to(*x, *y); - } - } - } - } + Kind::Polyline | Kind::Infinite { .. } => polyline(s, &mut batch, p, prim, raw, m), Kind::Tris | Kind::Mask => { flush(s, &mut batch); // A wipeout's mask is paper white. - let rgb = if prim.kind == Kind::Mask { white } else { rgb }; - if let Some(text) = u32::try_from(i).ok().and_then(|i| texts.get(&i)) - && let Some(font) = fonts.font(&text.glyph.face) - { - glyph(s, font, text, map, rgb, t); - continue; - } - let mapped: Vec = raw.iter().map(|q| map.pt(*q)).collect(); - // With a vertex out of range, keep the parts inside the plot area. - let tris = if mapped.iter().all(|q| coord(*q).is_some()) { mapped } else { clip::clip_triangles(&mapped, &p.clip_pt) }; - let mut pb = PathBuilder::new(); - for tri in tris.as_chunks::<3>().0 { - let Some(v) = tri.iter().map(|q| coord(*q)).collect::>>() else { continue }; - for (j, (x, y)) in v.iter().enumerate() { - if j == 0 { - pb.move_to(*x, *y); - } else { - pb.line_to(*x, *y); - } - } - pb.close(); - } - if let Some(path) = pb.finish() { - fill_path(s, &path, rgb, t); - } - } - Kind::Point => { - let Some(q) = raw.first() else { continue }; - flush(s, &mut batch); - let half = (w.max(0.5)) / 2.0; - let c = map.pt(*q); - if let Some(path) = rect_path(c - Vec2::new(half, half), c + Vec2::new(half, half)) { - fill_path(s, &path, rgb, t); + let m = if prim.kind == Kind::Mask { Mark { rgb: white, ..m } } else { m }; + let text_font = + u32::try_from(i).ok().and_then(|i| texts.get(&i).copied()).and_then(|text| fonts.font(&text.glyph.face).map(|font| (text, font))); + match text_font { + Some((text, font)) => glyph(s, font, text, &p.map, m.rgb, m.t), + None => fill_tris(s, p, raw, m.rgb, m.t), } } + Kind::Point => point(s, &mut batch, &p.map, raw, m), } } flush(s, &mut batch); layer_changed(s, layer, None) } +/// How a primitive is painted: colour, transparency (percent) and lineweight (points). +#[derive(Clone, Copy)] +struct Mark { + rgb: Rgb, + t: u8, + w: f64, +} + +/// Stroke a polyline or infinite line, clipped to the plot area, into the open batch. +fn polyline(s: &mut Surface<'_>, batch: &mut Option<(StrokeStyle, PathBuilder)>, p: &SheetPage, prim: &DPrim, raw: &[Vec2], m: Mark) { + let Some(pts) = polyline_points(prim, raw, &p.map, &p.clip_pt) else { return }; + if pts.len() < 2 { + return; + } + // With a vertex out of range, keep the pieces inside the plot area, widened so + // a wide stroke just outside it still shows its edge. + let runs = if pts.iter().all(|q| coord(*q).is_some()) { vec![pts] } else { clip::clip_polyline(&pts, &p.clip_pt.expand(m.w + 1.0)) }; + let style = StrokeStyle { rgb: m.rgb, width: m.w as f32, transparency: m.t }; + if m.t > 0 || batch.as_ref().is_some_and(|(b, _)| *b != style) { + flush(s, batch); + } + for run in runs { + stroke_run(s, batch, &run, style, m); + } +} + +/// The page points of a polyline, or of an infinite line clipped to the plot area; `None` when +/// there is nothing to draw. +fn polyline_points(prim: &DPrim, raw: &[Vec2], map: &Map, clip_pt: &Bounds2) -> Option> { + match prim.kind { + Kind::Infinite { ray } => { + let (Some(base), Some(dir)) = (raw.first(), raw.get(1)) else { return None }; + let a = map.pt(*base); + let dir_pt = map.pt(*base + *dir) - a; + let (x, y) = clip::clip_infinite(a, dir_pt, ray, clip_pt)?; + Some(vec![x, y]) + } + _ => Some(raw.iter().map(|q| map.pt(*q)).collect()), + } +} + +/// Add one run of a polyline to the batch, or paint it as a dot when it has no extent. +fn stroke_run(s: &mut Surface<'_>, batch: &mut Option<(StrokeStyle, PathBuilder)>, run: &[Vec2], style: StrokeStyle, m: Mark) { + let Some(pts) = run.iter().map(|q| coord(*q)).collect::>>() else { return }; + // The writer leaves out a path without extent, so a zero-length line is + // painted as the round-cap dot its stroke would make. + if let Some(&(x, y)) = pts.first() + && pts.iter().all(|q| *q == (x, y)) + { + flush(s, batch); + let d = if m.w > 0.0 { m.w as f32 } else { HAIRLINE_DOT_PT }; + if let Some(path) = circle_path(x, y, d / 2.0) { + fill_path(s, &path, m.rgb, m.t); + } + return; + } + let (_, pb) = batch.get_or_insert_with(|| (style, PathBuilder::new())); + for (j, (x, y)) in pts.iter().enumerate() { + if j == 0 { + pb.move_to(*x, *y); + } else { + pb.line_to(*x, *y); + } + } +} + +/// Fill a set of triangles, clipped to the plot area. +fn fill_tris(s: &mut Surface<'_>, p: &SheetPage, raw: &[Vec2], rgb: Rgb, t: u8) { + let mapped: Vec = raw.iter().map(|q| p.map.pt(*q)).collect(); + // With a vertex out of range, keep the parts inside the plot area. + let tris = if mapped.iter().all(|q| coord(*q).is_some()) { mapped } else { clip::clip_triangles(&mapped, &p.clip_pt) }; + let mut pb = PathBuilder::new(); + for tri in tris.as_chunks::<3>().0 { + let Some(v) = tri.iter().map(|q| coord(*q)).collect::>>() else { continue }; + for (j, (x, y)) in v.iter().enumerate() { + if j == 0 { + pb.move_to(*x, *y); + } else { + pb.line_to(*x, *y); + } + } + pb.close(); + } + if let Some(path) = pb.finish() { + fill_path(s, &path, rgb, t); + } +} + +/// Paint a point marker: a small square at the point. +fn point(s: &mut Surface<'_>, batch: &mut Option<(StrokeStyle, PathBuilder)>, map: &Map, raw: &[Vec2], m: Mark) { + let Some(q) = raw.first() else { return }; + flush(s, batch); + let half = (m.w.max(0.5)) / 2.0; + let c = map.pt(*q); + if let Some(path) = rect_path(c - Vec2::new(half, half), c + Vec2::new(half, half)) { + fill_path(s, &path, m.rgb, m.t); + } +} + /// Write one glyph as text in its embedded font: font size 1, placed by the glyph's matrix (one /// em along each axis). A glyph whose matrix is out of range or collapses is left out. fn glyph(s: &mut Surface<'_>, font: kr::text::Font, t: &cadcraft_render::PlotText, map: &Map, rgb: Rgb, transparency: u8) { diff --git a/crates/io/src/tests.rs b/crates/io/src/tests.rs index 7b8f38385..d5fe5d2f7 100644 --- a/crates/io/src/tests.rs +++ b/crates/io/src/tests.rs @@ -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 = (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 diff --git a/crates/io/tests/it/dimtfill_roundtrip.rs b/crates/io/tests/it/dimtfill_roundtrip.rs new file mode 100644 index 000000000..5e1b4f1bf --- /dev/null +++ b/crates/io/tests/it/dimtfill_roundtrip.rs @@ -0,0 +1,202 @@ +//! DIMTFILL (group 69) and DIMTFILLCLR (group 70) survive DXF: as DIMSTYLE groups from R2007, +//! after the record's own flags (also 70) and only when set, and in the style's `CADCRAFT` +//! "DIMVARS" xdata in older files. The `*D` block of a dimension with DIMTFILL 2 holds the background as a SOLID. + +use cadcraft_color::Color; +use cadcraft_doc::{Common, DimKind, DimStyle, Dimension, Drawing, EntityKind, Space}; +use cadcraft_geom::Vec3; +use cadcraft_io::{SaveOptions, Version}; + +fn filled() -> DimStyle { + DimStyle { name: "Fill".into(), text_fill: 2, text_fill_color: Color::Index(3), ..DimStyle::default() } +} + +fn drawing() -> Drawing { + let mut d = Drawing::new_imperial(); + d.dim_styles.push(filled()); + d +} + +fn dxf(d: &Drawing, v: Version) -> String { + String::from_utf8(cadcraft_io::write_with(d, "x.dxf", &SaveOptions { version: Some(v), binary: false }).unwrap()).unwrap() +} + +/// The DIMSTYLE record named `name`, as trimmed lines. +fn record<'a>(text: &'a str, name: &str) -> Vec<&'a str> { + let l: Vec<&str> = text.lines().map(str::trim).collect(); + let at = l.windows(2).position(|w| w == ["2", name]).unwrap(); + let end = l[at..].windows(2).position(|w| w == ["0", "DIMSTYLE"] || w == ["0", "ENDTAB"]).map_or(l.len(), |e| at + e); + l[at..end].to_vec() +} + +#[test] +fn text_fill_roundtrips_in_the_dimstyle_table() { + let d = drawing(); + for v in [Version::R2007, Version::R2018] { + let text = dxf(&d, v); + let rec = record(&text, "Fill"); + let groups: Vec<(&str, &str)> = rec.chunks(2).filter_map(|p| Some((*p.first()?, *p.get(1)?))).collect(); + assert!(groups.contains(&("69", "2")), "{v:?}: DIMTFILL as group 69"); + let seventies: Vec<&str> = groups.iter().filter(|g| g.0 == "70").map(|g| g.1).collect(); + assert_eq!(seventies, ["0", "3"], "{v:?}: the flags, then DIMTFILLCLR"); + assert!(!rec.contains(&"DIMVARS"), "{v:?}: no xdata"); + let back = cadcraft_io::read_dxf(text.as_bytes()).unwrap(); + assert_eq!(back.dim_style("Fill").unwrap(), &filled(), "{v:?}"); + } + for v in [Version::R2000, Version::R2004] { + let text = dxf(&d, v); + let rec = record(&text, "Fill"); + assert!(!rec.chunks(2).any(|p| p[0] == "69"), "{v:?}: no group 69"); + assert!(rec.windows(4).any(|w| w == ["1070", "69", "1070", "2"]), "{v:?}: DIMTFILL in the xdata"); + assert!(rec.windows(4).any(|w| w == ["1070", "70", "1070", "3"]), "{v:?}: DIMTFILLCLR in the xdata"); + let back = cadcraft_io::read_dxf(text.as_bytes()).unwrap(); + assert_eq!(back.dim_style("Fill").unwrap(), &filled(), "{v:?}"); + } +} + +#[test] +fn unset_text_fill_writes_no_groups() { + let mut d = Drawing::new_imperial(); + d.dim_styles.push(DimStyle { name: "Plain".into(), ..DimStyle::default() }); + d.dim_styles.push(DimStyle { name: "Canvas".into(), text_fill: 1, ..DimStyle::default() }); + let text = dxf(&d, Version::R2018); + let groups = |name| -> Vec<(String, String)> { + record(&text, name).chunks(2).filter_map(|p| Some((p.first()?.to_string(), p.get(1)?.to_string()))).collect() + }; + let plain = groups("Plain"); + // The record's flags are its only 70. + assert_eq!(plain.iter().filter(|g| g.0 == "70").count(), 1, "{plain:?}"); + assert!(!plain.iter().any(|g| g.0 == "69"), "{plain:?}"); + let canvas = groups("Canvas"); + assert!(canvas.contains(&("69".into(), "1".into())), "{canvas:?}"); + assert_eq!(canvas.iter().filter(|g| g.0 == "70").count(), 1, "the default DIMTFILLCLR is not written: {canvas:?}"); + let back = cadcraft_io::read_dxf(text.as_bytes()).unwrap(); + assert_eq!(back.dim_style("Plain").unwrap().text_fill, 0); + assert_eq!(back.dim_style("Canvas").unwrap().text_fill, 1); +} + +#[test] +fn hostile_text_fill_groups_fall_back_to_defaults() { + let text = dxf(&drawing(), Version::R2018); + for (fill, color) in [("99", "3"), ("-5", "3"), ("2", "40000"), ("2", "-7")] { + let edited = text.replacen(" 69\r\n2\r\n 70\r\n3\r\n", &format!(" 69\r\n{fill}\r\n 70\r\n{color}\r\n"), 1); + assert_ne!(edited, text, "groups replaced"); + let back = cadcraft_io::read_dxf(edited.as_bytes()).unwrap(); + let st = back.dim_style("Fill").unwrap(); + assert!(st.text_fill <= 2, "{fill}/{color}: {}", st.text_fill); + } +} + +#[test] +fn record_flags_are_not_read_as_dimtfillclr() { + let text = dxf(&drawing(), Version::R2018); + // Flags 64 (referenced) and no DIMTFILLCLR group: the color stays ByBlock. + let edited = + text.replacen(" 2\r\nFill\r\n 70\r\n0\r\n", " 2\r\nFill\r\n 70\r\n64\r\n", 1).replacen(" 69\r\n2\r\n 70\r\n3\r\n", " 69\r\n2\r\n", 1); + assert!(edited.contains(" 2\r\nFill\r\n 70\r\n64\r\n"), "flags edited"); + assert!(edited.contains(" 69\r\n2\r\n") && !edited.contains(" 69\r\n2\r\n 70\r\n3\r\n"), "DIMTFILLCLR removed"); + let back = cadcraft_io::read_dxf(edited.as_bytes()).unwrap(); + let st = back.dim_style("Fill").unwrap(); + assert_eq!((st.text_fill, st.text_fill_color), (2, Color::ByBlock)); +} + +#[test] +fn reopened_dimension_keeps_its_background_mask() { + let mut d = Drawing::new_imperial(); + d.dim_styles.push(DimStyle { name: "Fill".into(), text_fill: 1, ..DimStyle::default() }); + d.add(&Space::Model, Common::default(), EntityKind::Dimension(Dimension { style: "Fill".into(), ..linear() })).unwrap(); + let back = cadcraft_io::read_dxf(cadcraft_io::write_dxf(&d).as_bytes()).unwrap(); + assert!(back.model.iter().any(|e| matches!(&e.kind, EntityKind::Dimension(dm) if dm.block.is_some())), "read with its block"); + let list = cadcraft_render::build(&back, &Space::Model, &cadcraft_render::Options::default()); + assert_eq!(list.prims.iter().filter(|p| p.kind == cadcraft_render::Kind::Mask).count(), 1); +} + +#[test] +fn reopened_color_fill_is_drawn_once() { + let mut d = drawing(); + d.add(&Space::Model, Common::default(), EntityKind::Dimension(Dimension { style: "Fill".into(), ..linear() })).unwrap(); + let back = cadcraft_io::read_dxf(cadcraft_io::write_dxf(&d).as_bytes()).unwrap(); + let list = cadcraft_render::build(&back, &Space::Model, &cadcraft_render::Options::default()); + // The block's SOLID in DIMTFILLCLR (color 3, green) is the background; no second one is added. + let green = list.prims.iter().filter(|p| p.kind == cadcraft_render::Kind::Tris && p.color == cadcraft_color::Rgb(0, 255, 0)).count(); + assert_eq!(green, 1); +} + +#[test] +fn reopened_inspection_frame_stays_over_the_background() { + let mut d = Drawing::new_imperial(); + d.dim_styles.push(DimStyle { name: "Fill".into(), text_fill: 1, ..DimStyle::default() }); + let mut dm = Dimension { style: "Fill".into(), ..linear() }; + dm.marks.inspect = Some(cadcraft_doc::Inspection { shape: 1, label: "A".into(), rate: String::new() }); + d.add(&Space::Model, Common::default(), EntityKind::Dimension(dm)).unwrap(); + let back = cadcraft_io::read_dxf(cadcraft_io::write_dxf(&d).as_bytes()).unwrap(); + let dm = back + .model + .iter() + .find_map(|e| match &e.kind { + EntityKind::Dimension(dm) => Some(dm.clone()), + _ => None, + }) + .unwrap(); + assert!(dm.block.is_some(), "read with its block"); + let corners = cadcraft_render::dimension_in(&back, &dm).text_fill; + let (lo, hi) = corners.iter().fold((corners[0], corners[0]), |(lo, hi), p| (lo.min(*p), hi.max(*p))); + let inside = |q: &cadcraft_geom::Vec2| q.x >= lo.x - 1e-6 && q.x <= hi.x + 1e-6 && q.y >= lo.y - 1e-6 && q.y <= hi.y + 1e-6; + let list = cadcraft_render::build(&back, &Space::Model, &cadcraft_render::Options::default()); + let mask = list.prims.iter().position(|p| p.kind == cadcraft_render::Kind::Mask).unwrap(); + // The separator after the label, the frame's top and bottom: lines inside the background. + let marks: Vec = list + .prims + .iter() + .enumerate() + .filter(|(_, p)| p.kind == cadcraft_render::Kind::Polyline && list.points(p).len() == 2 && list.points(p).iter().all(inside)) + .map(|(i, _)| i) + .collect(); + assert!(marks.len() >= 3 && marks.iter().all(|i| *i > mask), "{marks:?} after the mask at {mask}"); +} + +/// A linear dimension from (0, 0) to (10, 0) with its line at y = 5. +fn linear() -> Dimension { + Dimension { + kind: DimKind::Linear { rotation: 0.0 }, + defpt: Vec3::new(0.0, 5.0, 0.0), + text_mid: Vec3::ZERO, + p13: Vec3::ZERO, + p14: Vec3::new(10.0, 0.0, 0.0), + p15: Vec3::ZERO, + p16: Vec3::ZERO, + text: String::new(), + style: "Standard".into(), + measurement: 0.0, + text_rotation: 0.0, + user_text_pos: false, + block: None, + overrides: Default::default(), + assoc: Vec::new(), + marks: Default::default(), + } +} + +#[test] +fn dimension_block_holds_the_text_background() { + let mut d = drawing(); + // An inspection frame (top, bottom and two angular ends: six segments) goes over the background. + let mut dm = Dimension { style: "Fill".into(), ..linear() }; + dm.marks.inspect = Some(cadcraft_doc::Inspection { shape: 1, label: String::new(), rate: String::new() }); + d.add(&Space::Model, Common::default(), EntityKind::Dimension(dm)).unwrap(); + let back = cadcraft_io::read_dxf(cadcraft_io::write_dxf(&d).as_bytes()).unwrap(); + let name = back + .model + .iter() + .find_map(|e| match &e.kind { + EntityKind::Dimension(dm) => dm.block.clone(), + _ => None, + }) + .unwrap(); + let ents: Vec<_> = back.block(&name).unwrap().entities.iter().cloned().collect(); + let solid = ents.iter().position(|e| matches!(e.kind, EntityKind::Solid(_)) && e.common.color == Color::Index(3)); + let mtext = ents.iter().position(|e| matches!(e.kind, EntityKind::MText(_))); + assert!(solid.is_some() && solid < mtext, "the background before the text: {solid:?}, {mtext:?}"); + let lines_after = ents.iter().skip(solid.unwrap_or(0)).filter(|e| matches!(e.kind, EntityKind::Line(_))).count(); + assert_eq!(lines_after, 6, "the frame after the background, the other lines before it"); +} diff --git a/crates/io/tests/it/main.rs b/crates/io/tests/it/main.rs index e6382aec8..51e7f1965 100644 --- a/crates/io/tests/it/main.rs +++ b/crates/io/tests/it/main.rs @@ -13,6 +13,7 @@ mod dim_block_resave_roundtrip; mod dim_block_text; mod dim_marks_roundtrip; mod dimstyle_vars_roundtrip; +mod dimtfill_roundtrip; mod drafting_spacing; #[cfg(not(target_arch = "wasm32"))] mod dwg_header_vars; diff --git a/crates/io/tests/pdf_text.rs b/crates/io/tests/pdf_text.rs index c238b2013..baba82f9d 100644 --- a/crates/io/tests/pdf_text.rs +++ b/crates/io/tests/pdf_text.rs @@ -165,7 +165,7 @@ fn style(d: &mut Drawing, name: &str, font: &str) { d.text_styles.push(TextStyle { name: name.into(), font: font.into(), ..TextStyle::default() }); } -fn text(d: &mut Drawing, space: &Space, style: &str, at: (f64, f64), value: &str, rotation: f64, width_factor: f64, oblique: f64) { +fn text(d: &mut Drawing, space: &Space, style: &str, at: (f64, f64), value: &str, (rotation, width_factor, oblique): (f64, f64, f64)) { let t = Text { insert: Vec3::new(at.0, at.1, 0.0), align_pt: None, @@ -225,7 +225,7 @@ fn font_files(doc: &Doc) -> usize { #[test] fn truetype_text_is_embedded_as_text() { let mut d = drawing(); - text(&mut d, &Space::Model, "Real", (0.0, 0.0), "ABBA", 0.0, 1.0, 0.0); + text(&mut d, &Space::Model, "Real", (0.0, 0.0), "ABBA", (0.0, 1.0, 0.0)); let doc = pdf(&d, &Space::Model, json!({})); let page = &doc.pages[0]; // A Type0 font over a CIDFontType2 subset with a ToUnicode map. @@ -260,7 +260,7 @@ fn truetype_text_is_embedded_as_text() { #[test] fn the_subset_is_a_valid_truetype_font() { let mut d = drawing(); - text(&mut d, &Space::Model, "Real", (0.0, 0.0), "A", 0.0, 1.0, 0.0); + text(&mut d, &Space::Model, "Real", (0.0, 0.0), "A", (0.0, 1.0, 0.0)); let doc = pdf(&d, &Space::Model, json!({})); let file = doc.pages[0].fonts[0].font_file.clone().expect("an embedded font file"); let face = cadcraft_fonts::ttf::Face::new(std::sync::Arc::new(file.clone()), 0).expect("the subset parses"); @@ -273,9 +273,9 @@ fn the_subset_is_a_valid_truetype_font() { fn rotation_width_factor_and_obliquing_go_into_the_text_matrix() { let mut d = drawing(); let quarter = std::f64::consts::FRAC_PI_2; - text(&mut d, &Space::Model, "Real", (0.0, 0.0), "A", quarter, 1.0, 0.0); - text(&mut d, &Space::Model, "Real", (0.0, 50.0), "A", 0.0, 2.0, 0.0); - text(&mut d, &Space::Model, "Real", (0.0, 100.0), "A", 0.0, 1.0, 15f64.to_radians()); + text(&mut d, &Space::Model, "Real", (0.0, 0.0), "A", (quarter, 1.0, 0.0)); + text(&mut d, &Space::Model, "Real", (0.0, 50.0), "A", (0.0, 2.0, 0.0)); + text(&mut d, &Space::Model, "Real", (0.0, 100.0), "A", (0.0, 1.0, 15f64.to_radians())); let m = matrices(&pdf(&d, &Space::Model, json!({}))); assert_eq!(m.len(), 3, "{m:?}"); // Rotated a quarter turn: the baseline points up, "up" points left. @@ -294,7 +294,7 @@ fn rotation_width_factor_and_obliquing_go_into_the_text_matrix() { #[test] fn a_font_the_writer_cannot_subset_plots_as_outlines() { let mut d = drawing(); - text(&mut d, &Space::Model, "Short", (0.0, 0.0), "AB", 0.0, 1.0, 0.0); + text(&mut d, &Space::Model, "Short", (0.0, 0.0), "AB", (0.0, 1.0, 0.0)); let p = pdf(&d, &Space::Model, json!({})); assert!(font_files(&p) == 0 && pdf_read::count(p.content(), "BT") == 0 && fills(&p) > 0, "outlines: {}", p.content()); } @@ -302,16 +302,16 @@ fn a_font_the_writer_cannot_subset_plots_as_outlines() { #[test] fn fonts_that_forbid_embedding_and_the_geometry_option_draw_outlines() { let mut d = drawing(); - text(&mut d, &Space::Model, "Locked", (0.0, 0.0), "AB", 0.0, 1.0, 0.0); + text(&mut d, &Space::Model, "Locked", (0.0, 0.0), "AB", (0.0, 1.0, 0.0)); let p = pdf(&d, &Space::Model, json!({})); assert!(font_files(&p) == 0 && pdf_read::count(p.content(), "BT") == 0 && fills(&p) > 0, "restricted licence: outlines"); let mut d = drawing(); - text(&mut d, &Space::Model, "Real", (0.0, 0.0), "AB", 0.0, 1.0, 0.0); + text(&mut d, &Space::Model, "Real", (0.0, 0.0), "AB", (0.0, 1.0, 0.0)); let p = pdf(&d, &Space::Model, json!({"text": "geometry"})); assert!(font_files(&p) == 0 && pdf_read::count(p.content(), "BT") == 0 && fills(&p) > 0, "text as geometry"); // The stroke font is always geometry. let mut d = drawing(); - text(&mut d, &Space::Model, "Standard", (0.0, 0.0), "AB", 0.0, 1.0, 0.0); + text(&mut d, &Space::Model, "Standard", (0.0, 0.0), "AB", (0.0, 1.0, 0.0)); let p = pdf(&d, &Space::Model, json!({})); assert!(pdf_read::count(p.content(), "BT") == 0 && pdf_read::count(p.content(), "S") > 0); } @@ -321,8 +321,8 @@ fn text_seen_through_a_viewport_and_on_layers() { let mut d = drawing(); let paper = Space::Paper("Layout1".into()); // Model: one word well inside the viewport, one cut by its right edge. - text(&mut d, &Space::Model, "Real", (0.0, 0.0), "AB", 0.0, 1.0, 0.0); - text(&mut d, &Space::Model, "Real", (88.0, 0.0), "BA", 0.0, 1.0, 0.0); + text(&mut d, &Space::Model, "Real", (0.0, 0.0), "AB", (0.0, 1.0, 0.0)); + text(&mut d, &Space::Model, "Real", (88.0, 0.0), "BA", (0.0, 1.0, 0.0)); let vp = Viewport { center: Vec3::new(150.0, 100.0, 0.0), width: 100.0, @@ -337,7 +337,7 @@ fn text_seen_through_a_viewport_and_on_layers() { on: true, }; d.add(&paper, Common::default(), EntityKind::Viewport(vp)).unwrap(); - text(&mut d, &paper, "Real", (20.0, 20.0), "B", 0.0, 1.0, 0.0); + text(&mut d, &paper, "Real", (20.0, 20.0), "B", (0.0, 1.0, 0.0)); let p = pdf(&d, &paper, json!({})); // "AB" inside, the paper-space "B", and the glyph of "BA" left of the edge: real text. The // glyph the edge cuts stays a (clipped) outline. @@ -353,7 +353,7 @@ fn text_seen_through_a_viewport_and_on_layers() { #[test] fn text_sits_in_its_layers_marked_content() { let mut d = drawing(); - text(&mut d, &Space::Model, "Real", (0.0, 0.0), "AB", 0.0, 1.0, 0.0); + text(&mut d, &Space::Model, "Real", (0.0, 0.0), "AB", (0.0, 1.0, 0.0)); let p = pdf(&d, &Space::Model, json!({})); let ops = pdf_read::ops(p.content()); let first_bt = ops.iter().position(|o| o.op == "BT").unwrap(); diff --git a/crates/io/tests/support/pdf_read.rs b/crates/io/tests/support/pdf_read.rs index 992544089..0553a46e4 100644 --- a/crates/io/tests/support/pdf_read.rs +++ b/crates/io/tests/support/pdf_read.rs @@ -235,6 +235,46 @@ pub struct Op { pub names: Vec, } +/// The index just past the string literal opening at `b[i]`, honouring escapes and nesting. +fn skip_string(b: &[char], mut i: usize) -> usize { + let mut depth = 0; + while i < b.len() { + match b[i] { + '\\' => i += 1, + '(' => depth += 1, + ')' => { + depth -= 1; + if depth == 0 { + break; + } + } + _ => {} + } + i += 1; + } + i + 1 +} + +/// The index just past the inline dictionary opening at `b[i]` (`<<`). +fn skip_dict(b: &[char], mut i: usize) -> usize { + let mut depth = 0; + while i < b.len() { + if b[i] == '<' && b.get(i + 1) == Some(&'<') { + depth += 1; + i += 1; + } else if b[i] == '>' && b.get(i + 1) == Some(&'>') { + depth -= 1; + i += 1; + if depth == 0 { + i += 1; + break; + } + } + i += 1; + } + i +} + /// The operators of content text in order, each with its numeric operands (other operands /// are skipped). pub fn ops(content: &str) -> Vec { @@ -253,39 +293,10 @@ pub fn ops(content: &str) -> Vec { i += 1; } } else if c == '(' { - let mut depth = 0; - while i < b.len() { - match b[i] { - '\\' => i += 1, - '(' => depth += 1, - ')' => { - depth -= 1; - if depth == 0 { - break; - } - } - _ => {} - } - i += 1; - } - i += 1; + i = skip_string(&b, i); } else if c == '<' && b.get(i + 1) == Some(&'<') { // An inline dictionary (marked-content properties): skip to its end. - let mut depth = 0; - while i < b.len() { - if b[i] == '<' && b.get(i + 1) == Some(&'<') { - depth += 1; - i += 1; - } else if b[i] == '>' && b.get(i + 1) == Some(&'>') { - depth -= 1; - i += 1; - if depth == 0 { - i += 1; - break; - } - } - i += 1; - } + i = skip_dict(&b, i); } else if c == '<' { while i < b.len() && b[i] != '>' { i += 1; diff --git a/crates/io/tests/wipeout_frame.rs b/crates/io/tests/wipeout_frame.rs new file mode 100644 index 000000000..5c2c201c9 --- /dev/null +++ b/crates/io/tests/wipeout_frame.rs @@ -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) -> 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"); +} diff --git a/crates/mcp/src/backend.rs b/crates/mcp/src/backend.rs index 693839335..9d012076a 100644 --- a/crates/mcp/src/backend.rs +++ b/crates/mcp/src/backend.rs @@ -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 { - 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 { 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 { + 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 { + 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() { diff --git a/crates/render/src/dim.rs b/crates/render/src/dim.rs index c05a20014..9b7f613eb 100644 --- a/crates/render/src/dim.rs +++ b/crates/render/src/dim.rs @@ -4,7 +4,7 @@ //! fixed-length extension lines (DIMFXL/DIMFXLON), text placement (DIMTAD, DIMTVP, DIMJUST, //! DIMTIH/DIMTOH, DIMGAP, DIMTMOVE) and text formatting (DIMLUNIT, DIMDEC, DIMZIN, DIMRND, //! DIMPOST, DIMTOL/DIMLIM with DIMTZIN and DIMTOLJ, DIMALT with DIMALTU/DIMALTZ/DIMALTRND, -//! DIMAZIN, DIMARCSYM). +//! DIMAZIN, DIMARCSYM) with its background (DIMTFILL). use cadcraft_doc::{DimKind, DimStyle, Dimension}; use cadcraft_fonts::{MTextParams, TextFont}; @@ -32,6 +32,12 @@ pub struct DimGeometry { pub line_roles: Vec, /// Filled triangles (three points each) in the dimension line colour. pub fills: Vec>, + /// DIMTFILL 1 or 2: the corners of the text's background, the text box widened by DIMGAP + /// on every side (empty without a fill or text). + pub text_fill: Vec, + /// Indices into `lines` of the marks drawn with the text, over `text_fill`: the arc length + /// symbol above the text (DIMARCSYM 1) and the DIMINSPECT frame. + pub text_marks: Vec, /// Text strokes (stroke font, fraction bars, underlines). pub text: Vec>, /// Text glyph outlines (TrueType), grouped per glyph. @@ -75,6 +81,13 @@ impl DimGeometry { self.line_roles.push(LineRole::Dim); } } + /// A dimension line that marks the text (see `text_marks`). + fn text_mark(&mut self, l: Vec) { + if l.len() >= 2 { + self.text_marks.push(self.lines.len()); + self.dim(l); + } + } fn ext(&mut self, l: Vec) { if l.len() >= 2 { self.lines.push(l); @@ -88,18 +101,21 @@ impl DimGeometry { } let mut lines = Vec::with_capacity(self.lines.len()); let mut roles = Vec::with_capacity(self.lines.len()); - let mut ext2 = Vec::new(); + let (mut ext2, mut marks) = (Vec::new(), Vec::new()); for (i, (l, rl)) in self.lines.iter().zip(&self.line_roles).enumerate() { let pieces = if role.is_none_or(|x| x == *rl) { cut_polyline(l, c, r) } else { vec![l.clone()] }; for p in pieces { if self.ext2_lines.contains(&i) { ext2.push(lines.len()); } + if self.text_marks.contains(&i) { + marks.push(lines.len()); + } lines.push(p); roles.push(*rl); } } - (self.lines, self.line_roles, self.ext2_lines) = (lines, roles, ext2); + (self.lines, self.line_roles, self.ext2_lines, self.text_marks) = (lines, roles, ext2, marks); } /// Extension line 1 or 2 (`second`). fn ext_n(&mut self, l: Option>, second: bool) { @@ -987,7 +1003,7 @@ pub fn dimension_geometry_with(d: &Dimension, st: &DimStyle, dimscale: f64, font base + x * (t * half) + y * ((1.0 - t * t).max(0.0).sqrt() * th * 0.35) }) .collect(); - g.dim(sym); + g.text_mark(sym); } } } @@ -1017,24 +1033,31 @@ pub fn dimension_geometry_with(d: &Dimension, st: &DimStyle, dimscale: f64, font let mut left = -w / 2.0; if !ins.label.is_empty() { left += wl; - g.dim(vec![at(left + gap2 / 2.0, -hh), at(left + gap2 / 2.0, hh)]); + g.text_mark(vec![at(left + gap2 / 2.0, -hh), at(left + gap2 / 2.0, hh)]); left += gap2; } if !ins.rate.is_empty() { left += wv; - g.dim(vec![at(left + gap2 / 2.0, -hh), at(left + gap2 / 2.0, hh)]); + g.text_mark(vec![at(left + gap2 / 2.0, -hh), at(left + gap2 / 2.0, hh)]); } - g.dim(vec![at(-hw, hh), at(hw, hh)]); - g.dim(vec![at(-hw, -hh), at(hw, -hh)]); + g.text_mark(vec![at(-hw, hh), at(hw, hh)]); + g.text_mark(vec![at(-hw, -hh), at(hw, -hh)]); for side in [-1.0, 1.0] { let ends: Vec = if ins.shape == 0 { (0..=12).map(|i| f64::from(i) * std::f64::consts::PI / 12.0).map(|a| at(side * (hw + hh * a.sin()), hh * a.cos())).collect() } else { vec![at(side * hw, hh), at(side * (hw + hh), 0.0), at(side * hw, -hh)] }; - g.dim(ends); + g.text_mark(ends); } } + // DIMTFILL: a background behind the text, the text box plus DIMGAP on every side. + if st.text_fill != 0 && !g.mtext.is_empty() { + let (w, h) = measure(&g.mtext); + let (x, y) = (Vec2::from_angle(text_angle), Vec2::from_angle(text_angle).perp()); + let (hw, hh) = (w / 2.0 + gap, h / 2.0 + gap); + g.text_fill = [(-hw, -hh), (hw, -hh), (hw, hh), (-hw, hh)].into_iter().map(|(px, py)| text_mid + x * px + y * py).collect(); + } g.value = decode_value(&cadcraft_fonts::plain_mtext(&g.mtext)); if !g.mtext.is_empty() { let l = cadcraft_fonts::layout_mtext_with(&g.mtext, ¶ms(text_mid, text_angle)); diff --git a/crates/render/src/hatch.rs b/crates/render/src/hatch.rs index dd83a71a2..1bfeb1b6c 100644 --- a/crates/render/src/hatch.rs +++ b/crates/render/src/hatch.rs @@ -465,6 +465,26 @@ impl Family<'_> { self.origin + p.rotate(self.ang) } + /// The range of the lines swept, in this frame: the boundary's extent, cut to the view's + /// when there is one, and the view's x extent that spans are cut to. Spans are cut to the + /// view's extent only: they already lie within the boundary, and cutting them to its extent + /// too would move an end that sits on a boundary corner by a rounding error, so a view + /// covering the hatch would change its lines. `None` when the view misses the boundary. + fn lines_range(&self, bb: &Bounds2, view: Option<&Bounds2>) -> Option<(Vec2, Vec2, Option<(f64, f64)>)> { + let (mut lo, mut hi) = (bb.min, bb.max); + let mut span_cut = None; + if let Some(v) = view { + let vb = Bounds2::from_points(v.corners().iter().map(|p| self.to_local(*p))); + if vb.is_empty() || !vb.intersects(bb) { + return None; + } + lo = Vec2::new(lo.x.max(vb.min.x), lo.y.max(vb.min.y)); + hi = Vec2::new(hi.x.min(vb.max.x), hi.y.min(vb.max.y)); + span_cut = Some((vb.min.x, vb.max.x)); + } + Some((lo, hi, span_cut)) + } + /// Intersect a pattern family with boundary loops, respecting view, output, cancellation, and shared dash-work limits. fn sweep(&self, loops: &[Vec], px: f64, view: Option<&Bounds2>, cancel: Option<&AtomicBool>, out: &mut Vec>, work: &mut u64) { let local: Vec> = loops.iter().map(|l| l.iter().map(|p| self.to_local(*p)).collect()).collect(); @@ -472,21 +492,7 @@ impl Family<'_> { if bb.is_empty() { return; } - // The lines swept: the boundary's extent, cut to the view's (its corners in this frame) - // when there is one. Spans are cut to the view's extent only: they already lie within the - // boundary, and cutting them to its extent too would move an end that sits on a boundary - // corner by a rounding error, so a view covering the hatch would change its lines. - let (mut lo, mut hi) = (bb.min, bb.max); - let mut span_cut = None; - if let Some(v) = view { - let vb = Bounds2::from_points(v.corners().iter().map(|p| self.to_local(*p))); - if vb.is_empty() || !vb.intersects(&bb) { - return; - } - lo = Vec2::new(lo.x.max(vb.min.x), lo.y.max(vb.min.y)); - hi = Vec2::new(hi.x.min(vb.max.x), hi.y.min(vb.max.y)); - span_cut = Some((vb.min.x, vb.max.x)); - } + let Some((lo, hi, span_cut)) = self.lines_range(&bb, view) else { return }; let shift_per_line = self.delta.rotate(-self.ang).x; let k0 = (lo.y / self.spacing).floor() as i64; let k1 = (hi.y / self.spacing).ceil() as i64; @@ -504,17 +510,7 @@ impl Family<'_> { return; } let y = k as f64 * self.spacing; - let mut xs: Vec = Vec::new(); - for l in &local { - let n = l.len(); - for i in 0..n { - let (Some(a), Some(b)) = (l.get(i), l.get((i + 1) % n)) else { continue }; - if (a.y > y) != (b.y > y) { - xs.push(a.x + (b.x - a.x) * (y - a.y) / (b.y - a.y)); - } - } - } - xs.sort_by(f64::total_cmp); + let xs = crossings(&local, y); let offset = shift_per_line * k as f64; for span in xs.chunks(2) { let [x0, x1] = span else { continue }; @@ -528,14 +524,14 @@ impl Family<'_> { if solid_line { out.push(vec![self.to_world(Vec2::new(x0, y)), self.to_world(Vec2::new(x1, y))]); } else { - self.dash(x0, x1, y, offset, pat_len, out, work); + self.dash((x0, x1), y, offset, pat_len, out, work); } } } } /// The dashes and dots of the span `x0..x1` of the line at `y`, phase anchored at x = `offset`. - fn dash(&self, x0: f64, x1: f64, y: f64, offset: f64, pat_len: f64, out: &mut Vec>, work: &mut u64) { + fn dash(&self, (x0, x1): (f64, f64), y: f64, offset: f64, pat_len: f64, out: &mut Vec>, work: &mut u64) { let start_rep = ((x0 - offset) / pat_len).floor(); let mut x = offset + start_rep * pat_len; let mut guard = 0; @@ -565,6 +561,22 @@ impl Family<'_> { } } +/// The x positions where the boundary loops cross the horizontal line at `y`, in order. +fn crossings(local: &[Vec], y: f64) -> Vec { + let mut xs: Vec = Vec::new(); + for l in local { + let n = l.len(); + for i in 0..n { + let (Some(a), Some(b)) = (l.get(i), l.get((i + 1) % n)) else { continue }; + if (a.y > y) != (b.y > y) { + xs.push(a.x + (b.x - a.x) * (y - a.y) / (b.y - a.y)); + } + } + } + xs.sort_by(f64::total_cmp); + xs +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 25ccf4101..cc7da1fa6 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -1125,10 +1125,39 @@ fn entity_parts(b: &mut Builder, ctx: &Ctx, e: &Entity) { && ctx.depth < cadcraft_doc::MAX_BLOCK_DEPTH { let sub = Ctx { block_pstyle: b.named, block_transparency: b.transparency, ..sub_ctx(ctx, e, Mat3::IDENTITY) }; + // DIMTFILL: the block's MTEXT carries no background mask here, so a background + // around each of the block's texts is drawn before the block's first text or first + // line of the text's marks, except for a text that a SOLID of the block covers. + let style = ctx.d.dim_style(&dm.style).cloned().unwrap_or_default().with_overrides(&dm.overrides); + let g = if style.text_fill != 0 { dimension_in(ctx.d, dm) } else { DimGeometry::default() }; + let gap = style.text_gap.abs() * style.effective_scale(ctx.d.header.f64("DIMSCALE", 1.0)); + let boxes: Vec> = if g.text_fill.is_empty() { + Vec::new() + } else { + let solids: Vec<&Entity> = + blk.entities.iter().map(|be| &**be).filter(|be| matches!(be.kind, EntityKind::Solid(_))).take(MAX_FILL_SOLIDS).collect(); + // A text's own box (no gap): a SOLID holding all its corners is its background. + let covered = + |be: &Entity| block_text_box(ctx.d, be, 0.0).is_some_and(|t| solids.iter().any(|s| t.iter().all(|p| solid_covers(s, *p)))); + blk.entities + .iter() + .filter(|be| matches!(be.kind, EntityKind::MText(_) | EntityKind::Text(_))) + .take(MAX_FILL_TEXTS) + .filter(|be| !covered(be)) + .filter_map(|be| block_text_box(ctx.d, be, gap)) + .collect() + }; + let mut fill_pending = !boxes.is_empty(); for be in blk.entities.iter() { if !b.expand() { break; } + if fill_pending && starts_text_marks(be, &g) { + for corners in &boxes { + dim_text_fill(b, ctx, e, &style, corners); + } + fill_pending = false; + } entity(b, &sub, be); } } else { @@ -1455,23 +1484,126 @@ fn dimension(b: &mut Builder, ctx: &Ctx, e: &Entity, dm: &cadcraft_doc::Dimensio }; let (dim_lw, ext_lw) = (weight(style.dim_lineweight), weight(style.ext_lineweight)); let (dim_lt, ext1_lt, ext2_lt) = (ltype(&style.dim_linetype), ltype(&style.ext_linetype1), ltype(&style.ext_linetype2)); - for (i, (l, role)) in g.lines.iter().zip(g.line_roles.iter().chain(std::iter::repeat(&LineRole::Dim))).enumerate() { - let (rgb, w, t) = match role { - LineRole::Ext if g.ext2_lines.contains(&i) => (ext_rgb, ext_lw, ext2_lt.as_ref()), - LineRole::Ext => (ext_rgb, ext_lw, ext1_lt.as_ref()), - LineRole::Dim => (dim_rgb, dim_lw, dim_lt.as_ref()), - }; + let line_style = |i: usize| match g.line_roles.get(i).unwrap_or(&LineRole::Dim) { + LineRole::Ext if g.ext2_lines.contains(&i) => (ext_rgb, ext_lw, ext2_lt.as_ref()), + LineRole::Ext => (ext_rgb, ext_lw, ext1_lt.as_ref()), + LineRole::Dim => (dim_rgb, dim_lw, dim_lt.as_ref()), + }; + // The text's marks (arc length symbol, DIMINSPECT frame) come last, over the text background. + for (i, l) in g.lines.iter().enumerate().filter(|(i, _)| !g.text_marks.contains(i)) { + let (rgb, w, t) = line_style(i); stroke_with(b, ctx, rgb, w, t, ltscale, l); } for t in &g.fills { b.tris(ctx, dim_rgb, t); } + dim_text_fill(b, ctx, e, &style, &g.text_fill); + for (i, l) in g.lines.iter().enumerate().filter(|(i, _)| g.text_marks.contains(i)) { + let (rgb, w, t) = line_style(i); + stroke_with(b, ctx, rgb, w, t, ltscale, l); + } if b.opts.text { let sh = cadcraft_fonts::Shaped { strokes: g.text.clone(), glyphs: g.text_glyphs.clone(), width: 0.0, text: g.text_runs.clone() }; b.shaped(ctx, txt_rgb, lw, &sh); } } +/// DIMTFILL: a dimension's text background (`corners` from [`DimGeometry::text_fill`]) hides +/// what is drawn before it, in the drawing's background color (1) or DIMTFILLCLR (2). +fn dim_text_fill(b: &mut Builder, ctx: &Ctx, e: &Entity, style: &cadcraft_doc::DimStyle, corners: &[Vec2]) { + let [p0, p1, p2, p3] = corners else { return }; + if !b.opts.fill || style.text_fill == 0 { + return; + } + // DIMTFILLCLR ByBlock = the dimension's own color, as DIMCLRT. + let layer_name = if e.common.layer == "0" { ctx.block_layer.as_deref().unwrap_or("0") } else { e.common.layer.as_str() }; + let layer_color = ctx.layer_color(layer_name).unwrap_or(Color::Index(7)); + let own = match e.common.color { + Color::ByLayer => layer_color, + Color::ByBlock => ctx.block_color, + c => c, + }; + let n = b.list.prims.len(); + b.tris(ctx, ink(style.text_fill_color, layer_color, own), &[*p0, *p1, *p2, *p0, *p2, *p3]); + if style.text_fill == 1 + && let Some(p) = b.list.prims.get_mut(n) + { + p.kind = Kind::Mask; + } +} + +/// A dimension block's text, or a line that is a segment of one of the text's marks in `g` +/// (as the `*D` block writer splits them): where the background goes. +fn starts_text_marks(be: &Entity, g: &DimGeometry) -> bool { + match &be.kind { + EntityKind::MText(_) | EntityKind::Text(_) => true, + EntityKind::Line(l) => { + let (a, b) = (l.a.xy(), l.b.xy()); + g.text_marks + .iter() + .filter_map(|i| g.lines.get(*i)) + .any(|m| m.windows(2).any(|w| matches!(w, [p, q] if p.dist(a) < 1e-6 && q.dist(b) < 1e-6))) + } + _ => false, + } +} + +/// The background of a dimension block's MTEXT or TEXT: its box widened by `gap` on every side, +/// turned with the text (corners in order); `None` for other entities. +fn block_text_box(d: &Drawing, be: &Entity, gap: f64) -> Option> { + let t = match &be.kind { + EntityKind::MText(t) => t, + EntityKind::Text(t) => return text_box(d, t, gap), + _ => return None, + }; + let (bounds, at, rot) = (layout_mtext_entity(d, &cadcraft_doc::MText { rotation: 0.0, ..t.clone() }).bounds, t.insert.xy(), t.rotation); + if !(bounds.width().is_finite() && bounds.height().is_finite()) || bounds.width() <= 0.0 { + return None; + } + let (lo, hi) = (bounds.min - Vec2::new(gap, gap), bounds.max + Vec2::new(gap, gap)); + let (x, y) = (Vec2::from_angle(rot), Vec2::from_angle(rot).perp()); + let turn = |p: Vec2| at + x * (p.x - at.x) + y * (p.y - at.y); + Some([lo, Vec2::new(hi.x, lo.y), hi, Vec2::new(lo.x, hi.y)].into_iter().map(turn).collect()) +} + +/// The background of a TEXT as placed: the box of its strokes and glyph outlines along the text's +/// direction (the baseline from the insertion to the alignment point for Aligned and Fit), +/// widened by `gap` on every side. +fn text_box(d: &Drawing, t: &cadcraft_doc::Text, gap: f64) -> Option> { + let (sh, _) = place_text_entity(d, t, &t.value); + let rot = match (t.halign, t.align_pt) { + (cadcraft_doc::HAlign::Aligned | cadcraft_doc::HAlign::Fit, Some(p)) if p.xy().dist(t.insert.xy()) > 1e-12 => t.insert.xy().angle_to(p.xy()), + _ => t.rotation, + }; + let (x, y) = (Vec2::from_angle(rot), Vec2::from_angle(rot).perp()); + let pts: Vec = sh.strokes.iter().flatten().chain(sh.glyphs.iter().flatten().flatten()).copied().collect(); + let first = pts.first()?; + let start = (first.dot(x), first.dot(y), first.dot(x), first.dot(y)); + let (s0, u0, s1, u1) = pts.iter().fold(start, |(s0, u0, s1, u1), p| (s0.min(p.dot(x)), u0.min(p.dot(y)), s1.max(p.dot(x)), u1.max(p.dot(y)))); + if !(s1 - s0 > 0.0 && (s0 + s1 + u0 + u1).is_finite()) { + return None; + } + let at = |s: f64, u: f64| x * s + y * u; + Some(vec![at(s0 - gap, u0 - gap), at(s1 + gap, u0 - gap), at(s1 + gap, u1 + gap), at(s0 - gap, u1 + gap)]) +} + +/// Most texts of one dimension block that get a DIMTFILL background, and most of its SOLIDs +/// checked for covering one. +const MAX_FILL_TEXTS: usize = 64; +const MAX_FILL_SOLIDS: usize = 256; + +/// Whether `be` is a SOLID that covers `p` (a text background the block holds already, as the +/// `*D` block of DIMTFILL 2 does). +fn solid_covers(be: &Entity, p: Vec2) -> bool { + let EntityKind::Solid(s) = &be.kind else { return false }; + // SOLID corners go 1, 2, 4, 3 around the outline. + let [c1, c2, c3, c4] = s.corners.map(|c| c.xy()); + let ring = [c1, c2, c4, c3]; + let next = [c2, c4, c3, c1]; + let sides: Vec = ring.iter().zip(next.iter()).map(|(a, b)| (*b - *a).cross(p - *a)).filter(|c| c.abs() > 1e-12).collect(); + !sides.is_empty() && (sides.iter().all(|c| *c > 0.0) || sides.iter().all(|c| *c < 0.0)) +} + mod table; pub use table::{MAX_LAYOUT_CELLS, TableFill, TableGeom, TableLine, TableText, table_covered, table_geometry, table_style_of}; diff --git a/crates/render/tests/it/dim_text_fill.rs b/crates/render/tests/it/dim_text_fill.rs new file mode 100644 index 000000000..b90e1595d --- /dev/null +++ b/crates/render/tests/it/dim_text_fill.rs @@ -0,0 +1,268 @@ +//! DIMTFILL and DIMTFILLCLR: a dimension's text background hides what is drawn under the text, +//! in the drawing's background color (1) or in DIMTFILLCLR (2). + +use cadcraft_color::{Color, Rgb}; +use cadcraft_doc::{Block, Circle, Common, DimKind, DimStyle, Dimension, Drawing, Entity, EntityKind, Handle, Line, MText, Space}; +use cadcraft_geom::{Vec2, Vec3}; +use cadcraft_render::raster::{RasterOptions, View, render}; +use cadcraft_render::{DisplayList, Kind, Options, build, dimension_geometry}; + +/// A linear dimension from (0, 0) to (10, 0) with its line at y = 5, in style "Fill". +fn dimension() -> Dimension { + Dimension { + kind: DimKind::Linear { rotation: 0.0 }, + defpt: Vec3::new(0.0, 5.0, 0.0), + text_mid: Vec3::ZERO, + p13: Vec3::ZERO, + p14: Vec3::new(10.0, 0.0, 0.0), + p15: Vec3::ZERO, + p16: Vec3::ZERO, + text: String::new(), + style: "Fill".into(), + measurement: 0.0, + text_rotation: 0.0, + user_text_pos: false, + block: None, + overrides: Default::default(), + assoc: Vec::new(), + marks: Default::default(), + } +} + +/// DIMGAP 0.5 leaves room above the text: a line at y = 5.4 passes under the background but +/// clear of the text. +fn style(fill: u8, color: Color) -> DimStyle { + DimStyle { name: "Fill".into(), text_gap: 0.5, text_fill: fill, text_fill_color: color, ..DimStyle::default() } +} + +/// A line at y = 5.4 drawn first, then the dimension. +fn drawing(fill: u8, color: Color) -> Drawing { + let mut d = Drawing::new_imperial(); + d.dim_styles.push(style(fill, color)); + let line = EntityKind::Line(Line { a: Vec3::new(-2.0, 5.4, 0.0), b: Vec3::new(12.0, 5.4, 0.0) }); + d.add(&Space::Model, Common::default(), line).unwrap(); + d.add(&Space::Model, Common::default(), EntityKind::Dimension(dimension())).unwrap(); + d +} + +/// Dark pixels on the row of the line at y = 5.4, near the text (x 4.6 to 5.4) and far from it +/// (x -1.5 to -0.5), on white paper. +fn dark_on_line(list: &DisplayList) -> (usize, usize) { + let view = View { center: Vec2::new(5.0, 5.0), scale: 20.0, width: 400, height: 200 }; + let o = RasterOptions { background: Rgb(255, 255, 255), antialias: false, ..Default::default() }; + let pm = render(list, &view, &o).unwrap(); + let (mut near, mut far) = (0, 0); + // World y = 5.4 is screen row 100 - 0.4 * 20 = 92; world x = 5 + (px - 200) / 20. + for y in 91..=93u32 { + for x in 0..400u32 { + let wx = 5.0 + (f64::from(x) + 0.5 - 200.0) / 20.0; + if pm.pixel(x, y).unwrap().red() > 128 { + continue; + } + if (4.6..5.4).contains(&wx) { + near += 1; + } else if (-1.5..-0.5).contains(&wx) { + far += 1; + } + } + } + (near, far) +} + +#[test] +fn text_fill_gives_the_text_a_background_box() { + let d = dimension(); + assert!(dimension_geometry(&d, &style(0, Color::ByBlock), 1.0).text_fill.is_empty(), "DIMTFILL 0: no background"); + let g = dimension_geometry(&d, &style(1, Color::ByBlock), 1.0); + let [a, b, c, e] = g.text_fill.as_slice() else { panic!("four corners: {:?}", g.text_fill) }; + // The box is centered on the text and reaches DIMGAP (0.5) past the text height (0.18). + let mid = (*a + *b + *c + *e) * 0.25; + assert!(mid.dist(g.text_pos) < 1e-9, "{mid:?} vs {:?}", g.text_pos); + assert!(((c.y - a.y) - (0.18 + 1.0)).abs() < 0.05, "height {}", c.y - a.y); + assert!(b.x - a.x > 1.0, "width {}", b.x - a.x); +} + +#[test] +fn text_fill_hides_what_is_drawn_under_the_text() { + let opts = Options::default(); + let (near, far) = dark_on_line(&build(&drawing(0, Color::ByBlock), &Space::Model, &opts)); + assert!(near > 0 && far > 0, "DIMTFILL 0: the line shows under the text ({near}, {far})"); + let list = build(&drawing(1, Color::ByBlock), &Space::Model, &opts); + assert_eq!(list.prims.iter().filter(|p| p.kind == Kind::Mask).count(), 1); + let (near, far) = dark_on_line(&list); + assert_eq!(near, 0, "DIMTFILL 1: the background hides the line under the text"); + assert!(far > 0, "the line shows away from the text"); +} + +#[test] +fn inspection_frame_is_drawn_over_the_background() { + // Polylines drawn after the background, for the dimension with and without an inspection + // frame (the text strokes come after it either way). + let after_mask = |dm: Dimension| { + let mut d = Drawing::new_imperial(); + d.dim_styles.push(style(1, Color::ByBlock)); + d.add(&Space::Model, Common::default(), EntityKind::Dimension(dm)).unwrap(); + let list = build(&d, &Space::Model, &Options::default()); + let mask = list.prims.iter().position(|p| p.kind == Kind::Mask).unwrap(); + list.prims[mask..].iter().filter(|p| p.kind == Kind::Polyline).count() + }; + let mut dm = dimension(); + // No label or rate: the same text, and a frame of top, bottom and two ends. + dm.marks.inspect = Some(cadcraft_doc::Inspection { shape: 1, label: String::new(), rate: String::new() }); + let frame = dimension_geometry(&dm, &style(1, Color::ByBlock), 1.0).text_marks.len(); + assert_eq!(frame, 4); + let (plain, inspected) = (after_mask(dimension()), after_mask(dm)); + assert_eq!(inspected, plain + frame, "the frame lines come after the background"); + // An arc length dimension's symbol over the text (DIMARCSYM 1) is a mark of the text too. + let arc = Dimension { + kind: DimKind::ArcLength, + defpt: Vec3::new(0.0, 6.0, 0.0), + p13: Vec3::new(5.0, 0.0, 0.0), + p14: Vec3::new(-5.0, 0.0, 0.0), + p15: Vec3::ZERO, + ..dimension() + }; + let st = DimStyle { arc_symbol: 1, ..style(1, Color::ByBlock) }; + let g = dimension_geometry(&arc, &st, 1.0); + let [sym] = g.text_marks.as_slice() else { panic!("one mark: {:?}", g.text_marks) }; + assert!(g.lines[*sym].len() == 13, "the 13-point arc over the text"); +} + +#[test] +fn a_dimension_read_with_its_block_keeps_the_block_and_gets_the_background() { + // The block (as read from a file) holds a circle the dimension's own geometry does not have, + // then the text, placed away from where CADCraft would put it. + let mut d = Drawing::new_imperial(); + d.dim_styles.push(style(1, Color::ByBlock)); + let mut blk = Block::new("*D1"); + let circle = EntityKind::Circle(Circle { center: Vec3::new(5.0, 5.0, 0.0), radius: 3.0 }); + blk.entities.push(Entity::new(Handle(1), circle)); + let text = MText { + insert: Vec3::new(20.0, 20.0, 0.0), + height: 0.18, + width: 0.0, + attach: 5, + rotation: 0.0, + style: "Standard".into(), + contents: "10.0000".into(), + line_spacing: 1.0, + line_spacing_exact: false, + columns: None, + }; + blk.entities.push(Entity::new(Handle(2), EntityKind::MText(text))); + d.blocks.insert("*D1".into(), std::sync::Arc::new(blk)); + d.add(&Space::Model, Common::default(), EntityKind::Dimension(Dimension { block: Some("*D1".into()), ..dimension() })).unwrap(); + let list = build(&d, &Space::Model, &Options::default()); + let mask = list.prims.iter().position(|p| p.kind == Kind::Mask).expect("a background"); + let circle = list.prims.iter().position(|p| list.points(p).iter().any(|q| q.dist(Vec2::new(8.0, 5.0)) < 0.05)).expect("the block's circle"); + assert!(circle < mask, "the circle before the background, the text after it"); + // The background is around the block's text. + let pts = list.points(&list.prims[mask]); + let mid = pts.iter().fold(Vec2::ZERO, |s, p| s + *p) * (1.0 / pts.len() as f64); + assert!(mid.dist(Vec2::new(20.0, 20.0)) < 0.05, "{mid:?}"); +} + +#[test] +fn every_text_of_a_block_gets_a_background() { + // A measurement and a tolerance as two MTEXTs of the block. + let mut d = Drawing::new_imperial(); + d.dim_styles.push(style(1, Color::ByBlock)); + let mut blk = Block::new("*D1"); + for (i, (y, s)) in [(20.0, "10.0000"), (22.0, "+0.1")].into_iter().enumerate() { + let text = MText { + insert: Vec3::new(20.0, y, 0.0), + height: 0.18, + width: 0.0, + attach: 5, + rotation: 0.0, + style: "Standard".into(), + contents: s.into(), + line_spacing: 1.0, + line_spacing_exact: false, + columns: None, + }; + blk.entities.push(Entity::new(Handle(i as u64 + 1), EntityKind::MText(text))); + } + d.blocks.insert("*D1".into(), std::sync::Arc::new(blk)); + d.add(&Space::Model, Common::default(), EntityKind::Dimension(Dimension { block: Some("*D1".into()), ..dimension() })).unwrap(); + let list = build(&d, &Space::Model, &Options::default()); + assert_eq!(list.prims.iter().filter(|p| p.kind == Kind::Mask).count(), 2); +} + +#[test] +fn a_block_that_holds_its_background_gets_no_second_one() { + // A block with a SOLID, then its text at (20, 20), away from where CADCraft would put it: the + // number of masks drawn. + let masks = |solid: [(f64, f64); 4]| { + let mut d = Drawing::new_imperial(); + d.dim_styles.push(style(1, Color::ByBlock)); + let mut blk = Block::new("*D1"); + let solid = cadcraft_doc::Solid { corners: solid.map(|(x, y)| Vec3::new(x, y, 0.0)) }; + blk.entities.push(Entity::new(Handle(1), EntityKind::Solid(solid))); + let text = MText { + insert: Vec3::new(20.0, 20.0, 0.0), + height: 0.18, + width: 0.0, + attach: 5, + rotation: 0.0, + style: "Standard".into(), + contents: "10.0000".into(), + line_spacing: 1.0, + line_spacing_exact: false, + columns: None, + }; + blk.entities.push(Entity::new(Handle(2), EntityKind::MText(text))); + d.blocks.insert("*D1".into(), std::sync::Arc::new(blk)); + d.add(&Space::Model, Common::default(), EntityKind::Dimension(Dimension { block: Some("*D1".into()), ..dimension() })).unwrap(); + let list = build(&d, &Space::Model, &Options::default()); + list.prims.iter().filter(|p| p.kind == Kind::Mask).count() + }; + assert_eq!(masks([(19.0, 19.5), (21.0, 19.5), (19.0, 20.5), (21.0, 20.5)]), 0, "the SOLID covers the text"); + // A small SOLID (an arrowhead) over the middle of the text only: the background is drawn. + assert_eq!(masks([(19.9, 19.95), (20.1, 20.0), (19.9, 20.05), (19.9, 20.05)]), 1, "the SOLID covers part of the text"); +} + +#[test] +fn a_block_with_turned_text_gets_a_turned_background() { + // A block whose text is a TEXT turned 90 degrees, at (20, 20): the background is taller than + // wide and holds every point of the text. + let mut d = Drawing::new_imperial(); + d.dim_styles.push(style(1, Color::ByBlock)); + let mut blk = Block::new("*D1"); + let text = cadcraft_doc::Text { + insert: Vec3::new(20.0, 20.0, 0.0), + align_pt: None, + height: 0.5, + value: "10.00".into(), + rotation: std::f64::consts::FRAC_PI_2, + width_factor: 1.0, + oblique: 0.0, + style: "Standard".into(), + halign: Default::default(), + valign: Default::default(), + }; + blk.entities.push(Entity::new(Handle(1), EntityKind::Text(text))); + d.blocks.insert("*D1".into(), std::sync::Arc::new(blk)); + d.add(&Space::Model, Common::default(), EntityKind::Dimension(Dimension { block: Some("*D1".into()), ..dimension() })).unwrap(); + let list = build(&d, &Space::Model, &Options::default()); + let mask = list.prims.iter().position(|p| p.kind == Kind::Mask).expect("a background"); + let quad = list.points(&list.prims[mask]); + let (lo, hi) = quad.iter().fold((quad[0], quad[0]), |(lo, hi), p| (lo.min(*p), hi.max(*p))); + assert!(hi.y - lo.y > 1.5 * (hi.x - lo.x), "turned: {lo:?} {hi:?}"); + let text: Vec = list.prims[mask + 1..].iter().flat_map(|p| list.points(p).to_vec()).collect(); + assert!(!text.is_empty() && text.iter().all(|p| p.x >= lo.x && p.x <= hi.x && p.y >= lo.y && p.y <= hi.y), "the text inside {lo:?} {hi:?}"); +} + +#[test] +fn text_fill_2_paints_dimtfillclr() { + let list = build(&drawing(2, Color::Index(1)), &Space::Model, &Options::default()); + assert_eq!(list.prims.iter().filter(|p| p.kind == Kind::Mask).count(), 0); + let red: Vec<_> = list.prims.iter().filter(|p| p.kind == Kind::Tris && p.color == Rgb(255, 0, 0)).collect(); + assert_eq!(red.len(), 1, "one red background"); + // Over the line, next to the text: the background is drawn after the line. + let view = View { center: Vec2::new(5.0, 5.0), scale: 20.0, width: 400, height: 200 }; + let o = RasterOptions { background: Rgb(255, 255, 255), antialias: false, ..Default::default() }; + let pm = render(&list, &view, &o).unwrap(); + let c = pm.pixel(194, 92).unwrap(); + assert_eq!((c.red(), c.green(), c.blue()), (255, 0, 0)); +} diff --git a/crates/render/tests/it/main.rs b/crates/render/tests/it/main.rs index 7469173d0..5f0005496 100644 --- a/crates/render/tests/it/main.rs +++ b/crates/render/tests/it/main.rs @@ -2,6 +2,7 @@ mod aci7_display; mod annotative_viewport; mod dim_angular_centre; mod dim_post_tolerance; +mod dim_text_fill; mod dim_text_format; mod hatch_fill; mod linetype_text; diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 18721a963..d21c2d8ca 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -17,6 +17,7 @@ cadcraft-doc = { workspace = true } cadcraft-render = { workspace = true } cadcraft-engine = { workspace = true } cadcraft-fonts = { workspace = true } +craft-window = { workspace = true } egui = { workspace = true } # GPU canvas paint callback (same egui-wgpu/wgpu eframe uses with its "wgpu" feature). egui-wgpu = { workspace = true } @@ -25,8 +26,16 @@ 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] +# Tests build the dock area as the desktop app does (headless contexts keep floats in-window). +craft-ui = { workspace = true, features = ["native-windows"] } +# 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"] } diff --git a/crates/ui-egui/src/aliasdlg.rs b/crates/ui-egui/src/aliasdlg.rs index e643edcfb..6caded5f8 100644 --- a/crates/ui-egui/src/aliasdlg.rs +++ b/crates/ui-egui/src/aliasdlg.rs @@ -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. diff --git a/crates/ui-egui/src/animation.rs b/crates/ui-egui/src/animation.rs index 75e28a706..3e2f3ad78 100644 --- a/crates/ui-egui/src/animation.rs +++ b/crates/ui-egui/src/animation.rs @@ -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] { diff --git a/crates/ui-egui/src/autosave.rs b/crates/ui-egui/src/autosave.rs index a0db8d0d7..d062f69e9 100644 --- a/crates/ui-egui/src/autosave.rs +++ b/crates/ui-egui/src/autosave.rs @@ -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)) diff --git a/crates/ui-egui/src/bedit.rs b/crates/ui-egui/src/bedit.rs index 0166e72ae..6137d5ccc 100644 --- a/crates/ui-egui/src/bedit.rs +++ b/crates/ui-egui/src/bedit.rs @@ -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; } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 390c40a1f..a2812367a 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -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)))); + } +} diff --git a/crates/ui-egui/src/canvas/blockedit.rs b/crates/ui-egui/src/canvas/blockedit.rs index d30d23140..e8d981b74 100644 --- a/crates/ui-egui/src/canvas/blockedit.rs +++ b/crates/ui-egui/src/canvas/blockedit.rs @@ -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. diff --git a/crates/ui-egui/src/canvas/compare.rs b/crates/ui-egui/src/canvas/compare.rs index aae585caa..32679e554 100644 --- a/crates/ui-egui/src/canvas/compare.rs +++ b/crates/ui-egui/src/canvas/compare.rs @@ -4,7 +4,8 @@ use std::sync::Arc; -use cadcraft_engine::doc::Handle; +use cadcraft_engine::cmd::file::compare::Comparison; +use cadcraft_engine::doc::{Drawing, Handle}; use cadcraft_render::DisplayList; use egui::{Rect, RichText, Stroke, vec2}; use serde_json::json; @@ -26,7 +27,24 @@ struct Cache { } fn id() -> egui::Id { - egui::Id::new("cadcraft-compare-overlay") + egui::Id::unique("cadcraft-compare-overlay") +} + +/// The display lists of the differences for this frame: the stored ones when they still match +/// `key`, otherwise rebuilt at zoom `scale` and stored. +fn cache_for(ctx: &egui::Context, key: (u64, u64, usize, usize, i64), doc: &Drawing, c: &Comparison, scale: f64) -> Cache { + let mut cache: Cache = ctx.data_mut(|d| d.get_temp(id())).unwrap_or_default(); + if cache.key != key || cache.ours.is_none() { + let opts = cadcraft_render::Options { tolerance: 0.5 / scale.max(1e-12), ..Default::default() }; + let ours: Vec = c.added.iter().copied().chain(c.changed.iter().map(|(a, _)| *a)).take(MAX_DRAWN).collect(); + let theirs: Vec = c.removed.iter().copied().chain(c.changed.iter().map(|(_, b)| *b)).take(MAX_DRAWN).collect(); + let list = |d: &Drawing, hs: &[Handle]| { + Arc::new(cadcraft_render::build_entities(d, hs.iter().filter_map(|h| d.entity(*h)).map(|e| e.as_ref()), &opts)) + }; + cache = Cache { key, ours: Some(list(doc, &ours)), theirs: Some(list(&c.other, &theirs)) }; + ctx.data_mut(|d| d.insert_temp(id(), cache.clone())); + } + cache } /// Draw the comparison over the canvas when it was made for the drawing and tab shown. @@ -38,17 +56,7 @@ pub(super) fn paint(app: &mut CadApp, ctx: &egui::Context, p: &egui::Painter, xf p.rect_filled(rect, 0.0, t.canvas.gamma_multiply(0.75)); let band = xf.scale.max(1e-300).log2().round() as i64; let key = (st.uid, st.revision, Arc::as_ptr(&c.other) as usize, c.differences(), band); - let mut cache: Cache = ctx.data_mut(|d| d.get_temp(id())).unwrap_or_default(); - if cache.key != key || cache.ours.is_none() { - let opts = cadcraft_render::Options { tolerance: 0.5 / xf.scale.max(1e-12), ..Default::default() }; - let ours: Vec = c.added.iter().copied().chain(c.changed.iter().map(|(a, _)| *a)).take(MAX_DRAWN).collect(); - let theirs: Vec = c.removed.iter().copied().chain(c.changed.iter().map(|(_, b)| *b)).take(MAX_DRAWN).collect(); - let list = |d: &cadcraft_engine::doc::Drawing, hs: &[Handle]| { - Arc::new(cadcraft_render::build_entities(d, hs.iter().filter_map(|h| d.entity(*h)).map(|e| e.as_ref()), &opts)) - }; - cache = Cache { key, ours: Some(list(&st.doc, &ours)), theirs: Some(list(&c.other, &theirs)) }; - ctx.data_mut(|d| d.insert_temp(id(), cache.clone())); - } + let cache = cache_for(ctx, key, &st.doc, c, xf.scale); for (list, color) in [(&cache.theirs, t.compare_other), (&cache.ours, t.compare_this)] { if let Some(l) = list { let all: Vec = (0..l.prims.len()).collect(); @@ -85,6 +93,9 @@ pub(super) fn paint(app: &mut CadApp, ctx: &egui::Context, p: &egui::Painter, xf if ui.button("▶").on_hover_text(crate::tl!("Next Change")).clicked() { action = Some("compare.next"); } + if ui.button(crate::tl!("Export")).on_hover_text(crate::tl!("Export Comparison")).clicked() { + action = Some("compareexport"); + } if ui.button("×").on_hover_text(crate::tl!("Close Comparison")).clicked() { action = Some("compareclose"); } diff --git a/crates/ui-egui/src/capture.rs b/crates/ui-egui/src/capture.rs new file mode 100644 index 000000000..4bca367b4 --- /dev/null +++ b/crates/ui-egui/src/capture.rs @@ -0,0 +1,1046 @@ +//! Bounded, one-shot window screenshots for `ui.screenshot`. +//! +//! The request record owns the output path, reply, deadline, document/focus identity and the +//! geometry it was admitted against. The shared `craft_ui::capture::CaptureSlot` owns the strong +//! `egui::Context`; its screenshot callback holds only a weak reference and never writes a file. +//! Files are encoded and written only from the logic drain, after the deadline and every identity +//! check have passed. + +use std::sync::mpsc::Sender; + +use craft_ui::capture::CaptureSlot; +use egui::ViewportCommand; +use serde_json::{Value, json}; +use web_time::{Duration, Instant}; + +use crate::CadApp; + +/// At most this many queued, in-flight or completed-but-not-yet-drained requests. +pub(crate) const MAX_REQUESTS: usize = 4; +/// CADCraft keeps its stricter 64 MiB per-image bound (the suite default is 128 MiB). +pub(crate) const MAX_IMAGE_BYTES: usize = 64 * 1024 * 1024; +/// CADCraft keeps the aggregate reservation at the same 64 MiB bound. Completed but undrained +/// images still own their reservation, so they count against this limit. +pub(crate) const MAX_TOTAL_BYTES: usize = 64 * 1024 * 1024; + +const READY_DELAY: Duration = Duration::from_millis(120); +/// Accepted settled passes (never discarded passes or hidden logic ticks). +const SETTLE_PASSES: u32 = 3; +const DEADLINE: Duration = Duration::from_secs(8); +const REBUILD_WAIT: Duration = Duration::from_secs(6); +const REPAINT_INTERVAL: Duration = Duration::from_millis(16); + +/// The host surface as reported by one `RawInput`. `RawInput` is the fresh source on hidden +/// logic-only ticks; `Context::input`/`viewport_rect` describe the last UI pass and are stale +/// there. +/// +/// The equality used for the capture epoch is deliberately strict: OS focus and visibility +/// (`window_focused`, `minimized`, `occluded`) are part of it even though they are not geometry. +/// A focus or visibility transition can change what the presented frame contains (menu state, +/// active-window styling, a hidden surface), so a capture admitted before it is rejected rather +/// than risking an image from another epoch. Do not narrow this to size/DPI/zoom without a +/// replacement visibility check and a measured acceptance path. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct SurfaceGeometry { + /// Logical content size in points. A minimized window keeps its last known size so a forced + /// hidden paint can still be requested; `None` only before any size was ever reported. + viewport: Option, + /// Native pixels per point times egui's interface zoom. + pixels_per_point: f32, + max_texture_side: usize, + minimized: bool, + occluded: bool, + window_focused: bool, +} + +impl SurfaceGeometry { + /// `Some(size)` only when both axes are finite and strictly positive; rejects degenerate viewport sizes before they enter a `SurfaceGeometry` snapshot. + fn valid_size(size: egui::Vec2) -> Option { + (size.x.is_finite() && size.y.is_finite() && size.x > 0.0 && size.y > 0.0).then_some(size) + } + + /// The fresh raw hook's surface. Called from `raw_input_hook`, so it runs on both UI and + /// hidden logic-only callbacks and never reads the stale `InputState` rectangle. + fn from_raw(raw: &egui::RawInput, ctx: &egui::Context, previous: Option) -> Option { + let info = raw.viewports.get(&raw.viewport_id).cloned().unwrap_or_default(); + // `InputState::viewport_rect` is `RawInput::screen_rect`, falling back to the previous + // pass's rect; the pass snapshot uses the same source so the two agree bit for bit. The + // screenshot is of the UI content rect (what kittest renders and the plan binds to), so + // remove the safe-area insets here as `InputState::content_rect` does. + let insets = raw.safe_area_insets.unwrap_or_else(|| ctx.input(|input| input.safe_area_insets())); + let rect = raw.screen_rect.map(|rect| rect - insets); + let viewport = rect.and_then(|rect| Self::valid_size(rect.size())).or_else(|| previous.and_then(|previous| previous.viewport)); + let zoom = ctx.zoom_factor(); + if !zoom.is_finite() || zoom <= 0.0 { + return None; + } + let pixels_per_point = info + .native_pixels_per_point + .map(|native| native * zoom) + .filter(|pixels| pixels.is_finite() && *pixels > 0.0) + .or_else(|| previous.map(|previous| previous.pixels_per_point)) + .unwrap_or_else(|| ctx.pixels_per_point()); + if !pixels_per_point.is_finite() || pixels_per_point <= 0.0 { + return None; + } + let max_texture_side = raw.max_texture_side.filter(|side| *side > 0).unwrap_or_else(|| ctx.input(|input| input.max_texture_side)); + let minimized = info.minimized.unwrap_or_else(|| previous.is_some_and(|previous| previous.minimized)); + let occluded = info.occluded.unwrap_or_else(|| previous.is_some_and(|previous| previous.occluded)); + Some(Self { viewport, pixels_per_point, max_texture_side, minimized, occluded, window_focused: raw.focused }) + } + + /// The settled surface of a UI pass: the content rect and ppp this pass renders (and is + /// presented) with. The saved interface zoom takes effect at the start of a pass, so this is + /// the only scale the presented frame can be expected to have. + fn from_pass(ctx: &egui::Context, previous: Option) -> Option { + let content = ctx.input(|input| input.content_rect().size()); + let viewport = Self::valid_size(content).or_else(|| previous.and_then(|previous| previous.viewport)); + let pixels_per_point = ctx.pixels_per_point(); + if !pixels_per_point.is_finite() || pixels_per_point <= 0.0 { + return None; + } + let max_texture_side = ctx.input(|input| input.max_texture_side); + let minimized = ctx.input(|input| input.viewport().minimized).unwrap_or_else(|| previous.is_some_and(|previous| previous.minimized)); + let occluded = ctx.input(|input| input.viewport().occluded).unwrap_or_else(|| previous.is_some_and(|previous| previous.occluded)); + let window_focused = ctx.input(|input| input.focused); + Some(Self { viewport, pixels_per_point, max_texture_side, minimized, occluded, window_focused }) + } + + /// The pixel dimensions this surface would capture at: viewport points × pixels-per-point, rounded, each axis clamped to `max_texture_side` (0 means unlimited). + /// `None` when the viewport is unknown/non-finite or an axis overflows `usize`. + fn expected_size(self) -> Option<[usize; 2]> { + let viewport = self.viewport?; + if !viewport.x.is_finite() || !viewport.y.is_finite() || viewport.x <= 0.0 || viewport.y <= 0.0 { + return None; + } + let max = if self.max_texture_side == 0 { usize::MAX } else { self.max_texture_side }; + let axis = |points: f32| -> Option { + let pixels = (f64::from(points) * f64::from(self.pixels_per_point)).round(); + if !pixels.is_finite() || pixels <= 0.0 || pixels > usize::MAX as f64 { + return None; + } + Some((pixels as usize).min(max)) + }; + Some([axis(viewport.x)?, axis(viewport.y)?]) + } + + /// Raw `Color32` bytes for `expected_size()` (4 bytes per pixel), with checked multiplication; `None` on overflow. + fn expected_bytes(self) -> Option { + let [width, height] = self.expected_size()?; + width.checked_mul(height)?.checked_mul(std::mem::size_of::()) + } +} + +/// The immutable document and widget-focus identity a capture is bound to. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct CaptureIdentity { + document: Option, + /// Monotonic count of drawing switches in the engine, so A→B→A is still a change. + document_epoch: u64, + focus: Option, +} + +/// Viewport geometry plus the immutable document and widget-focus identity a capture is bound to. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct CaptureGeometry { + surface: SurfaceGeometry, + identity: CaptureIdentity, +} + +impl CaptureGeometry { + /// Pixel dimensions of the bound surface, or `None` when it cannot be estimated. + fn expected_size(self) -> Option<[usize; 2]> { + self.surface.expected_size() + } + + /// Raw image byte count of the bound surface, or `None` when it cannot be estimated. + fn expected_bytes(self) -> Option { + self.surface.expected_bytes() + } +} + +struct Shot { + token: u64, + path: Option, + reply: Sender, + deadline: Instant, + ready_at: Instant, + frames: u32, + issued: bool, + identity: CaptureIdentity, + identity_epoch: u64, + /// The surface epoch at issue; a later change invalidates the in-flight image. + surface_epoch: u64, + geometry: SurfaceGeometry, + expected: [usize; 2], + reserved: usize, + slot: Option, + callback: Option, +} + +/// All admitted screenshots. `requests` includes queued, issued and completed-but-undrained +/// captures; nothing is removed before it has been replied to or timed out. +#[derive(Default)] +pub(crate) struct Shots { + token: u64, + requests: Vec, +} + +impl Shots { + /// True when no queued, in-flight or completed-but-undrained request remains. + pub(crate) fn is_empty(&self) -> bool { + self.requests.is_empty() + } + + /// True when at least one admitted request has not been issued yet; drives the hidden-window repaint request. + pub(crate) fn has_unissued(&self) -> bool { + self.requests.iter().any(|shot| !shot.issued) + } + + /// Saturating sum of the byte reservations of all outstanding requests. + fn reserved(&self) -> usize { + self.requests.iter().fold(0usize, |sum, shot| sum.saturating_add(shot.reserved)) + } + + /// Test-only count of outstanding request records. + #[cfg(test)] + fn len(&self) -> usize { + self.requests.len() + } +} + +/// Advance the per-app request token, wrapping and skipping 0. +fn next_token(shots: &mut Shots) -> u64 { + let mut token = shots.token.wrapping_add(1); + if token == 0 { + token = 1; + } + shots.token = token; + token +} + +/// The JSON rejection envelope `{ok:false,error,request}` used by admission, issuance, drain and cancellation rejections; `control::save_screenshot` builds an identical envelope for its own failures. +fn error_reply(token: u64, message: impl std::fmt::Display) -> Value { + json!({"ok": false, "error": message.to_string(), "request": token}) +} + +/// Send an `error_reply` to `reply`; a closed receiver is ignored so admission never blocks. +fn reject(reply: Sender, token: u64, message: impl std::fmt::Display) { + let _ = reply.send(error_reply(token, message)); +} + +/// UID of the active drawing, or `None` when no drawing is open. +fn active_document(app: &CadApp) -> Option { + app.session.docs.get(app.session.active).map(|doc| doc.uid) +} + +/// The widget egui currently reports as keyboard-focused, if any. +fn current_focus(ctx: &egui::Context) -> Option { + ctx.memory(|memory| memory.focused()) +} + +/// Snapshot the active drawing UID, the monotonic document epoch and the focused widget together. +fn observed_identity(app: &CadApp, ctx: &egui::Context) -> CaptureIdentity { + CaptureIdentity { document: active_document(app), document_epoch: app.session.doc_epoch, focus: current_focus(ctx) } +} + +/// Store a fresh observation. The surface and identity epochs are independent: a surface change +/// re-baselines a queued request at issue, while any identity change rejects it, including one +/// that returns to the same document or focused widget. +fn observe(app: &mut CadApp, surface: SurfaceGeometry, identity: CaptureIdentity) { + let previous = app.capture_geometry; + let surface_changed = previous.is_none_or(|previous| previous.surface != surface); + let identity_changed = previous.is_none_or(|previous| previous.identity != identity); + if surface_changed || identity_changed { + app.capture_geometry = Some(CaptureGeometry { surface, identity }); + } + if surface_changed { + app.capture_epoch = app.capture_epoch.wrapping_add(1); + } + if identity_changed { + app.capture_identity_epoch = app.capture_identity_epoch.wrapping_add(1); + } +} + +/// Refresh the fresh raw-input surface/identity snapshot. Called from `raw_input_hook`, so it runs +/// on both UI and hidden logic-only callbacks. +pub(crate) fn note_raw_geometry(app: &mut CadApp, ctx: &egui::Context, raw: &egui::RawInput) { + let previous = app.capture_geometry.map(|geometry| geometry.surface); + let Some(surface) = SurfaceGeometry::from_raw(raw, ctx, previous) else { + if app.capture_geometry.is_some() { + app.capture_geometry = None; + app.capture_epoch = app.capture_epoch.wrapping_add(1); + app.capture_identity_epoch = app.capture_identity_epoch.wrapping_add(1); + } + return; + }; + let identity = observed_identity(app, ctx); + observe(app, surface, identity); +} + +/// Refresh the settled geometry of a UI pass (content rect and ppp), from every pass so a pass +/// that applies the saved interface zoom is the one admission and issuance use. +/// +/// An unusable pass snapshot invalidates the retained surface exactly like the raw path: the +/// previous geometry must not survive a pass whose pixels-per-point is invalid, or a later +/// admission could be bound to a surface no current pass renders. Both epochs grow so queued and +/// in-flight captures are re-based or rejected. +pub(crate) fn note_pass_geometry(app: &mut CadApp, ctx: &egui::Context) { + let previous = app.capture_geometry.map(|geometry| geometry.surface); + let Some(surface) = SurfaceGeometry::from_pass(ctx, previous) else { + if app.capture_geometry.is_some() { + app.capture_geometry = None; + app.capture_epoch = app.capture_epoch.wrapping_add(1); + app.capture_identity_epoch = app.capture_identity_epoch.wrapping_add(1); + } + return; + }; + let identity = observed_identity(app, ctx); + observe(app, surface, identity); +} + +/// Refresh only the binding identity. Call after anything that can change the drawing or the +/// keyboard focus within one callback (each control command, opening completion), so an +/// intervening A→B→A transition is still seen. +pub(crate) fn note_identity(app: &mut CadApp, ctx: &egui::Context) { + let Some(geometry) = app.capture_geometry else { return }; + let identity = observed_identity(app, ctx); + observe(app, geometry.surface, identity); +} + +/// Admit one `ui.screenshot` request without issuing it. Counts queued, in-flight and +/// completed-but-undrained requests and reserves the expected raw image bytes before any capture. +/// The reservation is the admission estimate; issuance re-bases it on the settled pass. +pub(crate) fn admit(app: &mut CadApp, ctx: &egui::Context, path: Option, reply: Sender) { + note_identity(app, ctx); + let token = next_token(&mut app.shots); + let Some(geometry) = app.capture_geometry else { + return reject(reply, token, "screenshot failed: the window geometry is not known yet"); + }; + let Some(expected) = geometry.expected_size() else { + return reject(reply, token, "screenshot failed: the window has no capturable size yet"); + }; + let Some(reserved) = geometry.expected_bytes() else { + return reject(reply, token, "screenshot failed: the window size overflows the screenshot budget"); + }; + if reserved > MAX_IMAGE_BYTES { + return reject( + reply, + token, + format!( + "screenshot budget exceeded: {reserved} bytes for one {}×{} image, over the {MAX_IMAGE_BYTES}-byte per-image limit", + expected[0], expected[1] + ), + ); + } + if app.shots.requests.len() >= MAX_REQUESTS { + return reject(reply, token, format!("screenshot budget exceeded: {MAX_REQUESTS} requests are already outstanding")); + } + let total = app.shots.reserved().saturating_add(reserved); + if total > MAX_TOTAL_BYTES { + return reject( + reply, + token, + format!("screenshot budget exceeded: {total} bytes reserved by outstanding requests, over the {MAX_TOTAL_BYTES}-byte limit"), + ); + } + let (slot, callback) = CaptureSlot::new(ctx, ctx.viewport_id(), MAX_IMAGE_BYTES); + let now = Instant::now(); + app.shots.requests.push(Shot { + token, + path, + reply, + deadline: now + DEADLINE, + ready_at: now + READY_DELAY, + frames: 0, + issued: false, + identity: geometry.identity, + identity_epoch: app.capture_identity_epoch, + surface_epoch: app.capture_epoch, + geometry: geometry.surface, + expected, + reserved, + slot: Some(slot), + callback: Some(callback), + }); + ctx.request_repaint(); +} + +/// Human-readable reason when the live document, focus or document epoch differs from the shot's admission identity; `None` while all still match. +fn identity_reason(app: &CadApp, ctx: &egui::Context, shot: &Shot) -> Option<&'static str> { + if active_document(app) != shot.identity.document { + Some("the document changed while the capture was queued") + } else if current_focus(ctx) != shot.identity.focus { + Some("the keyboard focus changed while the capture was queued") + } else if app.session.doc_epoch != shot.identity.document_epoch { + Some("the drawing changed while the capture was queued") + } else { + None + } +} + +/// Any observed identity change since admission, including a transition that returned to the +/// same document or widget (`capture_identity_epoch` only ever grows). +fn identity_change(app: &CadApp, ctx: &egui::Context, shot: &Shot) -> Option<&'static str> { + if app.capture_identity_epoch == shot.identity_epoch { + return None; + } + Some(identity_reason(app, ctx, shot).unwrap_or("the drawing or keyboard focus changed while the capture was queued")) +} + +/// Issue settled requests from the first non-discarded UI pass. +/// +/// egui discards a pass only when `will_discard()` says so, and the final pass of a multi-pass +/// frame reports false; a hidden `run_logic` tick never reaches `issue` at all. +pub(crate) fn issue(app: &mut CadApp, ctx: &egui::Context) { + if ctx.will_discard() { + return; + } + // The draw may have moved the focus or switched drawings: observe that before judging. + note_identity(app, ctx); + let now = Instant::now(); + let building = app.canvas.building(); + let mut failures: Vec<(usize, String)> = Vec::new(); + for i in 0..app.shots.requests.len() { + if app.shots.requests[i].issued { + continue; + } + app.shots.requests[i].frames = app.shots.requests[i].frames.saturating_add(1); + let shot = &app.shots.requests[i]; + if now < shot.ready_at || shot.frames < SETTLE_PASSES || (building && now < shot.ready_at + REBUILD_WAIT) { + continue; + } + if let Some(reason) = identity_change(app, ctx, shot) { + failures.push((i, reason.to_string())); + continue; + } + // Re-base the expected image on the settled pass. The admission estimate can predate the + // saved interface zoom, which egui applies at the start of a later pass. + let Some(current) = app.capture_geometry else { + failures.push((i, "screenshot failed: the window geometry is no longer known".into())); + continue; + }; + let Some(expected) = current.expected_size() else { + failures.push((i, "screenshot failed: the window has no capturable size".into())); + continue; + }; + let Some(reserved) = current.expected_bytes() else { + failures.push((i, "screenshot failed: the window size overflows the screenshot budget".into())); + continue; + }; + if reserved > MAX_IMAGE_BYTES { + failures.push(( + i, + format!( + "screenshot budget exceeded: {reserved} bytes for one {}×{} image, over the {MAX_IMAGE_BYTES}-byte per-image limit", + expected[0], expected[1] + ), + )); + continue; + } + let total = app.shots.reserved().saturating_sub(shot.reserved).saturating_add(reserved); + if total > MAX_TOTAL_BYTES { + failures.push(( + i, + format!("screenshot budget exceeded: {total} bytes reserved by outstanding requests, over the {MAX_TOTAL_BYTES}-byte limit"), + )); + continue; + } + let shot = &mut app.shots.requests[i]; + let Some(callback) = shot.callback.take() else { + failures.push((i, "screenshot failed: the capture callback was already consumed".into())); + continue; + }; + ctx.send_viewport_cmd_to(ctx.viewport_id(), ViewportCommand::Screenshot(callback)); + shot.issued = true; + shot.identity = current.identity; + shot.geometry = current.surface; + shot.expected = expected; + shot.reserved = reserved; + shot.surface_epoch = app.capture_epoch; + } + for (i, message) in failures.into_iter().rev() { + let shot = app.shots.requests.remove(i); + if let Some(slot) = shot.slot { + slot.cancel(); + } + reject(shot.reply, shot.token, message); + } + if !app.shots.is_empty() { + ctx.request_repaint_after(REPAINT_INTERVAL); + } +} + +/// Drain completed slots and expire timed-out requests. Call from every logic callback (including +/// hidden ones). The deadline is checked before taking or writing an image. +pub(crate) fn drain(app: &mut CadApp, ctx: &egui::Context) { + note_identity(app, ctx); + let mut i = 0; + while i < app.shots.requests.len() { + let now = Instant::now(); + if now >= app.shots.requests[i].deadline { + let shot = app.shots.requests.remove(i); + if let Some(slot) = shot.slot { + slot.cancel(); + } + reject(shot.reply, shot.token, "no frame was presented before the screenshot timeout"); + continue; + } + if let Some(reason) = identity_change(app, ctx, &app.shots.requests[i]) { + let shot = app.shots.requests.remove(i); + if let Some(slot) = shot.slot { + slot.cancel(); + } + reject(shot.reply, shot.token, format!("screenshot failed: {reason}")); + continue; + } + if !app.shots.requests[i].issued { + // A queued request re-bases its geometry on the settled pass at issue. + i += 1; + continue; + } + if app.capture_epoch != app.shots.requests[i].surface_epoch + || app.capture_geometry.map(|geometry| geometry.surface) != Some(app.shots.requests[i].geometry) + { + let shot = app.shots.requests.remove(i); + if let Some(slot) = shot.slot { + slot.cancel(); + } + reject(shot.reply, shot.token, "screenshot failed: the window geometry changed while the capture was queued"); + continue; + } + let Some(result) = app.shots.requests[i].slot.as_ref().and_then(CaptureSlot::take) else { + i += 1; + continue; + }; + let shot = app.shots.requests.remove(i); + if let Some(slot) = shot.slot { + slot.cancel(); + } + match result { + Ok(image) => { + let reply = if image.size != shot.expected { + error_reply( + shot.token, + format!( + "screenshot is {}×{} pixels, expected {}×{}: the window changed size while it was captured", + image.size[0], image.size[1], shot.expected[0], shot.expected[1] + ), + ) + } else { + crate::control::save_screenshot(&image, shot.path.as_deref(), shot.token) + }; + let _ = shot.reply.send(reply); + } + Err(error) => { + let _ = shot.reply.send(error_reply(shot.token, error)); + } + } + } + if !app.shots.is_empty() { + ctx.request_repaint_after(REPAINT_INTERVAL); + } +} + +/// Cancel every queued/in-flight capture (app exit). Late callbacks see a dropped slot and write +/// nothing; clients receive one error reply instead of hanging past the transport timeout. +pub(crate) fn cancel_all(app: &mut CadApp) { + for shot in app.shots.requests.drain(..) { + if let Some(slot) = shot.slot { + slot.cancel(); + } + let _ = shot.reply.send(error_reply(shot.token, "screenshot cancelled: the app is closing")); + } +} + +/// Ask for a painted frame while a request is still queued, and on hidden logic ticks when a +/// capture is waiting for settlement. Native eframe honours `RequestPaintWhileHidden`; the web +/// runner currently logs it as unhandled, so web only schedules a repaint and hidden web captures +/// still reach their timeout (a documented limitation, not a success path). +pub(crate) fn request_hidden_paint(app: &CadApp, ctx: &egui::Context) { + if app.shots.has_unissued() { + #[cfg(not(target_arch = "wasm32"))] + ctx.send_viewport_cmd(ViewportCommand::RequestPaintWhileHidden); + ctx.request_repaint(); + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::*; + use cadcraft_engine::Session; + use egui::{Color32, ColorImage, ViewportId, ViewportInfo}; + use serde_json::json; + use std::sync::mpsc::{Receiver, channel}; + + use crate::Services; + + /// An empty `CadApp` over a fresh `Session` for capture tests. + fn app() -> CadApp { + CadApp::new(Session::new(), Services::default()) + } + + /// A visible, focused, 1-ppp surface of `size` with an 8192-pixel texture side. + fn surface(size: egui::Vec2) -> SurfaceGeometry { + SurfaceGeometry { + viewport: Some(size), + pixels_per_point: 1.0, + max_texture_side: 8192, + minimized: false, + occluded: false, + window_focused: true, + } + } + + /// Seed the admission snapshot a raw hook would have taken, then bump both epochs as an + /// observation of a change would. + fn set_geometry(app: &mut CadApp, ctx: &egui::Context, size: egui::Vec2) { + app.capture_geometry = Some(CaptureGeometry { surface: surface(size), identity: observed_identity(app, ctx) }); + app.capture_epoch = app.capture_epoch.wrapping_add(1); + app.capture_identity_epoch = app.capture_identity_epoch.wrapping_add(1); + } + + /// Seed 800×600 geometry, admit one pathless request and return its reply receiver and token. + fn admit_one(app: &mut CadApp, ctx: &egui::Context) -> (Receiver, u64) { + set_geometry(app, ctx, egui::vec2(800.0, 600.0)); + let (tx, rx) = channel(); + admit(app, ctx, None, tx); + let token = app.shots.requests.first().map(|shot| shot.token).unwrap_or_default(); + (rx, token) + } + + /// Focused `RawInput` with a `width`×`height` screen rect and an 8192-pixel texture side. + fn raw(width: f32, height: f32) -> egui::RawInput { + egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, height))), + focused: true, + max_texture_side: Some(8192), + ..Default::default() + } + } + + /// One real UI pass, with a presented frame the screenshot commands can be taken from. + fn ui_frame(app: &mut CadApp, ctx: &egui::Context) -> egui::FullOutput { + ui_frame_sized(app, ctx, 800.0, 600.0) + } + + /// Run one real `logic` + `ui` pass at the requested size and drop texture deltas, yielding the frame's output. + fn ui_frame_sized(app: &mut CadApp, ctx: &egui::Context, width: f32, height: f32) -> egui::FullOutput { + let raw = raw(width, height); + note_raw_geometry(app, ctx, &raw); + let mut out = ctx.run_ui(raw, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + out + } + + /// Every `ViewportCommand::Screenshot` callback emitted by a frame. + fn screenshot_callbacks(out: &egui::FullOutput) -> Vec { + out.viewport_output + .values() + .flat_map(|viewport| viewport.commands.iter()) + .filter_map(|command| match command { + egui::ViewportCommand::Screenshot(callback) => Some(callback.clone()), + _ => None, + }) + .collect() + } + + /// Drive the settling passes so `issue` sends the real `ViewportCommand::Screenshot`. + fn issue_after_settling(app: &mut CadApp, ctx: &egui::Context) -> (egui::FullOutput, Vec) { + for shot in &mut app.shots.requests { + // Exercise the settled-frame path without sleeping for either wall-clock delay. + // A scale change can also start an asynchronous canvas rebuild. + shot.ready_at = Instant::now() - REBUILD_WAIT - Duration::from_millis(1); + } + ui_frame(app, ctx).drop_without_applying_deltas(); + ui_frame(app, ctx).drop_without_applying_deltas(); + let out = ui_frame(app, ctx); + let callbacks = screenshot_callbacks(&out); + (out, callbacks) + } + + /// Complete each screenshot callback with a solid red image of `size`. + fn complete(callbacks: Vec, size: [usize; 2]) { + let image = Arc::new(ColorImage::filled(size, Color32::RED)); + for callback in callbacks { + callback.complete(Arc::clone(&image)); + } + } + + /// A completed-but-undrained shot still occupies a `MAX_REQUESTS` slot; the next admission is rejected as "outstanding" and no earlier token is replaced. + #[test] + fn admission_counts_completed_but_undrained_and_never_evicts() { + let ctx = egui::Context::default(); + let mut app = app(); + let _receivers: Vec<_> = (0..MAX_REQUESTS).map(|_| admit_one(&mut app, &ctx)).collect(); + assert_eq!(app.shots.len(), MAX_REQUESTS); + // Complete the first slot without draining it: it still owns its reservation and request. + let image = Arc::new(ColorImage::filled([800, 600], Color32::RED)); + app.shots.requests[0].callback.take().expect("a callback").complete(image); + let (tx, rx) = channel(); + admit(&mut app, &ctx, None, tx); + assert_eq!(app.shots.len(), MAX_REQUESTS, "the fifth request is rejected, not admitted"); + let reply = rx.recv().expect("a rejection reply"); + assert_eq!(reply["ok"], false); + assert!(reply["error"].as_str().unwrap_or_default().contains("outstanding"), "{reply}"); + assert!(app.shots.requests.iter().all(|shot| shot.token != reply["request"].as_u64().unwrap_or_default())); + } + + /// A completed callback whose deadline has passed is rejected: the reply reports "timeout" and the request is removed from `Shots`; the test does not inspect the filesystem. + #[test] + fn deadline_is_checked_before_writing_a_completed_image() { + let ctx = egui::Context::default(); + let mut app = app(); + let (rx, _) = admit_one(&mut app, &ctx); + app.shots.requests[0].issued = true; + app.shots.requests[0].surface_epoch = app.capture_epoch; + app.shots.requests[0].deadline = Instant::now() - Duration::from_millis(1); + let image = Arc::new(ColorImage::filled([800, 600], Color32::RED)); + app.shots.requests[0].callback.take().expect("a callback").complete(image); + drain(&mut app, &ctx); + let reply = rx.recv().expect("a timeout reply"); + assert_eq!(reply["ok"], false); + assert!(reply["error"].as_str().unwrap_or_default().contains("timeout"), "{reply}"); + assert!(app.shots.is_empty()); + } + + /// A drawing switch before issue rejects the queued request as "document changed" and empties the queue. + #[test] + fn document_change_cancels_before_issuing() { + let ctx = egui::Context::default(); + let mut app = app(); + let (rx, _) = admit_one(&mut app, &ctx); + app.session.new_drawing(false); + drain(&mut app, &ctx); + let reply = rx.recv().expect("a document-change reply"); + assert!(reply["error"].as_str().unwrap_or_default().contains("document changed"), "{reply}"); + assert!(app.shots.is_empty()); + } + + /// Moving keyboard focus before issue rejects the queued capture as "focus changed" and empties the queue. + #[test] + fn focus_change_cancels_an_unissued_capture() { + let ctx = egui::Context::default(); + let focus_a = egui::Id::unique("focus-a"); + let focus_b = egui::Id::unique("focus-b"); + ctx.memory_mut(|memory| memory.request_focus(focus_a)); + let mut app = app(); + set_geometry(&mut app, &ctx, egui::vec2(800.0, 600.0)); + let (tx, rx) = channel(); + admit(&mut app, &ctx, None, tx); + assert_eq!(app.shots.len(), 1); + ctx.memory_mut(|memory| memory.request_focus(focus_b)); + drain(&mut app, &ctx); + let reply = rx.recv().expect("a focus-change reply"); + assert!(reply["error"].as_str().unwrap_or_default().contains("focus changed"), "{reply}"); + assert!(app.shots.is_empty()); + } + + /// Two requests issue from one settled frame; each reply carries its own token and a distinct, readable file with a PNG signature. + #[test] + fn issued_callbacks_reply_once_with_matching_tokens_and_distinct_pathless_files() { + let ctx = egui::Context::default(); + let mut app = app(); + set_geometry(&mut app, &ctx, egui::vec2(800.0, 600.0)); + let (tx1, rx1) = channel(); + let (tx2, rx2) = channel(); + admit(&mut app, &ctx, None, tx1); + admit(&mut app, &ctx, None, tx2); + let tokens: Vec<_> = app.shots.requests.iter().map(|shot| shot.token).collect(); + let (_, callbacks) = issue_after_settling(&mut app, &ctx); + assert_eq!(callbacks.len(), 2, "both requests issue in the same settled frame"); + complete(callbacks, [800, 600]); + drain(&mut app, &ctx); + let first = rx1.recv().expect("the first reply"); + let second = rx2.recv().expect("the second reply"); + assert_eq!((first["ok"].clone(), second["ok"].clone()), (json!(true), json!(true))); + assert_eq!(first["request"].as_u64(), tokens.first().copied()); + assert_eq!(second["request"].as_u64(), tokens.get(1).copied()); + let a = first["result"]["path"].as_str().expect("the first file").to_string(); + let b = second["result"]["path"].as_str().expect("the second file").to_string(); + assert_ne!(a, b, "one file per request"); + for path in [&a, &b] { + assert!(std::fs::read(path).is_ok_and(|bytes| bytes.starts_with(b"\x89PNG\r\n\x1a\n")), "{path}"); + let _ = std::fs::remove_file(path); + } + assert!(app.shots.is_empty()); + } + + /// A drawing switch while a capture is in flight rejects its completed callback as "document changed" and reports no result path. + #[test] + fn document_switch_after_issue_rejects_the_completed_callback_without_writing() { + let ctx = egui::Context::default(); + let mut app = app(); + let (rx, token) = admit_one(&mut app, &ctx); + let (_, callbacks) = issue_after_settling(&mut app, &ctx); + assert_eq!(callbacks.len(), 1); + complete(callbacks, [800, 600]); + app.session.new_drawing(false); + drain(&mut app, &ctx); + let reply = rx.recv().expect("a document-change reply"); + assert_eq!(reply["request"].as_u64(), Some(token)); + assert!(reply["error"].as_str().unwrap_or_default().contains("document changed"), "{reply}"); + assert!(reply.get("result").is_none(), "a rejected capture must not report or write a file"); + assert!(app.shots.is_empty()); + } + + /// Once a callback has completed but the deadline has passed, drain rejects it as "timeout" with no result path. + #[test] + fn completed_callback_after_timeout_writes_nothing() { + let ctx = egui::Context::default(); + let mut app = app(); + let (rx, _) = admit_one(&mut app, &ctx); + let (_, callbacks) = issue_after_settling(&mut app, &ctx); + complete(callbacks, [800, 600]); + app.shots.requests[0].deadline = Instant::now() - Duration::from_millis(1); + drain(&mut app, &ctx); + let reply = rx.recv().expect("a timeout reply"); + assert!(reply["error"].as_str().unwrap_or_default().contains("timeout"), "{reply}"); + assert!(reply.get("result").is_none(), "a timed-out capture must not write a file"); + assert!(app.shots.is_empty()); + } + + /// Cancelling all shots replies "cancelled" and wins over an already-completed callback, which is never reported. + #[test] + fn callback_completed_before_cancel_all_is_inert() { + let ctx = egui::Context::default(); + let mut app = app(); + let (rx, token) = admit_one(&mut app, &ctx); + let (_, callbacks) = issue_after_settling(&mut app, &ctx); + complete(callbacks, [800, 600]); + cancel_all(&mut app); + let reply = rx.recv().expect("a cancellation reply"); + assert_eq!(reply["request"].as_u64(), Some(token)); + assert!(reply["error"].as_str().unwrap_or_default().contains("cancelled"), "{reply}"); + assert!(reply.get("result").is_none(), "cancellation wins over a late completion"); + assert!(app.shots.is_empty()); + } + + /// A 5000×5000 image is rejected by the 64 MiB per-image bound; after two 2500×2500 admissions a third is rejected by the aggregate bound, leaving two queued. + #[test] + fn per_image_and_aggregate_budgets_reject_before_issue() { + let ctx = egui::Context::default(); + let mut app = app(); + // 5000×5000×4 bytes = 100 MB, over the 64 MiB per-image bound. + set_geometry(&mut app, &ctx, egui::vec2(5000.0, 5000.0)); + let (tx, rx) = channel(); + admit(&mut app, &ctx, None, tx); + let reply = rx.recv().expect("per-image rejection"); + assert!(reply["error"].as_str().unwrap_or_default().contains("per-image"), "{reply}"); + assert!(app.shots.is_empty()); + + // 2500×2500×4 bytes = 25 MB each: two fit, the third exceeds the 64 MiB aggregate bound. + set_geometry(&mut app, &ctx, egui::vec2(2500.0, 2500.0)); + let _first = admit_one_at(&mut app, &ctx, egui::vec2(2500.0, 2500.0)); + let _second = admit_one_at(&mut app, &ctx, egui::vec2(2500.0, 2500.0)); + let (tx, rx) = channel(); + admit(&mut app, &ctx, None, tx); + let reply = rx.recv().expect("aggregate rejection"); + assert!(reply["error"].as_str().unwrap_or_default().contains("outstanding requests"), "{reply}"); + assert_eq!(app.shots.len(), 2); + } + + /// Seed an arbitrary surface size, admit one pathless request and return the newly queued request's receiver and token. + fn admit_one_at(app: &mut CadApp, ctx: &egui::Context, size: egui::Vec2) -> (Receiver, u64) { + set_geometry(app, ctx, size); + let (tx, rx) = channel(); + admit(app, ctx, None, tx); + let token = app.shots.requests.last().map(|shot| shot.token).unwrap_or_default(); + (rx, token) + } + + /// Every observed surface change, including A→B→A, bumps the surface epoch and retains the latest geometry. + #[test] + fn every_observed_geometry_change_bumps_the_generation() { + let ctx = egui::Context::default(); + let mut app = app(); + note_raw_geometry(&mut app, &ctx, &raw(800.0, 600.0)); + let first = app.capture_epoch; + note_raw_geometry(&mut app, &ctx, &raw(900.0, 600.0)); + let second = app.capture_epoch; + note_raw_geometry(&mut app, &ctx, &raw(800.0, 600.0)); + let third = app.capture_epoch; + assert!(first < second && second < third, "A→B→A bumps the generation at each observed change"); + assert_eq!(app.capture_geometry.and_then(|g| g.expected_size()), Some([800, 600])); + } + + /// An invalid settled pass must not leave the previous surface retained: the raw path clears + /// it, and the pass path has to invalidate it identically so a later admission cannot reuse a + /// surface no current pass renders. + #[test] + fn an_invalid_pass_invalidates_the_retained_surface() { + let ctx = egui::Context::default(); + let mut app = app(); + note_raw_geometry(&mut app, &ctx, &raw(800.0, 600.0)); + assert!(app.capture_geometry.is_some(), "the raw snapshot was retained"); + let surface_epoch = app.capture_epoch; + let identity_epoch = app.capture_identity_epoch; + let mut invalid = raw(800.0, 600.0); + invalid.viewports.insert(ViewportId::ROOT, ViewportInfo { native_pixels_per_point: Some(0.0), ..Default::default() }); + let mut out = ctx.run_ui(invalid, |_| {}); + out.textures_delta.clear(); + assert_eq!(ctx.pixels_per_point(), 0.0, "the pass pixels-per-point is invalid"); + note_pass_geometry(&mut app, &ctx); + assert!(app.capture_geometry.is_none(), "an invalid pass must not keep the previous surface"); + assert!(app.capture_epoch > surface_epoch, "the surface epoch changes"); + assert!(app.capture_identity_epoch > identity_epoch, "the identity epoch changes with the surface"); + let (tx, rx) = channel(); + admit(&mut app, &ctx, None, tx); + let reply = rx.recv().expect("an admission rejection"); + assert_eq!(reply["ok"], false); + assert!(reply["error"].as_str().unwrap_or_default().contains("not known"), "{reply}"); + } + + /// With a screen rect but no inner rect, 640×480 points at 2 ppp yields a `[1280, 960]` image and keeps the focused flag. + #[test] + fn raw_geometry_uses_screen_rect_when_inner_rect_is_absent() { + let ctx = egui::Context::default(); + let mut app = app(); + let mut raw = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(640.0, 480.0))), + focused: true, + max_texture_side: Some(4096), + ..Default::default() + }; + raw.viewports.insert(ViewportId::ROOT, ViewportInfo { native_pixels_per_point: Some(2.0), ..Default::default() }); + note_raw_geometry(&mut app, &ctx, &raw); + let geometry = app.capture_geometry.expect("fresh geometry"); + assert_eq!(geometry.expected_size(), Some([1280, 960])); + assert!(geometry.surface.window_focused); + } + + /// The producer regression for the pass-lifecycle defect: a first pass that egui discards must + /// not issue, and the settled second pass must issue exactly once. + #[test] + fn a_discarded_pass_does_not_issue_and_the_settled_pass_does_once() { + let ctx = egui::Context::default(); + let mut app = app(); + let (rx, _) = admit_one(&mut app, &ctx); + for shot in &mut app.shots.requests { + shot.ready_at = Instant::now() - Duration::from_millis(1); + shot.frames = SETTLE_PASSES - 1; + } + let raw = raw(800.0, 600.0); + note_raw_geometry(&mut app, &ctx, &raw); + let mut discard = true; + let mut out = ctx.run_ui(raw, |ui| { + app.logic(ui.ctx()); + if std::mem::take(&mut discard) { + ui.ctx().request_discard("test: force a second pass"); + } + app.ui(ui); + }); + out.textures_delta.clear(); + let callbacks = screenshot_callbacks(&out); + assert_eq!(callbacks.len(), 1, "the settled pass issues once; the discarded pass does not"); + assert_eq!(app.ui_frames, 1, "one settled frame, not one per pass"); + complete(callbacks, [800, 600]); + drain(&mut app, &ctx); + assert_eq!(rx.recv().expect("a reply")["ok"], true); + } + + /// Issuance re-bases the expected image on the settled pass, so the saved interface zoom + /// (applied by egui at the start of a later pass) cannot leave a stale admission estimate. + #[test] + fn issuance_uses_the_settled_interface_scale_not_the_admission_estimate() { + let ctx = egui::Context::default(); + let mut app = app(); + app.options.interface_scale = 2.0; + let raw = raw(800.0, 600.0); + note_raw_geometry(&mut app, &ctx, &raw); + let (tx, rx) = channel(); + admit(&mut app, &ctx, None, tx); + assert_eq!(app.shots.requests[0].expected, [800, 600], "the admission estimate predates the zoom"); + let (_, callbacks) = issue_after_settling(&mut app, &ctx); + assert_eq!(callbacks.len(), 1); + assert_eq!(app.shots.requests[0].expected, [1600, 1200], "issued against the settled pass scale"); + complete(callbacks, [1600, 1200]); + drain(&mut app, &ctx); + let reply = rx.recv().expect("a reply"); + assert_eq!(reply["ok"], true, "{reply}"); + let path = reply["result"]["path"].as_str().expect("a file").to_string(); + let _ = std::fs::remove_file(path); + } + + /// A→B→A: a switch that returns to the same drawing still invalidates the queued request. + #[test] + fn a_document_switch_that_returns_still_rejects_the_queued_capture() { + let ctx = egui::Context::default(); + let mut app = app(); + app.session.new_drawing(false); + let (rx, _) = admit_one(&mut app, &ctx); + let before = active_document(&app); + app.run("document.switch", json!({ "index": 0 })).expect("switch to the first drawing"); + note_identity(&mut app, &ctx); + app.run("document.switch", json!({ "index": 1 })).expect("switch back"); + note_identity(&mut app, &ctx); + assert_eq!(active_document(&app), before, "the drawing and focus are back where they were"); + let (_, callbacks) = issue_after_settling(&mut app, &ctx); + assert!(callbacks.is_empty(), "the request is rejected, not issued"); + let reply = rx.recv().expect("a rejection reply"); + assert!(reply["error"].as_str().unwrap_or_default().contains("changed"), "{reply}"); + assert!(reply.get("result").is_none()); + } + + /// A drawing created and closed inside one callback never reaches the same observed values, but + /// the engine's monotonic document epoch still records it. + #[test] + fn a_drawing_created_and_closed_between_observations_still_bumps_the_identity_epoch() { + let ctx = egui::Context::default(); + let mut app = app(); + set_geometry(&mut app, &ctx, egui::vec2(800.0, 600.0)); + let before_epoch = app.capture_identity_epoch; + let before_doc = active_document(&app); + app.session.new_drawing(false); + app.run("close", json!({})).expect("close the new drawing"); + assert_eq!(active_document(&app), before_doc, "the same drawing is active again"); + note_identity(&mut app, &ctx); + assert!(app.capture_identity_epoch > before_epoch, "the intervening switch is still observed"); + } + + /// Raw-hook and pass geometry must agree, or every frame would re-baseline the surface and a + /// capture in flight could never settle. + #[test] + fn a_settled_window_does_not_keep_changing_the_surface_epoch() { + let ctx = egui::Context::default(); + let mut app = app(); + ui_frame(&mut app, &ctx).drop_without_applying_deltas(); + let epoch = app.capture_epoch; + let identity = app.capture_identity_epoch; + for _ in 0..4 { + ui_frame(&mut app, &ctx).drop_without_applying_deltas(); + } + assert_eq!(app.capture_epoch, epoch, "the raw hook and the pass agree on the surface"); + assert_eq!(app.capture_identity_epoch, identity, "nothing changed the drawing or focus"); + assert_eq!(app.capture_geometry.and_then(|geometry| geometry.expected_size()), Some([800, 600])); + } + + /// A window resized while the request is still settling is issued against the new size. + #[test] + fn a_resize_while_settling_is_absorbed_at_issue() { + let ctx = egui::Context::default(); + let mut app = app(); + let (rx, _) = admit_one(&mut app, &ctx); + for shot in &mut app.shots.requests { + shot.ready_at = Instant::now() - Duration::from_millis(1); + } + ui_frame_sized(&mut app, &ctx, 900.0, 600.0).drop_without_applying_deltas(); + ui_frame_sized(&mut app, &ctx, 900.0, 600.0).drop_without_applying_deltas(); + let out = ui_frame_sized(&mut app, &ctx, 900.0, 600.0); + let callbacks = screenshot_callbacks(&out); + out.drop_without_applying_deltas(); + assert_eq!(callbacks.len(), 1); + assert_eq!(app.shots.requests[0].expected, [900, 600], "issued against the settled size"); + complete(callbacks, [900, 600]); + drain(&mut app, &ctx); + let reply = rx.recv().expect("a reply"); + assert_eq!(reply["ok"], true, "{reply}"); + let path = reply["result"]["path"].as_str().expect("a file").to_string(); + let _ = std::fs::remove_file(path); + } +} diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index b30a0110f..f3a935d79 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -69,6 +69,8 @@ pub fn title_and_toolbar(app: &mut CadApp, ui: &mut egui::Ui) { egui::Panel::top("cc_title_toolbar").exact_size(title_h + tb_h).frame(egui::Frame::NONE.fill(t.chrome)).show(ui, |ui| { let r = ui.max_rect(); if title_h > 0.0 { + // The empty row moves the window; it goes first so later widgets take their own clicks. + craft_window::title_bar::empty_area(ui, Rect::from_min_size(r.min, vec2(r.width(), title_h))); let title = match app.session.state() { Ok(st) if !app.ui.start_tab => format!("CADCraft {}{}", st.title, if st.title.contains('.') { "" } else { ".dwg" }), _ => format!("CADCraft {}", crate::tl!("Start")), @@ -78,16 +80,6 @@ pub fn title_and_toolbar(app: &mut CadApp, ui: &mut egui::Ui) { let galley = ui.painter().layout_no_wrap(title, egui::FontId::proportional(13.0), t.text_dim); let x = title_left(r.left(), r.width(), galley.size().x); 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); - if resp.drag_started() { - ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag); - } - if resp.double_clicked() { - let max = ui.ctx().input(|i| i.viewport().maximized.unwrap_or(false)); - ui.ctx().send_viewport_cmd(egui::ViewportCommand::Maximized(!max)); - } } if tb_h > 0.0 { let row = Rect::from_min_size(pos2(r.left(), r.top() + title_h), vec2(r.width(), tb_h)); @@ -122,7 +114,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 +133,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 +176,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 +230,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 +240,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 +290,8 @@ fn visible_tabs(widths: &[f32], active: Option, 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 +308,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 +321,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 +362,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 +411,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 +427,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 +442,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 +483,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 +535,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 +549,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)); } diff --git a/crates/ui-egui/src/chrome/annoscale.rs b/crates/ui-egui/src/chrome/annoscale.rs index 4aa9a2613..b18db6e19 100644 --- a/crates/ui-egui/src/chrome/annoscale.rs +++ b/crates/ui-egui/src/chrome/annoscale.rs @@ -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); diff --git a/crates/ui-egui/src/clipboard.rs b/crates/ui-egui/src/clipboard.rs index af612aba7..69070f4fb 100644 --- a/crates/ui-egui/src/clipboard.rs +++ b/crates/ui-egui/src/clipboard.rs @@ -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. diff --git a/crates/ui-egui/src/closing.rs b/crates/ui-egui/src/closing.rs index bd1d30148..04d526fa2 100644 --- a/crates/ui-egui/src/closing.rs +++ b/crates/ui-egui/src/closing.rs @@ -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); diff --git a/crates/ui-egui/src/cmdline.rs b/crates/ui-egui/src/cmdline.rs index c136b7032..d78561551 100644 --- a/crates/ui-egui/src/cmdline.rs +++ b/crates/ui-egui/src/cmdline.rs @@ -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, + /// 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) { + fn frame(app: &mut CadApp, ctx: &egui::Context, events: Vec) -> 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()); diff --git a/crates/ui-egui/src/context_menu.rs b/crates/ui-egui/src/context_menu.rs index 68f427679..bbaf48df3 100644 --- a/crates/ui-egui/src/context_menu.rs +++ b/crates/ui-egui/src/context_menu.rs @@ -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())); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index bc6d23ef5..2dc1f898a 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -102,8 +102,9 @@ struct Widget { selected: Option, } +/// 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()}, }) } @@ -452,7 +454,7 @@ pub fn handle(app: &mut CadApp, ctx: &egui::Context, req: &ControlRequest) -> Ou } match serde_json::from_value::(cur) { Ok(u) => { - if let Err(error) = u.docking.validate() { + if let Err(error) = crate::docking::validate(&u.docking) { return err(error); } app.ui = u; @@ -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 { + use std::sync::OnceLock; + static DIR: OnceLock> = 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---` directories in the temp dir; only `AlreadyExists` is retried. +fn create_private_shot_dir() -> std::io::Result { + 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 { + 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 { + 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")); diff --git a/crates/ui-egui/src/credits.rs b/crates/ui-egui/src/credits.rs index 2385c61b5..6c3276c91 100644 --- a/crates/ui-egui/src/credits.rs +++ b/crates/ui-egui/src/credits.rs @@ -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::(id)).unwrap_or_default(); ui.horizontal_wrapped(|ui| { ui.label(crate::tl!("Show")); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 2a46d7ed7..37de4f197 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -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::(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::(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() { diff --git a/crates/ui-egui/src/docking.rs b/crates/ui-egui/src/docking.rs index c44f8d1cf..bbbf08902 100644 --- a/crates/ui-egui/src/docking.rs +++ b/crates/ui-egui/src/docking.rs @@ -1,7 +1,10 @@ //! Application panel identities and persistence around the shared docking renderer. //! Document contents remain owned by their existing view implementations. -use craft_ui::docking::{Action, DockArea, DockStyle, Layout, Location, Node, PanelLimits, Permissions, Placement, Zone}; +use craft_ui::docking::{ + Action, Column, DockArea, DockCommand, DockContent, DockHeights, DockOperation, DockStyle, FloatSpace, Group, Layout, Location, Node, + PanelLimits, Permissions, Placement, Side, Sides, Target, UiDock, Zone, +}; use craft_ui::layout::{SplitAxis, SplitSize}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; @@ -40,25 +43,25 @@ impl Panel { Self::ExternalRefs => "External References", } } - fn default_side(self) -> Zone { + /// The icon a palette shows in a column collapsed to icons. + pub fn icon(self) -> Option { + use crate::icons::Icon; match self { - Self::Canvas => Zone::Right, - Self::ToolSets => Zone::Left, - Self::QuickCalc => Zone::Right, - Self::Layers => Zone::Right, - Self::Properties => Zone::Right, - Self::ExternalRefs => Zone::Right, + Self::Canvas => None, + Self::ToolSets => Some(Icon::Switcher), + Self::QuickCalc => Some(Icon::Table), + Self::Layers => Some(Icon::Layers), + Self::Properties => Some(Icon::Properties), + Self::ExternalRefs => Some(Icon::Attach), } } /// Palettes outside the default arrangement, opened on request (QUICKCALC). pub const ON_DEMAND: &[Self] = &[Self::QuickCalc]; - fn parse(id: &str) -> Result { - Self::ALL.iter().chain(Self::ON_DEMAND).copied().find(|panel| panel.id() == id).ok_or_else(|| format!("Unknown panel: {id}")) - } } -fn permissions(panel: &Panel) -> Permissions { - if *panel == Panel::Canvas { Permissions::PROTECTED } else { Permissions::default() } +/// The drawing view never moves, floats, closes or takes tabs; palettes dock beside it. +pub fn permissions(panel: &Panel) -> Permissions { + if *panel == Panel::Canvas { Permissions { accepts_splits: true, ..Permissions::PROTECTED } } else { Permissions::default() } } fn tabs(panel: Panel) -> Node { @@ -69,230 +72,218 @@ fn split(axis: SplitAxis, size: SplitSize, first: Node, second: Node, - /// Closed panels retain their most recent group, side, size or floating rectangle. - pub hidden: Vec<(Panel, Location)>, +/// The saved panel arrangement: craft-ui's workspace over CADCraft's panels. +pub type Workspace = craft_ui::docking::Workspace; + +/// The panel layout's shape version, saved beside it. Layouts saved without it, or with an +/// older one, are the split frames from before the dock columns and give way to the default. +pub const LAYOUT_VERSION: u32 = 2; + +/// Width of the left dock's Tool Sets column. +pub const TOOL_SETS_WIDTH: f32 = 220.0; +/// Width of the right dock's palettes column. +pub const PALETTES_WIDTH: f32 = 300.0; +/// Layers' body height above Properties in a dock column. +const LAYERS_HEIGHT: f32 = 280.0; + +fn dock(side: Side, width: f32, groups: &[Panel]) -> Node { + let groups = groups.iter().map(|panel| Group::new(vec![*panel], 0)).collect(); + Node::Dock { side, columns: vec![Column::new(groups, width)] } } -impl Default for Workspace { - fn default() -> Self { - Self { - layout: Layout { - root: Some(split( - SplitAxis::Horizontal, - SplitSize::FixedFirst(220.0), - tabs(Panel::ToolSets), - split( - SplitAxis::Horizontal, - SplitSize::FixedSecond(300.0), - tabs(Panel::Canvas), - Node::Stack { - entries: vec![ - craft_ui::docking::StackEntry { panel: Panel::Layers, open: true, height: Some(280.0) }, - craft_ui::docking::StackEntry { panel: Panel::Properties, open: true, height: None }, - ], - }, - ), - )), - floating: Vec::new(), - }, - hidden: Vec::new(), +/// Tool Sets in the left dock, the drawing, and Layers above Properties in the right dock. +pub fn default_workspace() -> Workspace { + Workspace::new(Layout { + root: Some(split( + SplitAxis::Horizontal, + SplitSize::FixedFirst(TOOL_SETS_WIDTH), + dock(Side::Start, TOOL_SETS_WIDTH, &[Panel::ToolSets]), + split( + SplitAxis::Horizontal, + SplitSize::FixedSecond(PALETTES_WIDTH), + tabs(Panel::Canvas), + dock(Side::End, PALETTES_WIDTH, &[Panel::Layers, Panel::Properties]), + ), + )), + floating: Vec::new(), + }) +} + +/// The dock a panel belongs in: Tool Sets on the left, the palettes on the right. +fn home_side(panel: Panel) -> (Side, f32) { + if panel == Panel::ToolSets { (Side::Start, TOOL_SETS_WIDTH) } else { (Side::End, PALETTES_WIDTH) } +} + +/// The panels of every dock group on `side`: columns from the window edge inwards, groups from +/// top to bottom. +fn dock_groups(layout: &Layout, side: Side) -> Vec>> { + let mut nodes: Vec<&Node> = layout.root.iter().collect(); + let mut seen = 0usize; + while let Some(node) = nodes.pop() { + seen = seen.saturating_add(1); + if seen > 4 * craft_ui::docking::MAX_PANELS { + break; } + match node { + Node::Split { first, second, .. } => nodes.extend([&**first, &**second]), + Node::Dock { side: s, columns } if *s == side => { + return columns.iter().map(|column| column.groups.iter().map(|group| group.panels.clone()).collect()).collect(); + } + _ => {} + } + } + Vec::new() +} + +fn docked(layout: &Layout, panel: Panel) -> bool { + [Side::Start, Side::End].into_iter().any(|side| dock_groups(layout, side).iter().flatten().flatten().any(|p| *p == panel)) +} + +/// The docked neighbour a panel with no recorded place opens beside, as a new group above +/// (`false`) or below (`true`) it: Layers above Properties, External References and QuickCalc +/// below them, Tool Sets at the top of the left dock. +fn home_anchor(layout: &Layout, panel: Panel) -> Option<(Panel, bool)> { + let neighbours: &[(Panel, bool)] = match panel { + Panel::Canvas | Panel::ToolSets => &[], + Panel::Layers => &[(Panel::Properties, false), (Panel::ExternalRefs, false)], + Panel::Properties => &[(Panel::Layers, true), (Panel::ExternalRefs, false)], + Panel::ExternalRefs => &[(Panel::Properties, true), (Panel::Layers, true)], + Panel::QuickCalc => &[(Panel::ExternalRefs, true), (Panel::Properties, true), (Panel::Layers, true)], + }; + if let Some(found) = neighbours.iter().copied().find(|(neighbour, _)| docked(layout, *neighbour)) { + return Some(found); + } + let (side, _) = home_side(panel); + let columns = dock_groups(layout, side); + let column = columns.first()?; + if panel == Panel::ToolSets { + column.first().and_then(|group| group.first()).map(|anchor| (*anchor, false)) + } else { + column.last().and_then(|group| group.first()).map(|anchor| (*anchor, true)) } } -impl Workspace { - pub fn validate(&self) -> Result<(), String> { - self.layout.validate().map_err(|error| error.to_string())?; - if !self.layout.contains(&Panel::Canvas) || self.hidden.len() > Panel::ALL.len() + Panel::ON_DEMAND.len() { - return Err("The panel layout must contain its document view and bounded hidden panels".into()); - } - // Saved settings cannot turn the protected document view into a floating or closable tab. - if self.layout.floating.iter().any(|group| group.panels.contains(&Panel::Canvas)) { - return Err("The document view cannot float".into()); - } - let mut nodes: Vec<_> = self.layout.root.iter().collect(); - while let Some(node) = nodes.pop() { - match node { - Node::Split { first, second, .. } => { - nodes.push(first); - nodes.push(second); - } - Node::Tabs { panels, .. } if panels.contains(&Panel::Canvas) && panels.len() != 1 => { - return Err("The document view cannot share a tab group".into()); - } - Node::Stack { entries } if entries.iter().any(|entry| entry.panel == Panel::Canvas) => { - return Err("The document view cannot be collapsed".into()); - } - _ => {} - } - } - let mut seen = std::collections::HashSet::new(); - for (panel, location) in &self.hidden { - if *panel == Panel::Canvas || self.layout.contains(panel) || !seen.insert(*panel) { - return Err("A hidden panel has an invalid or duplicate identity".into()); - } - if location.anchor == Some(Panel::Canvas) && matches!(location.placement, Placement::Tab { .. } | Placement::Split(Zone::Center)) { - return Err("A hidden panel cannot restore over the document view".into()); - } - let mut probe = self.layout.clone(); - if let Err(error) = probe.restore(*panel, location) - && error != craft_ui::docking::DockError::MissingTarget - { - return Err(error.to_string()); - } - } - Ok(()) +/// The actions that open a panel with no recorded place in its dock: a new group beside its +/// docked neighbour, else a new dock on its side at its default width. +fn home(layout: &Layout, panel: Panel) -> Vec> { + if let Some((anchor, below)) = home_anchor(layout, panel) { + return vec![Action::OpenAt { panel, anchor, placement: Placement::Group { below, height: None } }]; } + let (side, width) = home_side(panel); + vec![Action::Open { panel, anchor: None }, Action::MovePanel { panel, to: Target::NewDock { side } }, Action::ResizeColumn { panel, width }] +} - pub fn apply(&mut self, action: Action) -> Result<(), String> { - self.validate()?; - let mut next = self.clone(); - if let Action::Close { panel } = &action { - let location = next.layout.location(panel).map_err(|error| error.to_string())?; - next.hidden.retain(|(id, _)| id != panel); - next.hidden.push((*panel, location)); - } else if let Action::Open { panel, .. } | Action::OpenAt { panel, .. } = &action { - next.hidden.retain(|(id, _)| id != panel); - } - next.layout.apply_with_permissions(action, permissions).map_err(|error| error.to_string())?; - next.validate()?; - *self = next; - Ok(()) +/// craft-ui's checks plus the drawing view's: a saved or hand-edited file cannot hide, float, +/// group, collapse or dock it, or restore a palette over it. +pub fn validate(workspace: &Workspace) -> Result<(), String> { + workspace.validate().map_err(|error| error.to_string())?; + if !workspace.layout.contains(&Panel::Canvas) { + return Err("The panel layout must contain its document view".into()); } - - /// Explicit opens select the requested tab and expand a collapsed stack entry. - pub fn reveal_panel(&mut self, panel: Panel) -> Result<(), String> { - let mut next = self.clone(); - next.set_visible(panel, true)?; - next.apply(Action::Activate { panel })?; - *self = next; - Ok(()) + if workspace.layout.floating.iter().any(|window| window.contains(&Panel::Canvas)) { + return Err("The document view cannot float".into()); } + let mut nodes: Vec<_> = workspace.layout.root.iter().collect(); + while let Some(node) = nodes.pop() { + match node { + Node::Split { first, second, .. } => { + nodes.push(first); + nodes.push(second); + } + Node::Tabs { panels, .. } if panels.contains(&Panel::Canvas) && panels.len() != 1 => { + return Err("The document view cannot share a tab group".into()); + } + Node::Stack { entries } if entries.iter().any(|entry| entry.panel == Panel::Canvas) => { + return Err("The document view cannot be collapsed".into()); + } + Node::Dock { columns, .. } if columns.iter().flat_map(|column| &column.groups).any(|group| group.panels.contains(&Panel::Canvas)) => { + return Err("The document view cannot be in a dock".into()); + } + _ => {} + } + } + let over_canvas = |location: &Location| { + location.anchor == Some(Panel::Canvas) && matches!(location.placement, Placement::Tab { .. } | Placement::Split(Zone::Center)) + }; + if workspace.hidden.iter().any(|(_, location)| over_canvas(location)) { + return Err("A hidden panel cannot restore over the document view".into()); + } + Ok(()) +} - pub fn set_visible(&mut self, panel: Panel, visible: bool) -> Result<(), String> { - if self.layout.contains(&panel) == visible { - return Ok(()); - } - if !visible { - return self.apply(Action::Close { panel }); - } - self.validate()?; - let mut next = self.clone(); - let location = next.hidden.iter().find(|(id, _)| *id == panel).map(|(_, location)| location.clone()); - // Recreate the last surviving member first. Restoring a member also restores any - // hidden anchor it needs, so either close order retains the original tab order. - // Remove this member before recursion to bound malformed/cyclic saved anchors. +/// Show a panel and select it at its recorded place, keeping the drawing view's rules. A panel +/// with no recorded place opens in its dock ([`home`]); one whose recorded place is gone joins +/// its docked neighbour's group. +pub fn reveal_panel(workspace: &mut Workspace, panel: Panel) -> Result<(), String> { + let mut next = workspace.clone(); + if next.layout.contains(&panel) || next.hidden.iter().any(|(id, _)| *id == panel) { + let fallback = home_anchor(&next.layout, panel).map(|(anchor, _)| anchor); + next.set_visible(panel, true, fallback, permissions).map_err(|error| error.to_string())?; + } else { + next.apply_all(home(&next.layout, panel).into_iter().chain([Action::Activate { panel }]), permissions).map_err(|error| error.to_string())?; + } + validate(&next)?; + *workspace = next; + Ok(()) +} + +/// Show or hide a panel for the Tool Sets and palette switches. Showing a panel whose recorded +/// anchor is shown restores it there without changing which tab is selected or which accordion +/// entries are open. Otherwise it goes through [`reveal_panel`], which regroups it with hidden +/// panels it was beside, and selects it. +fn set_visible(workspace: &mut Workspace, panel: Panel, visible: bool) -> Result<(), String> { + if workspace.layout.contains(&panel) == visible { + return Ok(()); + } + if !visible { + return workspace.set_visible(panel, false, None, permissions).map_err(|error| error.to_string()); + } + let mut next = workspace.clone(); + let location = next.hidden.iter().find(|(id, _)| *id == panel).map(|(_, location)| location.clone()); + // A direct restore against a hidden anchor would open a window of its own. + let direct = location.filter(|location| location.anchor.is_none_or(|anchor| next.layout.contains(&anchor))); + if direct.is_some_and(|location| next.layout.restore(panel, &location).is_ok()) { next.hidden.retain(|(id, _)| *id != panel); - if let Some(anchor) = location.as_ref().and_then(|saved| saved.anchor) - && !next.layout.contains(&anchor) - && next.hidden.iter().any(|(id, _)| *id == anchor) - { - next.set_visible(anchor, true)?; - } - let restored = location.as_ref().is_some_and(|location| next.layout.restore(panel, location).is_ok()); - if !restored { - next.layout - .apply_with_permissions( - Action::OpenAt { panel, anchor: Panel::Canvas, placement: Placement::Split(panel.default_side()) }, - permissions, - ) - .map_err(|error| error.to_string())?; - } - next.validate()?; - *self = next; - Ok(()) + validate(&next)?; + *workspace = next; + return Ok(()); } + reveal_panel(workspace, panel) +} - fn command(&mut self, params: &Value) -> Result<(), String> { - self.validate()?; - if let Some(action) = params.get("action") { - let action: Action = serde_json::from_value(action.clone()).map_err(|error| error.to_string())?; - return self.apply(action); +/// `ui.dock` params: `{action}` or `{operation, …}` in craft-ui's shared grammar. +fn apply_command(workspace: &mut Workspace, params: &Value) -> Result<(), String> { + validate(workspace)?; + let operation = if let Some(action) = params.get("action") { + UiDock::Action { action: serde_json::from_value(action.clone()).map_err(|error| error.to_string())? } + } else { + UiDock::Operation(serde_json::from_value(params.clone()).map_err(|error| error.to_string())?) + }; + let mut next = workspace.clone(); + match operation { + UiDock::Operation(DockOperation::Open { panel } | DockOperation::Activate { panel }) if !next.layout.contains(&panel) => { + reveal_panel(&mut next, panel)?; } - let operation = params.get("operation").and_then(Value::as_str).ok_or("operation is required")?; - if operation == "resizeSplit" { - let path: Vec = - serde_json::from_value(params.get("path").cloned().ok_or("path is required")?).map_err(|error| error.to_string())?; - let size: SplitSize = - serde_json::from_value(params.get("size").cloned().ok_or("size is required")?).map_err(|error| error.to_string())?; - return self.apply(Action::ResizeSplit { path, size }); + operation => { + // Every ui.dock operation carries its own rect, so the float space is never consulted. + let space = FloatSpace::new(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1280.0, 800.0))); + DockCommand::UiDock(operation).apply(&mut next, false, &space, permissions).map_err(|error| error.to_string())?; } - let panel = Panel::parse(params.get("panel").and_then(Value::as_str).ok_or("panel is required")?)?; - let action = match operation { - "open" => return self.reveal_panel(panel), - "close" => Action::Close { panel }, - "activate" => Action::Activate { panel }, - "setStackOpen" => Action::SetStackOpen { panel, open: params.get("open").and_then(Value::as_bool).ok_or("open must be a boolean")? }, - "resizeStack" => { - let value = params.get("height").ok_or("height is required")?; - let height = if value.is_null() { - None - } else { - Some( - value - .as_f64() - .filter(|height| height.is_finite() && (0.0..=1_000_000.0).contains(height)) - .ok_or("height must be finite and bounded")? as f32, - ) - }; - Action::ResizeStack { panel, height } - } - "float" | "moveFloating" => { - let rect = params.get("rect").and_then(Value::as_array).filter(|rect| rect.len() == 4).ok_or("rect must be [x,y,width,height]")?; - let mut values = [0.0f32; 4]; - for (output, value) in values.iter_mut().zip(rect) { - *output = value - .as_f64() - .filter(|number| number.is_finite() && number.abs() <= 1_000_000.0) - .ok_or("rect values must be finite and bounded")? as f32; - } - if operation == "float" { Action::Float { panel, rect: values } } else { Action::MoveFloating { panel, rect: values } } - } - "move" => { - let anchor = Panel::parse(params.get("target").and_then(Value::as_str).ok_or("target is required")?)?; - let placement = match params.get("zone").and_then(Value::as_str).unwrap_or("center") { - "center" => Placement::Split(Zone::Center), - "left" => Placement::Split(Zone::Left), - "right" => Placement::Split(Zone::Right), - "top" => Placement::Split(Zone::Top), - "bottom" => Placement::Split(Zone::Bottom), - _ => return Err("zone must be center, left, right, top or bottom".into()), - }; - let placement = if let Some(before) = params.get("before") { - if !matches!(placement, Placement::Split(Zone::Center)) { - return Err("before only applies to a center move".into()); - } - Placement::Tab { - before: if before.is_null() { - None - } else { - Some(Panel::parse(before.as_str().ok_or("before must be a panel ID or null")?)?) - }, - } - } else { - placement - }; - Action::Move { panel, anchor, placement } - } - _ => return Err("operation must be open, close, activate, float, move, moveFloating, resizeSplit, setStackOpen or resizeStack".into()), - }; - self.apply(action) } + validate(&next)?; + *workspace = next; + Ok(()) } fn sync(workspace: &mut Workspace, app: &crate::CadApp) -> Result<(), String> { - workspace.set_visible(Panel::ToolSets, app.ui.show_toolsets)?; + set_visible(workspace, Panel::ToolSets, app.ui.show_toolsets)?; if !app.ui.show_palettes { - workspace.set_visible(Panel::Layers, false)?; - workspace.set_visible(Panel::Properties, false)?; + set_visible(workspace, Panel::Layers, false)?; + set_visible(workspace, Panel::Properties, false)?; } else if !workspace.layout.contains(&Panel::Layers) && !workspace.layout.contains(&Panel::Properties) { - // Reopen the last surviving accordion entry before restoring its earlier sibling. - workspace.set_visible(Panel::Properties, true)?; - workspace.set_visible(Panel::Layers, true)?; + // Reopen the palette that closed last before the one recorded beside it. + set_visible(workspace, Panel::Properties, true)?; + set_visible(workspace, Panel::Layers, true)?; } Ok(()) } @@ -305,22 +296,27 @@ fn publish_visibility(app: &mut crate::CadApp, workspace: &Workspace) { pub fn command(app: &mut crate::CadApp, reset: bool, params: &Value) -> Result { let mut workspace = app.ui.docking.clone(); if reset { - workspace = Workspace::default(); + workspace = default_workspace(); } else { sync(&mut workspace, app)?; - workspace.command(params)?; + apply_command(&mut workspace, params)?; + // An opened panel's floating window comes to the front, in its own OS window too. + if matches!(params.get("operation").and_then(Value::as_str), Some("open" | "activate")) { + app.raise_panel = params.get("panel").and_then(|panel| serde_json::from_value(panel.clone()).ok()); + } } publish_visibility(app, &workspace); app.ui.docking = workspace; 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(); - if let Err(error) = workspace.validate().and_then(|()| sync(&mut workspace, app)) { + if let Err(error) = validate(&workspace).and_then(|()| sync(&mut workspace, app)) { app.set_status(format!("Panel layout could not be restored: {error}")); - workspace = Workspace::default(); + workspace = default_workspace(); let _ = sync(&mut workspace, app); } // Bodies can dispatch commands (including the drawing's typed command line). @@ -336,16 +332,70 @@ pub fn show(app: &mut crate::CadApp, ui: &mut egui::Ui) { style.border = egui::Stroke::new(1.0, t.border); style.accent = t.accent; style.font = crate::theme::body(); - let output = DockArea::new(egui::Id::new("cadcraft-docking")).show_with_limits( - ui, - &workspace.layout, - &style, - |panel| crate::i18n::t(panel.label()).into(), - permissions, - panel_limits, - |ui, panel| match panel { + // AutoCAD's palettes dock only in the left and right dock columns: a window edge without a + // dock makes one, and neither the drawing nor the top and bottom edges take a drop. + // Floating title bars only move windows. + style.side_edge_zones = false; + style.new_dock_zones = true; + style.drop_edges = Sides::NONE; + style.title_bar_docks = false; + let mut area = DockArea::new(egui::Id::unique("cadcraft-docking")).input_blocked(native_input_blocked(app, ui.ctx())); + if let Some(panel) = app.raise_panel.take() { + area = area.raise_panel(panel); + } + let mut output = area.show_content(ui, &workspace.layout, &style, &mut Panels { app }); + // Keys typed in a floating panel's own OS window run the main window's shortcuts and + // command line; files dropped on one open like files dropped on the main window. + app.forward_native_input(ui.ctx(), std::mem::take(&mut output.forwarded_events), output.forwarded_modifiers); + #[cfg(not(target_arch = "wasm32"))] + for files in std::mem::take(&mut output.window_files) { + for file in files.dropped { + let path = file.path().to_string_lossy().to_string(); + if !path.is_empty() { + app.open_path(&path); + } + } + } + // Existing panel close buttons and commands still own their visibility switches. + let rendered = workspace; + let mut workspace = app.ui.docking.clone(); + let _ = sync(&mut workspace, app); + let stale = workspace != rendered; + if stale { + craft_ui::docking::cancel_drag::(ui.ctx(), egui::Id::unique("cadcraft-docking")); + } + publish_visibility(app, &workspace); + app.ui.docking = workspace; + if stale { + return; + } + for action in output.actions { + if let Err(error) = + serde_json::to_value(action).map_err(|error| error.to_string()).and_then(|action| command(app, false, &json!({"action": action}))) + { + app.set_status(format!("Panel move was not applied: {error}")); + } + } +} + +/// Whether the floating panels' own OS windows ignore input: while a modal dialog is up, since +/// egui's `Modal` blocks only the main window. egui's modal layer is last frame's; the app's +/// state covers the frame a modal opens in. +pub fn native_input_blocked(app: &crate::CadApp, ctx: &egui::Context) -> bool { + app.shows_modal() || ctx.memory(|memory| memory.top_modal_layer()).is_some() +} + +/// The panels' contents, names, size limits and icons for the dock area. +pub struct Panels<'a> { + pub app: &'a mut crate::CadApp, +} + +impl DockContent for Panels<'_> { + fn body(&mut self, ui: &mut egui::Ui, panel: &Panel) { + let app = &mut *self.app; + match panel { Panel::Canvas => { - ui.painter().rect_filled(ui.max_rect(), 0.0, t.canvas); + ui.painter().rect_filled(ui.max_rect(), 0.0, crate::theme::Tokens::get().canvas); crate::canvas::show(app, ui); } Panel::ToolSets => crate::palettes::toolsets(app, ui), @@ -364,28 +414,26 @@ pub fn show(app: &mut crate::CadApp, ui: &mut egui::Ui) { Panel::ExternalRefs => { egui::ScrollArea::both().id_salt("dock-xrefs").auto_shrink([false, false]).show(ui, |ui| crate::xrefs::section(app, ui)); } - }, - ); - // Existing panel close buttons and commands still own their visibility switches. - let rendered = workspace; - let mut workspace = app.ui.docking.clone(); - let _ = sync(&mut workspace, app); - let stale = workspace.layout != rendered.layout || workspace.hidden != rendered.hidden; - if stale { - craft_ui::docking::cancel_drag::(ui.ctx(), egui::Id::new("cadcraft-docking")); - } - publish_visibility(app, &workspace); - app.ui.docking = workspace; - if stale { - return; - } - for action in output.actions { - if let Err(error) = - serde_json::to_value(action).map_err(|error| error.to_string()).and_then(|action| command(app, false, &json!({"action": action}))) - { - app.set_status(format!("Panel move was not applied: {error}")); } } + fn logical_label(&self, panel: &Panel) -> String { + crate::i18n::t(panel.label()).into() + } + fn permissions(&self, panel: &Panel) -> Permissions { + permissions(panel) + } + fn limits(&self, panel: &Panel) -> PanelLimits { + panel_limits(panel) + } + fn dock_heights(&self, panel: &Panel) -> Option { + // Layers keeps its list's height above Properties, which takes the rest of the column. + (*panel == Panel::Layers).then(|| DockHeights::new(LAYERS_HEIGHT)) + } + fn paint_icon(&mut self, painter: &egui::Painter, rect: egui::Rect, panel: &Panel, _color: egui::Color32) -> bool { + let Some(icon) = panel.icon() else { return false }; + crate::icons::paint(painter, rect, icon, false); + true + } } fn panel_limits(panel: &Panel) -> PanelLimits { diff --git a/crates/ui-egui/src/dyngrips.rs b/crates/ui-egui/src/dyngrips.rs index f38f19466..7c30447c4 100644 --- a/crates/ui-egui/src/dyngrips.rs +++ b/crates/ui-egui/src/dyngrips.rs @@ -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 = 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(¤t), c.as_str()).clicked() { diff --git a/crates/ui-egui/src/exchange.rs b/crates/ui-egui/src/exchange.rs index 7ece54259..48a615003 100644 --- a/crates/ui-egui/src/exchange.rs +++ b/crates/ui-egui/src/exchange.rs @@ -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::(egui::Id::new(FROM_PLOT))) { + if let Some(f) = ctx.data(|d| d.get_temp::(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::(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::(egui::Id::new(FROM_PLOT))).is_some(); + let from_plot = ctx.data(|d| d.get_temp::(egui::Id::unique(FROM_PLOT))).is_some(); if !*open { ctx.data_mut(|d| { d.remove::(id); - d.remove::(egui::Id::new(FROM_PLOT)); + d.remove::(egui::Id::unique(FROM_PLOT)); }); } if from_plot && !*open { diff --git a/crates/ui-egui/src/hatchdlg.rs b/crates/ui-egui/src/hatchdlg.rs index 6a0ad2206..1fff4c627 100644 --- a/crates/ui-egui/src/hatchdlg.rs +++ b/crates/ui-egui/src/hatchdlg.rs @@ -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:?}"); } diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 6400a1f38..9ada44442 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -150,6 +150,7 @@ Close All Other Drawings إغلاق كل الرسومات الأخرى Close Array Editing إغلاق تحرير المصفوفة Close Comparison إغلاق المقارنة + Export Comparison تصدير المقارنة Closed مغلق Coincident تطابق Collapse Palettes طي اللوحات @@ -203,6 +204,7 @@ Current selection التحديد الحالي Current: {color} الحالي: {color} Cut قص + Cut with Base Point قص من نقطة أساس Cyan سماوي Cylinder أسطوانة DWG DWG @@ -614,6 +616,7 @@ No supported 3D mouse found لم يُعثر على فأرة ثلاثية الأبعاد مدعومة Node عقدة None لا شيء + Normal عادي Not Found غير موجود Not available غير متاح Not available yet غير متاح بعد @@ -785,6 +788,7 @@ Rename Text Style إعادة تسمية نمط النص Rename... إعادة تسمية... Repeat {command} تكرار {command} + Replace Block... استبدال الكتلة... Requires a point (x,y, @dx,dy or @d pen.text(x, y, "❄", 9.0, Color32::from_rgb(0x9f, 0xd4, 0xff)), LayerLock => pen.rect(x - 3.0, y - 1.0, 6.0, 5.0, Color32::from_rgb(0xe6, 0xb4, 0x3c)), LayerMatch => pen.la(x - 4.0, y, x + 4.0, y), - MakeCurrent => pen.text(x, y, "✓", 9.0, Color32::from_rgb(0x6f, 0xe0, 0x6f)), + // Drawn rather than a "✓" glyph: no bundled font has U+2713, and a missing + // glyph panics under epaint's strict policy when no system font supplies one. + MakeCurrent => pen.poly(&[(x - 3.0, y), (x - 1.0, y + 2.0), (x + 3.0, y - 2.5)], false, Color32::from_rgb(0x6f, 0xe0, 0x6f)), _ => { pen.arc(x, y, 3.5, 90.0, 360.0, a); } @@ -967,8 +969,8 @@ pub fn button(ui: &mut egui::Ui, icon: Icon, size: f32, tooltip: &str, selected: pub fn describe_control(ui: &egui::Ui, response: &egui::Response, label: &str, selected: Option, tab: bool) { let label = crate::i18n::t(label); response.widget_info(|| match selected { - Some(selected) => egui::WidgetInfo::selected(egui::WidgetType::Button, response.enabled(), selected, label), - None => egui::WidgetInfo::labeled(egui::WidgetType::Button, response.enabled(), label), + Some(selected) => egui::WidgetInfo::selected(egui::Role::Button, response.enabled(), selected, label), + None => egui::WidgetInfo::labeled(egui::Role::Button, response.enabled(), label), }); if tab { ui.ctx().accesskit_node_builder(response.id, |node| { diff --git a/crates/ui-egui/src/layers.rs b/crates/ui-egui/src/layers.rs index 6cadfeac1..d9bc0d833 100644 --- a/crates/ui-egui/src/layers.rs +++ b/crates/ui-egui/src/layers.rs @@ -218,7 +218,7 @@ fn natural_lower(s: &str) -> String { /// The Layer Properties Manager window. pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { let t = Tokens::get(); - let sid = egui::Id::new("lpm_state"); + let sid = egui::Id::unique("lpm_state"); let mut st: LpmState = ctx.data_mut(|d| d.get_temp(sid)).unwrap_or_default(); let mut actions: Vec<(&str, Value)> = Vec::new(); let Ok(dst) = app.session.state() else { @@ -249,7 +249,7 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { None => "Layer Properties Manager", }; egui::Window::new(crate::i18n::t(title)) - .id(egui::Id::new("lpm_window")) + .id(egui::Id::unique("lpm_window")) .open(open) .default_size(vec2(1180.0, 440.0)) .min_width(640.0) @@ -302,7 +302,8 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { { actions.push(("layerstate.delete", json!({ "name": s }))); } - ui.add(egui::TextEdit::singleline(&mut st.state_name).hint_text(crate::tl!("New state name")).desired_width(120.0)); + ui.add(egui::TextEdit::singleline(&mut st.state_name).hint_text(crate::tl!("New state name")).desired_width(120.0)) + .on_hover_text(crate::tl!("New state name")); if ui.add_enabled(!st.state_name.trim().is_empty(), egui::Button::new(crate::tl!("Save State"))).clicked() { let n = st.state_name.trim().to_string(); actions.push(("layerstate.save", json!({ "name": n }))); @@ -310,7 +311,8 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { st.state_name.clear(); } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - ui.add(egui::TextEdit::singleline(&mut st.search).hint_text(crate::tl!("Search for layer")).desired_width(160.0)); + ui.add(egui::TextEdit::singleline(&mut st.search).hint_text(crate::tl!("Search for layer")).desired_width(160.0)) + .on_hover_text(crate::tl!("Search for layer")); icons::paint( ui.painter(), Rect::from_center_size(ui.cursor().right_center() - vec2(8.0, 0.0), vec2(14.0, 14.0)), @@ -403,6 +405,8 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { } } +/// One Layer Properties Manager row: status/name, on/freeze/lock/plot, colour, linetype, lineweight, transparency, viewport overrides, new-viewport freeze and description. +/// Edits update `st` and push commands into `actions`. #[allow(clippy::too_many_arguments)] fn row( ui: &mut egui::Ui, @@ -439,7 +443,9 @@ fn row( // Name (double-click or F2-style rename). match st.renaming.clone() { Some((n, mut buf)) if n.eq_ignore_ascii_case(&name) => { - let r = ui.add(egui::TextEdit::singleline(&mut buf).desired_width(150.0).id(egui::Id::new(("lpm_rename", &name)))); + let r = ui + .add(egui::TextEdit::singleline(&mut buf).desired_width(150.0).id(egui::Id::unique(("lpm_rename", &name)))) + .accessible_name(crate::tl!("Name")); if !r.has_focus() && !r.lost_focus() { r.request_focus(); } @@ -503,7 +509,7 @@ fn row( actions.push(("layer.set", json!({ "name": name, "plot": !l.plot }))); } // Color. - if let Some(c) = color_button(ui, egui::Id::new(("lpm_color", &name)), l.color, false, 96.0) { + if let Some(c) = color_button(ui, egui::Id::unique(("lpm_color", &name)), l.color, false, 96.0) { actions.push(("layer.set", json!({ "name": name, "color": c.name() }))); } // Linetype. @@ -524,7 +530,7 @@ fn row( }) .inner; egui::Popup::from_toggle_button_response(<_resp) - .id(egui::Id::new(("lpm_lt", &name))) + .id(egui::Id::unique(("lpm_lt", &name))) .width(240.0) .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) .show(|ui| { @@ -580,7 +586,7 @@ fn row( }) .inner; egui::Popup::from_toggle_button_response(&lw_resp) - .id(egui::Id::new(("lpm_lw", &name))) + .id(egui::Id::unique(("lpm_lw", &name))) .width(170.0) .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) .show(|ui| { @@ -611,7 +617,7 @@ fn row( }); }); // Transparency (commits when the drag or edit ends). - let tid = egui::Id::new(("lpm_tr", &name)); + let tid = egui::Id::unique(("lpm_tr", &name)); let mut tr = ui.data_mut(|d| d.get_temp::(tid)).unwrap_or(l.transparency); let r = ui.add(egui::DragValue::new(&mut tr).range(0..=90).speed(0.5)); if r.dragged() || r.has_focus() { @@ -632,7 +638,7 @@ fn row( } let vc = v.layer_colors.iter().find(|(n, _)| n.eq_ignore_ascii_case(&name)).map(|(_, c)| *c); ui.horizontal(|ui| { - if let Some(c) = color_button(ui, egui::Id::new(("lpm_vpcolor", &name)), vc.unwrap_or(l.color), false, 96.0) { + if let Some(c) = color_button(ui, egui::Id::unique(("lpm_vpcolor", &name)), vc.unwrap_or(l.color), false, 96.0) { actions.push(("vplayer", json!({ "handle": h.hex(), "colors": { name.clone(): c.name() } }))); } if vc.is_some() && ui.small_button("×").on_hover_text(crate::tl!("Remove the viewport override")).clicked() { @@ -653,7 +659,9 @@ fn row( // Description (double-click to edit). match st.describing.clone() { Some((n, mut buf)) if n.eq_ignore_ascii_case(&name) => { - let r = ui.add(egui::TextEdit::singleline(&mut buf).desired_width(200.0).id(egui::Id::new(("lpm_desc", &name)))); + let r = ui + .add(egui::TextEdit::singleline(&mut buf).desired_width(200.0).id(egui::Id::unique(("lpm_desc", &name)))) + .accessible_name(crate::tl!("Description")); if !r.has_focus() && !r.lost_focus() { r.request_focus(); } @@ -750,7 +758,7 @@ mod tests { dialog(&mut app, ui.ctx(), &mut open); }); out.textures_delta.clear(); - let id = egui::LayerId::new(egui::Order::Middle, egui::Id::new("lpm_window")); + let id = egui::LayerId::new(egui::Order::Middle, egui::Id::unique("lpm_window")); heights.push(ctx.memory(|m| m.area_rect(id.id)).map_or(0.0, |r| r.height())); } let (first, last) = (heights[5], heights[29]); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 087e5cbf8..017cf5a42 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -13,6 +13,7 @@ pub mod animation; pub mod autosave; pub mod bedit; pub mod canvas; +pub mod capture; pub mod chrome; pub mod clipboard; pub mod closing; @@ -24,6 +25,7 @@ pub mod dialogs; pub mod docking; pub mod hatchdlg; mod mtext_editor; +mod panel_state; pub mod spacemouse; // The External References palette. pub mod xrefs; @@ -37,6 +39,7 @@ pub mod icons; pub mod layers; pub mod managers; pub mod menus; +pub mod modal; pub mod opening; pub mod options; pub mod palettes; @@ -45,6 +48,8 @@ pub mod plotstyles; pub mod quick; pub mod shortcut_menus; pub mod theme; +#[cfg(not(target_arch = "wasm32"))] +pub mod updates_ui; pub mod viewcube; pub mod vpscale; @@ -156,7 +161,7 @@ impl SaveFormat { impl Default for UiState { fn default() -> Self { UiState { - docking: docking::Workspace::default(), + docking: docking::default_workspace(), interface_language: i18n::Preference::default(), show_toolsets: true, @@ -220,15 +225,37 @@ pub struct CadApp { pub opening: opening::Opening, pub integrated_titlebar: bool, control_rx: Option>, - pending_shots: Vec<(u64, Option, std::sync::mpsc::Sender, f64)>, - queued_shots: Vec<(u64, f64, u32)>, - shot_token: u64, + /// Fresh surface and identity snapshot (see [`capture`]). + pub(crate) capture_geometry: Option, + /// Monotonic surface epoch, bumped whenever the window/viewport geometry changes. + pub(crate) capture_epoch: u64, + /// Monotonic identity epoch, bumped whenever the drawing or focused widget changes (even + /// when a later change returns to the same value). + pub(crate) capture_identity_epoch: u64, + /// Admitted `ui.screenshot` requests, including completed-but-undrained ones. + pub(crate) shots: capture::Shots, pub synthetic: Vec, + /// Keys, text and clipboard events a floating panel's own OS window left over, with the + /// modifiers held there, for the main window's next input ([`Self::forward_native_input`]). + forwarded: Vec, + forwarded_modifiers: Option, + /// The main window's own modifiers, put back the frame after forwarded keys went in. + restore_modifiers: Option, + /// A panel opened from a menu or command, whose floating window comes to the front on the + /// next frame (craft-ui's `DockArea::raise_panel`). + pub raise_panel: Option, + /// One synthetic batch has been put into `RawInput` and is waiting for a real `App::ui` call. + synthetic_injected: bool, + /// Counts settled UI passes (one per painted frame; never hidden logic-only ticks or + /// discarded passes). + pub ui_frames: u64, /// Caps the frame rate when presenting without vsync (see [`gpu::FrameCap`]). #[cfg(not(target_arch = "wasm32"))] pub frame_cap: Option, /// Language whose interface fonts are installed (`None` until the first frame). fonts_language: Option<&'static str>, + /// Language last given to egui (and so to the AccessKit root; `None` until the first frame). + shown_language: Option<&'static str>, /// The theme last installed into egui (`None` until the first frame). shown_theme: Option, /// Whether the last applied theme was handed to egui as the System preference. @@ -258,9 +285,13 @@ pub struct CadApp { pub hatch_dlg: Option, /// The Edit Block Definition dialog ([`bedit`]). pub bedit: bedit::State, + /// The startup update check and its prompt (desktop; [`updates_ui`]). + #[cfg(not(target_arch = "wasm32"))] + pub updates_rt: updates_ui::Runtime, } impl CadApp { + /// Construct `CadApp` from a session and services with default UI state, options, canvas and command line; no control channel, capture geometry or pending shots are installed. pub fn new(session: Session, services: Services) -> Self { CadApp { system_languages: Vec::new(), @@ -276,13 +307,21 @@ impl CadApp { opening: opening::Opening::default(), integrated_titlebar: false, control_rx: None, - pending_shots: Vec::new(), - queued_shots: Vec::new(), - shot_token: 0, + capture_geometry: None, + capture_epoch: 0, + capture_identity_epoch: 0, + shots: capture::Shots::default(), synthetic: Vec::new(), + forwarded: Vec::new(), + forwarded_modifiers: None, + restore_modifiers: None, + raise_panel: None, + synthetic_injected: false, + ui_frames: 0, #[cfg(not(target_arch = "wasm32"))] frame_cap: None, fonts_language: None, + shown_language: None, shown_theme: None, shown_follows_system: false, shown_animations: None, @@ -298,6 +337,8 @@ impl CadApp { alias_dlg: None, hatch_dlg: None, bedit: bedit::State::default(), + #[cfg(not(target_arch = "wasm32"))] + updates_rt: updates_ui::Runtime::default(), } } @@ -432,6 +473,11 @@ impl CadApp { self.status = Some((s.into(), now_ms())); } + /// Cancel queued/in-flight screenshots (host exit). Late callbacks write nothing. + pub fn cancel_screenshots(&mut self) { + capture::cancel_all(self); + } + /// Open the drawing at `path`. Natively the file is read on a worker thread while an /// "Opening ..." card shows ([`opening`]); [`Self::finish_opening`] adds it when ready. On the /// web it opens at once. @@ -548,22 +594,169 @@ impl CadApp { recent.truncate(RECENT_FILES); } - /// Per-frame logic before layout. + /// Consume asynchronous work on every logic callback, including hidden `run_logic` ticks. + /// + /// Consume-based queues and screenshot settlement run on **every** call, whichever pass + /// delivers it. Frame work (LISP progress, autosave, close requests, screenshot issuance + /// pacing, hidden repaints) runs once per host frame: eframe calls `App::logic` inside every + /// pass of a multi-pass frame, and also on hidden logic-only ticks where no pass runs and the + /// pass index is zero. `current_pass_index() == 0` is therefore the frame guard: true on the + /// first pass and every hidden tick, false on later passes. pub fn logic(&mut self, ctx: &egui::Context) { - // A long LISP routine runs a slice per frame, so the window stays live (and Esc stops it). - self.session.lisp_tick(); - if self.session.lisp_busy() { + // Consume-based queues: each item is handled once, whichever callback delivers it. A + // reply arriving during a later pass is never starved until the next frame. + self.drain_control(ctx); + self.finish_opening(); + // Finishing an open switches to the new drawing. + capture::note_identity(self, ctx); + capture::drain(self, ctx); + self.gpu_fault_tick(ctx); + managers::plot_window_resume(self, ctx); + hatchdlg::resume(self); + // What follows is frame work, once per layout frame or hidden tick. + if ctx.current_pass_index() != 0 { + return; + } + if self.restore_modifiers.is_some() { ctx.request_repaint(); } - // Closing the window (title bar, File ▸ Exit, Cmd+Q) with unsaved changes asks first. + // Closing the window with unsaved changes asks first. `run_logic` refreshes viewport + // close/focus state, so this must also work while hidden. if ctx.input(|i| i.viewport().close_requested()) && self.request_quit() { ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); } + // A long AutoLISP routine runs one bounded slice per frame, including hidden web/native + // ticks; it must not depend on a paint or `RequestPaintWhileHidden`. + self.session.lisp_tick(); + // Time-based and idempotent by its own deadline; keeps autosave alive while hidden. + self.autosave_tick(ctx); + #[cfg(not(target_arch = "wasm32"))] + updates_ui::tick(self, ctx); + if !self.synthetic.is_empty() || self.synthetic_injected { + request_paint_while_hidden(ctx); + } + if self.session.lisp_busy() || self.opening.busy() { + request_paint_while_hidden(ctx); + } + capture::request_hidden_paint(self, ctx); + } + + /// Queue keys typed in a floating panel's own OS window (craft-ui's + /// `DockOutput::forwarded_events` and `forwarded_modifiers`) for the main window's next + /// input. egui gives each OS window only its own input, and the shortcuts, the command line + /// and the canvas all read the main window's, so the keys go in there as if typed in it. + pub fn forward_native_input(&mut self, ctx: &egui::Context, events: Vec, modifiers: Option) { + if events.is_empty() { + return; + } + self.forwarded.extend(events); + if modifiers.is_some() { + self.forwarded_modifiers = modifiers; + } + if self.restore_modifiers.is_none() { + self.restore_modifiers = Some(ctx.input(|i| i.modifiers)); + } + ctx.request_repaint_of(egui::ViewportId::ROOT); + } + + /// Whether a modal dialog is up: the "Save changes?" prompt or Drawing Recovery. The + /// floating panels' own OS windows take no input meanwhile (egui's `Modal` blocks only the + /// main window). The app's other dialogs are ordinary windows that leave the drawing usable. + pub fn shows_modal(&self) -> bool { + self.close_prompt().is_some() || !self.recovery.items.is_empty() + } + + /// Input hook: refresh the raw surface snapshot, put in keys forwarded from floating panels' + /// OS windows, and release at most one synthetic batch. + /// + /// Eframe keeps the unused `RawInput` across hidden logic-only ticks, so a batch already in + /// the buffer is never injected again; the next batch waits for a real `ui` pass to consume + /// the previous one. + pub fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { + capture::note_raw_geometry(self, ctx, raw); + // The forwarded window's modifiers hold for the frame its keys go in (Shift+Copy is + // COPYBASE); the next frame puts the main window's back, before its own changes. + if !self.forwarded.is_empty() { + // A modifier the main window changed meanwhile is the one to put back. + if let Some(own) = raw.events.iter().rev().find_map(|event| match event { + egui::Event::ModifiersChanged(modifiers) => Some(*modifiers), + _ => None, + }) { + self.restore_modifiers = Some(own); + } + if let Some(modifiers) = self.forwarded_modifiers.take() { + raw.events.push(egui::Event::ModifiersChanged(modifiers)); + } + raw.events.append(&mut self.forwarded); + } else if let Some(modifiers) = self.restore_modifiers.take() { + raw.events.insert(0, egui::Event::ModifiersChanged(modifiers)); + } + if self.synthetic_injected || self.synthetic.is_empty() { + return; + } + let n = match self.synthetic.first() { + Some(egui::Event::PointerMoved(_) | egui::Event::PointerButton { .. }) => 1, + _ => self.synthetic.iter().position(|e| matches!(e, egui::Event::Key { pressed: false, .. })).map_or(self.synthetic.len(), |i| i + 1), + }; + if let Some(egui::Event::PointerMoved(p) | egui::Event::PointerButton { pos: p, .. }) = self.synthetic.first() { + raw.events.push(egui::Event::PointerMoved(*p)); + } + // A synthetic click holds its Shift/Ctrl (Shift+click a grip); the next move lets go. + match self.synthetic.first() { + Some(egui::Event::PointerButton { modifiers, .. }) => raw.events.push(egui::Event::ModifiersChanged(*modifiers)), + Some(egui::Event::PointerMoved(_)) => raw.events.push(egui::Event::ModifiersChanged(egui::Modifiers::default())), + _ => {} + } + let n = n.min(self.synthetic.len()); + raw.events.extend(self.synthetic.drain(..n)); + self.synthetic_injected = true; + } + + /// Lay out the whole window. This is the only place with a real UI pass. + pub fn ui(&mut self, ui: &mut egui::Ui) { + let ctx = ui.ctx().clone(); + let first_pass = ctx.current_pass_index() == 0; + if first_pass { + self.ui_frame_begin(ui); + } else { + // A second pass must render the state the first pass produced. egui's raw input is + // empty there, but the smoothed scroll delta can persist; never pan/zoom twice. + ctx.input_mut(|input| { + input.smooth_scroll_delta = egui::Vec2::ZERO; + input.raw.events.retain(|event| !matches!(event, egui::Event::MouseWheel { .. } | egui::Event::Zoom(_))); + }); + } + // The settled geometry and ppp of this pass, before drawing and after the frame's saved + // interface zoom was applied (egui applies a new zoom at the start of a pass). + capture::note_pass_geometry(self, &ctx); + i18n::with_language(self.language(), || self.draw(ui)); + if first_pass { + // The batch reached an actual Ui callback; a discarded pass still consumed it. + self.synthetic_injected = false; + } + // `will_discard()` is false on the settled (final) pass of a frame, whether the frame is + // single-pass or the second pass after a layout discard; a discarded pass is skipped. + if !ctx.will_discard() { + self.ui_frames = self.ui_frames.saturating_add(1); + capture::issue(self, &ctx); + } + } + + /// Frame-only work, run from the first real UI pass (never from a hidden logic tick). + fn ui_frame_begin(&mut self, ui: &mut egui::Ui) { + let ctx = ui.ctx().clone(); + let ctx = &ctx; let language = self.language(); if self.fonts_language != Some(language) { theme::install_fonts(ctx, language); self.fonts_language = Some(language); } + // The AccessKit root carries the language the interface is actually shown in; egui copies + // it onto the root node at the end of the pass. + if self.shown_language != Some(language) { + ctx.set_language(Some(language.to_string())); + self.shown_language = Some(language); + } // Resolved every frame, so a System choice follows the OS appearance live (winit and the // browser report changes as input); the saved choice itself never changes here. let shown = self.ui.theme.resolve(ctx.system_theme()); @@ -591,59 +784,34 @@ impl CadApp { ctx.set_zoom_factor(scale); self.shown_interface_scale = Some(scale); } - self.drain_control(ctx); - self.finish_opening(); - self.opening.show(ctx); - if !self.synthetic.is_empty() { - ctx.request_repaint(); - } - self.collect_screenshots(ctx); - self.issue_screenshots(ctx); - self.gpu_fault_tick(ctx); - managers::plot_window_resume(self, ctx); - hatchdlg::resume(self); - self.autosave_tick(ctx); - menus::shortcuts(self, ctx); - #[cfg(not(target_arch = "wasm32"))] - for f in ctx.input(|i| i.raw.dropped_files.clone()) { - let p = f.path().to_string_lossy().to_string(); - if !p.is_empty() { - self.open_path(&p); - } - } - } - - /// Wait for the frame cap (if any), then inject synthetic events (one pointer event per frame). - pub fn raw_input_hook(&mut self, raw: &mut egui::RawInput) { + // Wait for the frame cap on a painted pass, where a wait cannot delay a hidden tick. #[cfg(not(target_arch = "wasm32"))] if let Some(cap) = &mut self.frame_cap { cap.wait(); } - if self.synthetic.is_empty() { - return; - } - let n = match self.synthetic.first() { - Some(egui::Event::PointerMoved(_) | egui::Event::PointerButton { .. }) => 1, - _ => self.synthetic.iter().position(|e| matches!(e, egui::Event::Key { pressed: false, .. })).map_or(self.synthetic.len(), |i| i + 1), - }; - if let Some(egui::Event::PointerMoved(p) | egui::Event::PointerButton { pos: p, .. }) = self.synthetic.first() { - raw.events.push(egui::Event::PointerMoved(*p)); - } - // A synthetic click holds its Shift/Ctrl (Shift+click a grip); the next move lets go. - match self.synthetic.first() { - Some(egui::Event::PointerButton { modifiers, .. }) => raw.events.push(egui::Event::ModifiersChanged(*modifiers)), - Some(egui::Event::PointerMoved(_)) => raw.events.push(egui::Event::ModifiersChanged(egui::Modifiers::default())), - _ => {} - } - let n = n.min(self.synthetic.len()); - raw.events.extend(self.synthetic.drain(..n)); + panel_state::migrate(ui); + self.ui_input(ctx); } - /// Lay out the whole window. - pub fn ui(&mut self, ui: &mut egui::Ui) { - i18n::with_language(self.language(), || self.draw(ui)); + /// Input-derived work, once per layout frame. + fn ui_input(&mut self, ctx: &egui::Context) { + menus::shortcuts(self, ctx); + #[cfg(not(target_arch = "wasm32"))] + { + let dropped = ctx.input_mut(|input| std::mem::take(&mut input.raw.dropped_files)); + for file in dropped { + let path = file.path().to_string_lossy().to_string(); + if !path.is_empty() { + self.open_path(&path); + } + } + } + if !self.synthetic.is_empty() { + ctx.request_repaint(); + } } + /// Draw one UI pass: title/toolbar, optional menu/status bar/file tabs, the standalone tool-set strip when a drawing is shown but Tool Sets is not docked, docking or the start page, and the floating overlays; record `frame_ms`. Runs on every pass, including one egui later discards. fn draw(&mut self, ui: &mut egui::Ui) { let t0 = now_ms(); control::begin_frame(ui.ctx()); @@ -669,6 +837,9 @@ impl CadApp { chrome::start_page(self, ui); } }); + self.opening.show(ui.ctx()); + #[cfg(not(target_arch = "wasm32"))] + updates_ui::show(self, ui.ctx()); dialogs::show(self, ui.ctx()); palettes::patterns::prompt_picker(self, ui.ctx()); closing::prompt(self, ui.ctx()); @@ -677,6 +848,7 @@ impl CadApp { self.frame_ms = now_ms() - t0; } + /// Drain each queued control request at most once (timed-out requests are skipped), dispatch it to `control::handle` or `capture::admit`, note the resulting capture identity and request a repaint. fn drain_control(&mut self, ctx: &egui::Context) { let Some(rx) = self.control_rx.take() else { return }; while let Ok(req) = rx.try_recv() { @@ -689,85 +861,31 @@ impl CadApp { control::Outcome::Done(v) => { let _ = reply.send(v); } - control::Outcome::Screenshot { path } => { - self.shot_token += 1; - let token = self.shot_token; - self.queued_shots.push((token, now_ms() + 120.0, 0)); - self.pending_shots.push((token, path, reply, now_ms() + 8000.0)); - } + control::Outcome::Screenshot { path } => capture::admit(self, ctx, path, reply), } + // A command can switch drawings or move the focus; a screenshot queued before it must + // see that transition even if a later command returns to the same drawing. + capture::note_identity(self, ctx); ctx.request_repaint(); } self.control_rx = Some(rx); } - - fn issue_screenshots(&mut self, ctx: &egui::Context) { - let now = now_ms(); - // Wait for a background rebuild for the current view, so the shot shows the drawing at - // that view, but not past the reply's deadline. - let building = self.canvas.building(); - self.queued_shots.retain_mut(|(token, at, frames)| { - *frames += 1; - if now >= *at && *frames >= 3 && (!building || now >= *at + SHOT_REBUILD_WAIT_MS) { - ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::new(*token))); - false - } else { - true - } - }); - if !self.queued_shots.is_empty() || !self.pending_shots.is_empty() { - ctx.request_repaint_after(std::time::Duration::from_millis(16)); - } - } - - fn collect_screenshots(&mut self, ctx: &egui::Context) { - if self.pending_shots.is_empty() { - return; - } - let events: Vec<_> = ctx.input(|i| { - i.raw - .events - .iter() - .filter_map(|e| match e { - egui::Event::Screenshot { user_data, image, .. } => { - let token = user_data.data.as_ref().and_then(|d| d.downcast_ref::()).copied()?; - Some((token, image.clone())) - } - _ => None, - }) - .collect() - }); - for (token, image) in events { - if let Some(i) = self.pending_shots.iter().position(|(t, ..)| *t == token) { - let (_, path, reply, _) = self.pending_shots.remove(i); - let _ = reply.send(control::save_screenshot(&image, path.as_deref())); - } - } - let now = now_ms(); - self.pending_shots.retain(|(_, _, reply, deadline)| { - if now < *deadline { - return true; - } - let _ = reply.send(json!({"ok": false, "error": "no frame was presented (screen locked or window hidden); use ui.render"})); - false - }); - } } -/// How long a control-channel screenshot waits for a background rebuild before it is taken -/// anyway (the reply itself times out after 8 s). -const SHOT_REBUILD_WAIT_MS: f64 = 6000.0; - -pub fn now_ms() -> f64 { +/// Ask for a painted frame while no frame is being painted. Native eframe honours the viewport +/// command; the pinned web runner logs it as unhandled, so web only schedules a repaint (hidden +/// work still advances from `logic`). +fn request_paint_while_hidden(ctx: &egui::Context) { #[cfg(not(target_arch = "wasm32"))] - { - use std::time::{SystemTime, UNIX_EPOCH}; - SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs_f64() * 1000.0).unwrap_or(0.0) - } - #[cfg(target_arch = "wasm32")] - { - 0.0 - } + ctx.send_viewport_cmd(egui::ViewportCommand::RequestPaintWhileHidden); + ctx.request_repaint(); +} + +/// Monotonic milliseconds since the first call, on native and web (`performance.now` on wasm). +/// Durations (screenshot deadlines, status expiry, frame timings) must not follow the wall clock. +pub fn now_ms() -> f64 { + static START: std::sync::OnceLock = std::sync::OnceLock::new(); + START.get_or_init(web_time::Instant::now).elapsed().as_secs_f64() * 1000.0 } /// A LISP source file, by its name. @@ -784,13 +902,141 @@ mod tests { CadApp::new(Session::empty(), Services::default()) } - /// One frame of `logic` with the OS reporting `system` as its appearance. + /// One real UI frame, with the OS reporting `system` as its appearance. fn frame(app: &mut CadApp, ctx: &egui::Context, system: Option) { let raw = egui::RawInput { system_theme: system, ..Default::default() }; - let mut out = ctx.run_ui(raw, |ui| app.logic(ui.ctx())); + let mut out = ctx.run_ui(raw, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); out.textures_delta.clear(); } + /// One real UI frame whose first pass asks egui for a second layout pass, as a popup or a + /// changed layout does. + fn two_pass_frame(app: &mut CadApp, ctx: &egui::Context) { + let mut discard = true; + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + app.logic(ui.ctx()); + if std::mem::take(&mut discard) { + ui.ctx().request_discard("test: force a second pass"); + } + app.ui(ui); + }); + out.textures_delta.clear(); + } + + /// Eframe runs `App::logic` inside every pass of a multi-pass frame. Frame work (here one + /// AutoLISP slice) belongs to the frame, not to a pass. + #[test] + fn a_two_pass_frame_runs_frame_logic_once() { + use cadcraft_engine::lisp::value::Value; + + let routine = "(setq cc-two 0) (while (< cc-two 400000) (setq cc-two (1+ cc-two)))"; + let start = |app: &mut CadApp| { + let started = app.session.lisp_eval(routine, false, false, Vec::new()).expect("the routine starts"); + assert!(started.is_none(), "a non-blocking routine yields before finishing"); + assert!(app.session.lisp_busy()); + }; + let mut single = CadApp::new(Session::new(), Services::default()); + let mut twice = CadApp::new(Session::new(), Services::default()); + start(&mut single); + start(&mut twice); + let ctx = egui::Context::default(); + frame(&mut single, &ctx, None); + two_pass_frame(&mut twice, &ctx); + let counter = |app: &CadApp| match app.session.lisp_var("CC-TWO") { + Value::Int(n) => n, + other => panic!("CC-TWO is {other:?}, not an integer"), + }; + assert!(counter(&single) > 0, "the frame ran the first slice"); + assert_eq!(counter(&twice), counter(&single), "the second pass did not run a second slice"); + assert_eq!(twice.session.lisp_busy(), single.session.lisp_busy(), "the same amount of the routine ran"); + } + + /// A control request is consumed once per frame, not once per pass. + #[test] + fn a_two_pass_frame_executes_a_control_request_once() { + use std::time::Duration; + + use crate::control::ControlRequest; + + let (tx, rx) = std::sync::mpsc::channel(); + let mut app = CadApp::new(Session::new(), Services::default()).with_control(rx); + let (request, reply) = ControlRequest::new("engine.execute", json!({ "command": "line", "params": { "points": [[0.0, 0.0], [10.0, 0.0]] } })); + tx.send(request).expect("the request is queued"); + let ctx = egui::Context::default(); + two_pass_frame(&mut app, &ctx); + let entities = app.session.doc().map(cadcraft_doc::Drawing::entity_count).unwrap_or_default(); + assert_eq!(entities, 1, "one command ran once, in the first pass"); + let reply = reply.wait(Duration::from_secs(1)); + assert_eq!(reply["ok"], true, "{reply}"); + } + + /// A hidden logic-only tick runs no frame-only work: `ui_frames` stays 0 and fonts are not installed; a real `Ui` pass advances both. + #[test] + fn hidden_logic_does_not_run_frame_only_work() { + let mut a = app(); + let ctx = egui::Context::default(); + // A logic-only call (what eframe runs for a hidden window) reports pass index zero too. + a.logic(&ctx); + assert_eq!(a.ui_frames, 0, "no real Ui callback ran"); + assert_eq!(a.fonts_language, None, "fonts are a frame-only concern"); + frame(&mut a, &ctx, None); + assert_eq!(a.ui_frames, 1); + assert!(a.fonts_language.is_some()); + } + + /// Repeated hidden logic-only ticks finish a long Lisp routine (`CC-HIDDEN` reaches 400000) while `ui_frames` stays 0. + #[test] + fn hidden_logic_advances_a_long_lisp_routine_without_a_ui_pass() { + use cadcraft_engine::lisp::value::Value; + + let mut a = CadApp::new(Session::new(), Services::default()); + let ctx = egui::Context::default(); + let started = a + .session + .lisp_eval("(setq cc-hidden 0) (while (< cc-hidden 400000) (setq cc-hidden (1+ cc-hidden)))", false, false, Vec::new()) + .expect("the routine starts"); + assert!(started.is_none(), "a non-blocking routine yields before finishing"); + assert!(a.session.lisp_busy(), "the first slice reached the yield point"); + for _ in 0..2_000 { + if !a.session.lisp_busy() { + break; + } + // What eframe runs for a hidden window: logic only, no `Ui` callback. + a.logic(&ctx); + } + assert!(!a.session.lisp_busy(), "hidden logic ticks carried the routine to the end"); + assert!(matches!(a.session.lisp_var("CC-HIDDEN"), Value::Int(400_000))); + assert_eq!(a.ui_frames, 0, "no frame was painted"); + } + + /// An injected synthetic event batch is delivered by the first raw hook, a second raw hook before a real `Ui` pass adds nothing, and a new batch is accepted after that pass. + #[test] + fn one_synthetic_batch_waits_for_a_real_ui_pass() { + let mut a = app(); + let ctx = egui::Context::default(); + a.synthetic.push(egui::Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }); + let mut first = egui::RawInput::default(); + a.raw_input_hook(&ctx, &mut first); + assert!(first.events.iter().any(|event| matches!(event, egui::Event::Key { key: egui::Key::A, .. }))); + // A hidden tick (or a second raw hook before the UI) must not append another batch. + let mut second = egui::RawInput::default(); + a.raw_input_hook(&ctx, &mut second); + assert!(second.events.is_empty(), "the injected batch is still pending"); + let mut out = ctx.run_ui(first, |ui| { + a.logic(ui.ctx()); + a.ui(ui); + }); + out.textures_delta.clear(); + // The batch was consumed by a real Ui callback; a new synthetic event can now be injected. + a.synthetic.push(egui::Event::Key { key: egui::Key::B, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }); + let mut third = egui::RawInput::default(); + a.raw_input_hook(&ctx, &mut third); + assert!(third.events.iter().any(|event| matches!(event, egui::Event::Key { key: egui::Key::B, .. }))); + } + #[test] fn theme_choice_persists_and_drives_tokens() { let mut a = app(); diff --git a/crates/ui-egui/src/managers.rs b/crates/ui-egui/src/managers.rs index 4e008ddda..712c02a61 100644 --- a/crates/ui-egui/src/managers.rs +++ b/crates/ui-egui/src/managers.rs @@ -74,16 +74,18 @@ fn fmt_num(v: f64) -> String { } /// A number field that commits on Enter or focus loss. -fn num_field(ui: &mut egui::Ui, id: impl egui::AsId, v: f64) -> Option { - expr_field(ui, egui::Id::new(id), &fmt_num(v)).and_then(|t| t.parse::().ok()).filter(|v| v.is_finite()) +/// +/// `label` is the visible label's widget id (see [`expr_field`]). +fn num_field(ui: &mut egui::Ui, id: impl egui::AsIdSalt, v: f64, label: Option) -> Option { + expr_field(ui, ui.make_persistent_id(id), &fmt_num(v), label).and_then(|t| t.parse::().ok()).filter(|v| v.is_finite()) } fn temp(ctx: &egui::Context, id: &str) -> Option { - ctx.data_mut(|d| d.get_temp::(egui::Id::new(id))) + ctx.data_mut(|d| d.get_temp::(egui::Id::unique(id))) } fn set_temp(ctx: &egui::Context, id: &str, v: T) { - ctx.data_mut(|d| d.insert_temp(egui::Id::new(id), v)); + ctx.data_mut(|d| d.insert_temp(egui::Id::unique(id), v)); } fn no_drawing(ctx: &egui::Context, title: &str, open: &mut bool) { @@ -94,6 +96,7 @@ fn no_drawing(ctx: &egui::Context, title: &str, open: &mut bool) { // ---------- Linetype Manager ---------- +/// Linetype Manager: current linetype and global scale, loaded linetypes, and load/current actions; returns the command to run after drawing. fn linetype(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action { let t = Tokens::get(); let Ok(d) = app.session.doc() else { @@ -172,8 +175,8 @@ fn linetype(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action { }); ui.separator(); ui.horizontal(|ui| { - ui.label("Global scale factor"); - if let Some(v) = num_field(ui, "ltmgr_scale", ltscale).filter(|v| *v > 0.0) { + let label = ui.label("Global scale factor"); + if let Some(v) = num_field(ui, "ltmgr_scale", ltscale, Some(label.id)).filter(|v| *v > 0.0) { action = Some(("ltscale", json!({ "scale": v }))); } }); @@ -240,6 +243,7 @@ fn draw_point(p: &egui::Painter, c: egui::Pos2, r: f32, mode: i64, stroke: Strok } } +/// Point Style dialog for the PDMODE/PDSIZE presets; returns the `ddptype` action. fn point_style(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action { let t = Tokens::get(); let Ok(d) = app.session.doc() else { @@ -275,8 +279,8 @@ fn point_style(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action }); ui.add_space(6.0); ui.horizontal(|ui| { - ui.label("Point Size"); - if let Some(v) = num_field(ui, "ptype_size", size).filter(|v| *v > 0.0) { + let label = ui.label("Point Size"); + if let Some(v) = num_field(ui, "ptype_size", size, Some(label.id)).filter(|v| *v > 0.0) { action = set(mode, v, rel); } ui.label(if rel { "%" } else { "units" }); @@ -415,7 +419,7 @@ fn style_manager(ctx: &egui::Context, open: &mut bool, title: &str, cmd: &'stati action = Some((cmd, json!({ "name": sel }))); } left.horizontal(|ui| { - ui.add(egui::TextEdit::singleline(&mut new_name).hint_text("New style name").desired_width(120.0)); + ui.add(egui::TextEdit::singleline(&mut new_name).hint_text("New style name").desired_width(120.0)).on_hover_text("New style name"); let name = new_name.trim().to_string(); let ok = !name.is_empty() && !styles.iter().any(|s| s.name.eq_ignore_ascii_case(&name)); if ui.add_enabled(ok, egui::Button::new("New")).clicked() { @@ -430,8 +434,8 @@ fn style_manager(ctx: &egui::Context, open: &mut bool, title: &str, cmd: &'stati for f in &style.fields { let change = match f { Field::Num(key, label, v) => { - ui.label(*label); - num_field(ui, (cmd, &style.name, *key), *v).filter(|v| *v >= 0.0).map(|v| (*key, json!(v))) + let field_label = ui.label(*label); + num_field(ui, (cmd, &style.name, *key), *v, Some(field_label.id)).filter(|v| *v >= 0.0).map(|v| (*key, json!(v))) } Field::Bool(key, label, v) => { ui.label(""); @@ -466,6 +470,7 @@ fn style_manager(ctx: &egui::Context, open: &mut bool, title: &str, cmd: &'stati // ---------- Page Setup Manager ---------- +/// Page Setup Manager over layouts sorted by tab order; returns the selected setup command. fn page_setup(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action { let t = Tokens::get(); let Ok(d) = app.session.doc() else { @@ -502,7 +507,7 @@ fn page_setup(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action // Layout plotting always prints the whole sheet, so only that area is offered. let rows = PageRows { areas: &["layout"], scale: true, center: true }; egui::Grid::new("pagesetup_grid").num_columns(2).spacing(vec2(10.0, 6.0)).show(right, |ui| { - if let Some((k, v)) = page_rows(ui, egui::Id::new(("pagesetup", name)), page, &rows, &choices, None) { + if let Some((k, v)) = page_rows(ui, egui::Id::unique(("pagesetup", name)), page, &rows, &choices, None) { action = Some(("pagesetup", json!({ "layout": name, k: v }))); } }); @@ -666,9 +671,9 @@ fn page_rows( }); ui.end_row(); for (i, label) in [(0, "X offset (mm)"), (1, "Y offset (mm)")] { - ui.label(label); + let field_label = ui.label(label); ui.add_enabled_ui(supported && !*center, |ui| { - if let Some(v) = num_field(ui, ("plot_offset", i), offset_mm[i]) { + if let Some(v) = num_field(ui, ("plot_offset", i), offset_mm[i], Some(field_label.id)) { offset_mm[i] = v; } }) @@ -683,9 +688,9 @@ fn page_rows( } } if rows.scale { - ui.label("Scale"); + let label = ui.label("Scale"); ui.add_enabled_ui(!page.scale_to_fit, |ui| { - if let Some(v) = expr_field(ui, salt.with("scale"), &fmt_num(page.scale)).and_then(|t| parse_ratio(&t)) { + if let Some(v) = expr_field(ui, salt.with("scale"), &fmt_num(page.scale), Some(label.id)).and_then(|t| parse_ratio(&t)) { change = Some(("scale", json!(v))); } }) @@ -955,7 +960,7 @@ fn plot(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action { PageRows { areas: &["layout"], scale: false, center: false } }; let placement = model.then_some((&mut st.center, &mut st.offset_mm, offset_supported)); - if let Some((k, v)) = page_rows(ui, egui::Id::new(("plot", st.target.as_str())), &st.page, &rows, &choices, placement) { + if let Some((k, v)) = page_rows(ui, egui::Id::unique(("plot", st.target.as_str())), &st.page, &rows, &choices, placement) { apply_page_change(&mut st.page, k, &v); } if model && st.page.plot_area == "window" { @@ -963,10 +968,10 @@ fn plot(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action { ui.label(label); ui.horizontal(|ui| { for (j, axis) in ["X", "Y"].iter().enumerate() { - ui.label(*axis); + let axis_label = ui.label(*axis); let k = i + j; if let Some(slot) = st.window.get_mut(k) - && let Some(v) = num_field(ui, ("plot_window", k), *slot) + && let Some(v) = num_field(ui, ("plot_window", k), *slot, Some(axis_label.id)) { *slot = v; } @@ -1075,6 +1080,7 @@ fn sheet_sketch(ui: &mut egui::Ui, page: &cadcraft_doc::PageSetup, t: &Tokens) { // ---------- Constraint Settings ---------- +/// Constraint Settings dialog editing the drawing's parametric settings; returns the `constraintsettings` action. fn constraint_settings(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action { let t = Tokens::get(); let Ok(d) = app.session.doc() else { @@ -1096,8 +1102,8 @@ fn constraint_settings(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) - action = set("barsVisible", json!(bars)); } ui.horizontal(|ui| { - ui.label("Constraint bar transparency (%)"); - if let Some(v) = num_field(ui, "csettings_transp", f64::from(s.bar_transparency)) { + let label = ui.label("Constraint bar transparency (%)"); + if let Some(v) = num_field(ui, "csettings_transp", f64::from(s.bar_transparency), Some(label.id)) { action = set("barTransparency", json!(v)); } }); @@ -1124,13 +1130,13 @@ fn constraint_settings(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) - } }); egui::Grid::new("csettings_tol").num_columns(2).show(ui, |ui| { - ui.label("Distance tolerance"); - if let Some(v) = num_field(ui, "csettings_dist", s.distance_tolerance) { + let label = ui.label("Distance tolerance"); + if let Some(v) = num_field(ui, "csettings_dist", s.distance_tolerance, Some(label.id)) { action = set("distanceTolerance", json!(v)); } ui.end_row(); - ui.label("Angle tolerance (degrees)"); - if let Some(v) = num_field(ui, "csettings_angle", s.angle_tolerance) { + let label = ui.label("Angle tolerance (degrees)"); + if let Some(v) = num_field(ui, "csettings_angle", s.angle_tolerance, Some(label.id)) { action = set("angleTolerance", json!(v)); } ui.end_row(); diff --git a/crates/ui-egui/src/managers/battman.rs b/crates/ui-egui/src/managers/battman.rs index 2c92bb219..7ec42e414 100644 --- a/crates/ui-egui/src/managers/battman.rs +++ b/crates/ui-egui/src/managers/battman.rs @@ -21,17 +21,19 @@ struct Row { } fn temp(ctx: &egui::Context, id: &str) -> Option { - ctx.data_mut(|d| d.get_temp::(egui::Id::new(id))) + ctx.data_mut(|d| d.get_temp::(egui::Id::unique(id))) } fn set_temp(ctx: &egui::Context, id: &str, v: T) { - ctx.data_mut(|d| d.insert_temp(egui::Id::new(id), v)); + ctx.data_mut(|d| d.insert_temp(egui::Id::unique(id), v)); } /// A text field committing on Enter / focus loss when changed (an empty value too). -fn text_field(ui: &mut egui::Ui, id: egui::Id, value: &str) -> Option { +/// +/// `label` is the visible row label's widget id, so the field has an accessible name. +fn text_field(ui: &mut egui::Ui, id: egui::Id, value: &str, label: egui::Id) -> Option { let mut buf = ui.data_mut(|d| d.get_temp::(id)).unwrap_or_else(|| value.to_string()); - let resp = ui.add(egui::TextEdit::singleline(&mut buf).id(id.with("te")).desired_width(180.0)); + let resp = ui.add(egui::TextEdit::singleline(&mut buf).id(id.with("te")).desired_width(180.0)).labelled_by(label); if resp.has_focus() { ui.data_mut(|d| d.insert_temp(id, buf.clone())); } else { @@ -153,19 +155,19 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Option< ui.label(RichText::new(format!("Edit attribute {}", r.tag)).strong()); let edit = |key: &str, v: Value| json!({ "name": block, "action": "edit", "tag": r.tag, key: v }); egui::Grid::new("battman_edit").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { - ui.label("Tag"); - if let Some(v) = text_field(ui, egui::Id::new(("battman_tag", &block, &r.tag)), &r.tag) { + let label = ui.label("Tag"); + if let Some(v) = text_field(ui, egui::Id::unique(("battman_tag", &block, &r.tag)), &r.tag, label.id) { action = act(edit("newTag", json!(v)), apply); sel = Some(v.trim().to_ascii_uppercase()); } ui.end_row(); - ui.label("Prompt"); - if let Some(v) = text_field(ui, egui::Id::new(("battman_prompt", &block, &r.tag)), &r.prompt) { + let label = ui.label("Prompt"); + if let Some(v) = text_field(ui, egui::Id::unique(("battman_prompt", &block, &r.tag)), &r.prompt, label.id) { action = act(edit("prompt", json!(v)), apply); } ui.end_row(); - ui.label("Default"); - if let Some(v) = text_field(ui, egui::Id::new(("battman_default", &block, &r.tag)), &r.default) { + let label = ui.label("Default"); + if let Some(v) = text_field(ui, egui::Id::unique(("battman_default", &block, &r.tag)), &r.default, label.id) { action = act(edit("default", json!(v)), apply); } ui.end_row(); @@ -180,8 +182,8 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Option< } }); ui.end_row(); - ui.label("Height"); - let height = text_field(ui, egui::Id::new(("battman_height", &block, &r.tag)), &fmt_num(r.height)); + let label = ui.label("Height"); + let height = text_field(ui, egui::Id::unique(("battman_height", &block, &r.tag)), &fmt_num(r.height), label.id); if let Some(h) = height.and_then(|v| v.trim().parse::().ok()).filter(|h| h.is_finite() && *h > 0.0) { action = act(edit("height", json!(h)), apply); } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 8a4476e39..43b135d19 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -63,6 +63,8 @@ pub const UI_COMMANDS: &[(&str, &str, &[&str], Option<&str>)] = &[ ("ui.dialog.qselect", "Quick Select...", &[], None), ("ui.dialog.parameters", "Parameters Manager", &["Window", "Parameters Manager"], None), ("ui.dialog.dsettings", "Drafting Settings...", &[], None), + #[cfg(not(target_arch = "wasm32"))] + ("ui.updates.check", "Check for Updates…", &["Help", "Check for Updates…"], None), ("ui.dialog.about", "About CADCraft", &["Help", "About CADCraft"], None), ("ui.dialog.commands", "Command Reference", &["Help", "CADCraft Help"], Some("F1")), ("ui.toggle.history", "Command History", &["Window", "Command History"], Some("F2")), @@ -170,7 +172,7 @@ fn run_ui_command_inner(app: &mut CadApp, id: &str, params: &Value) -> Option Option { + crate::updates_ui::check_now(app); + Ok(Value::Null) + } "ui.dialog.close" => { app.ui.dialog = None; Ok(Value::Null) @@ -642,6 +649,61 @@ pub fn activate(app: &mut CadApp, id: &str) { app.start(id); } +/// Activate the menu command `id` from a native menu bar, which takes the Edit menu's key +/// equivalents (⌘C, ⌘X, ⌘V, ⌘A, ⌘Z, ⇧⌘Z) before any window sees them: while a text field has +/// the keyboard, in the main window or a floating panel's own window, the field gets the key +/// and the drawing command does not run. +pub fn activate_native(app: &mut CadApp, ctx: &egui::Context, id: &str) { + let (Some(window), Some(text)) = (text_field_window(ctx), text_command(id, ctx.os().is_mac())) else { + activate(app, id); + return; + }; + match text { + TextCommand::Paste => ctx.send_viewport_cmd_to(window, egui::ViewportCommand::RequestPaste), + TextCommand::Events(events) if window == egui::ViewportId::ROOT => { + app.synthetic.extend(events); + ctx.request_repaint(); + } + TextCommand::Events(events) => craft_ui::docking::send_keys(ctx, window, events), + } +} + +/// The window whose text field has the keyboard: a floating panel's own OS window, or the main +/// window. +pub fn text_field_window(ctx: &egui::Context) -> Option { + craft_ui::docking::typing_window(ctx).or_else(|| ctx.text_edit_focused().then_some(egui::ViewportId::ROOT)) +} + +/// Whether a focused text field takes the menu command `id` ([`activate_native`]): a native +/// menu keeps such items enabled while one has the keyboard, whatever the drawing allows. +pub fn edits_text(id: &str) -> bool { + text_command(id, false).is_some() +} + +/// What a focused text field does for an Edit menu command. +#[derive(Debug, PartialEq)] +enum TextCommand { + /// The clipboard text, which the window asks for and receives as `Paste`. + Paste, + Events(Vec), +} + +fn text_command(id: &str, mac: bool) -> Option { + let key = |key, shift| { + let modifiers = egui::Modifiers { mac_cmd: mac, ctrl: !mac, command: true, shift, alt: false }; + TextCommand::Events([true, false].map(|pressed| egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }).into()) + }; + Some(match id { + "copyclip" => TextCommand::Events(vec![egui::Event::Copy]), + "cutclip" => TextCommand::Events(vec![egui::Event::Cut]), + "pasteclip" => TextCommand::Paste, + "u" => key(egui::Key::Z, false), + "redo" => key(egui::Key::Z, true), + "selectall" => key(egui::Key::A, false), + _ => return None, + }) +} + /// Run `id` with default parameters without cancelling the active command, as the drafting /// toggles (F3, F7-F12, the status bar buttons) are in AutoCAD. pub fn run_transparent(app: &mut CadApp, id: &str) { diff --git a/crates/ui-egui/src/modal.rs b/crates/ui-egui/src/modal.rs new file mode 100644 index 000000000..10c2ea562 --- /dev/null +++ b/crates/ui-egui/src/modal.rs @@ -0,0 +1,14 @@ +//! Alerts that appear in the frame that asks for them. +//! +//! An `egui::Modal` normally shows a frame late and then fades in over `animation_time` +//! (0.2 s): its area's first frame only measures it and draws nothing, and the dim behind it +//! fades with it. An alert that answers a key press or menu choice should be there at once. + +/// A modal with no fade-in. On its first show it asks egui to run the pass again with the +/// measured size, so the first frame presented already draws it. +pub fn instant(ctx: &egui::Context, id: egui::Id) -> egui::Modal { + if egui::AreaState::load(ctx, id).is_none() { + ctx.request_discard("an instant modal's first show only measures it"); + } + egui::Modal::new(id).area(egui::Modal::default_area(id).fade_in(false)) +} diff --git a/crates/ui-egui/src/mtext_editor.rs b/crates/ui-egui/src/mtext_editor.rs index 3bd54a3cd..8258a2986 100644 --- a/crates/ui-egui/src/mtext_editor.rs +++ b/crates/ui-egui/src/mtext_editor.rs @@ -23,6 +23,42 @@ const SWATCHES: [u16; 7] = [1, 2, 3, 4, 5, 6, 7]; /// Ruler snap, in text heights. const SNAP: f64 = 0.25; +/// Keep the actual diameter character in editable text even without optional fonts. +/// This original circle-and-slash rasterizer is a last fallback, after installed fonts. +pub(crate) fn install_diameter_glyph(ctx: &egui::Context) { + ctx.add_glyph_rasterizer(egui::GlyphRasterizer::new("cadcraft::diameter", |request| { + if request.cluster != "⌀" || !request.font_size_px.is_finite() || !(1.0..=1024.0).contains(&request.font_size_px) { + return None; + } + let size = request.font_size_px; + let side = (size * 0.8).ceil() as usize + 2; + let center = vec2(1.0 + size * 0.4, 1.0 + size * 0.4); + let radius = size * 0.3; + let half_stroke = (size * 0.05).max(1.0) * 0.5; + let start = center + vec2(-radius, radius); + let segment = vec2(2.0 * radius, -2.0 * radius); + let mut pixels = Vec::with_capacity(side * side); + for y in 0..side { + for x in 0..side { + let point = vec2(x as f32 + 0.5, y as f32 + 0.5); + let ring_distance = ((point - center).length() - radius).abs(); + let along = ((point - start).dot(segment) / segment.length_sq()).clamp(0.0, 1.0); + let slash_distance = (point - (start + segment * along)).length(); + let alpha = ((half_stroke + 0.5 - ring_distance.min(slash_distance)).clamp(0.0, 1.0) * 255.0).round() as u8; + pixels.push(Color32::from_white_alpha(alpha)); + } + } + Some(egui::RasterizedGlyph { + bitmap: egui::GlyphBitmap { + image: egui::ColorImage::new([side, side], pixels), + offset_px: vec2(0.0, -size * 0.8 - 1.0), + is_color: false, + }, + advance_px: size * 0.85 + 2.0, + }) + })); +} + #[derive(Clone)] struct State { fmt: Formatted, @@ -39,11 +75,11 @@ struct State { } fn state_id() -> egui::Id { - egui::Id::new("mtext_editor_state") + egui::Id::unique("mtext_editor_state") } fn edit_id() -> egui::Id { - egui::Id::new("mtext_editor_text") + egui::Id::unique("mtext_editor_text") } /// The ACI index or true colour as a screen colour. @@ -89,7 +125,7 @@ pub fn show(app: &mut CadApp, ctx: &egui::Context) { let mut done = Done::No; let top_start = pos.y - st.top; - egui::Area::new(egui::Id::new("mtext_editor_area")).order(egui::Order::Foreground).fixed_pos(pos2(pos.x, top_start)).show(ctx, |ui| { + egui::Area::new(egui::Id::unique("mtext_editor_area")).order(egui::Order::Foreground).fixed_pos(pos2(pos.x, top_start)).show(ctx, |ui| { egui::Frame::NONE.fill(t.panel).stroke(Stroke::new(1.0, t.accent)).inner_margin(egui::Margin::same(0)).show(ui, |ui| { ui.set_width(width); let y0 = ui.cursor().top(); @@ -186,6 +222,7 @@ fn set_flag(c: &mut CharFmt, f: Flag, on: bool) { } } +/// MTEXT formatting toolbar for the current selection: font, height, bold/italic/underline/overline, colour, alignment, lists, tab, degree/plus-minus and Diameter. Font, bold/italic/underline/overline, ByLayer/palette colour, alignment, list, tab and special-character changes refocus the editor; height and custom RGB colour changes do not. fn toolbar(ui: &mut egui::Ui, st: &mut State, base_h: f64) { let cur = current(st); let range = st.sel.0.min(st.sel.1)..st.sel.0.max(st.sel.1); @@ -194,21 +231,22 @@ fn toolbar(ui: &mut egui::Ui, st: &mut State, base_h: f64) { ui.add_space(4.0); // Font let shown = cur.font.clone().unwrap_or_else(|| st.fmt.base_font.clone()); - egui::ComboBox::from_id_salt("mtext_font").width(120.0).selected_text(shown.clone()).show_ui(ui, |ui| { - for fam in FAMILIES.iter().map(ToString::to_string).chain(cur.font.clone().filter(|f| !FAMILIES.contains(&f.as_str()))) { - if ui.selectable_label(shown == fam, &fam).clicked() { - change(st, |c| c.font = Some(fam.clone())); - refocus = true; + egui::ComboBox::from_id_salt("mtext_font") + .width(120.0) + .selected_text(shown.clone()) + .show_ui(ui, |ui| { + for fam in FAMILIES.iter().map(ToString::to_string).chain(cur.font.clone().filter(|f| !FAMILIES.contains(&f.as_str()))) { + if ui.selectable_label(shown == fam, &fam).clicked() { + change(st, |c| c.font = Some(fam.clone())); + refocus = true; + } } - } - }); + }) + .response + .accessible_name(crate::tl!("Font")); // Height let mut h = cur.height.unwrap_or(base_h); - if ui - .add(egui::DragValue::new(&mut h).speed(base_h * 0.02).range(base_h * 0.05..=base_h * 50.0).max_decimals(4)) - .on_hover_text(crate::tl!("Text height")) - .changed() - { + if height_field(ui, &mut h, base_h) { let none = (h - base_h).abs() < 1e-9; change(st, |c| c.height = (!none).then_some(h)); } @@ -226,7 +264,7 @@ fn toolbar(ui: &mut egui::Ui, st: &mut State, base_h: f64) { } // Colour let swatch = cur.color.map_or(ui.visuals().text_color(), color32); - ui.menu_button(RichText::new("A▾").color(swatch).strong(), |ui| { + ui.menu_button(RichText::new("A").color(swatch).strong(), |ui| { if ui.button(crate::tl!("ByLayer")).clicked() { change(st, |c| c.color = None); refocus = true; @@ -253,49 +291,141 @@ fn toolbar(ui: &mut egui::Ui, st: &mut State, base_h: f64) { } }) .response + .accessible_name(crate::tl!("Text color")) .on_hover_text(crate::tl!("Text color")); ui.separator(); // Paragraph alignment let para = st.fmt.para(st.fmt.paragraph_of(range.start)); - for (al, glyph, tip) in [ - (ParaAlign::Left, "⇤", crate::tl!("Left")), - (ParaAlign::Center, "↔", crate::tl!("Center")), - (ParaAlign::Right, "⇥", crate::tl!("Right")), - (ParaAlign::Justify, "≡", crate::tl!("Justify")), + for (al, tip) in [ + (ParaAlign::Left, crate::tl!("Left")), + (ParaAlign::Center, crate::tl!("Center")), + (ParaAlign::Right, crate::tl!("Right")), + (ParaAlign::Justify, crate::tl!("Justify")), ] { let on = para.align == al; - if ui.selectable_label(on, glyph).on_hover_text(tip).clicked() { + if formatting_button(ui, FormattingIcon::Align(al), tip, on) { st.fmt.edit_paras(range.clone(), |p| p.align = if on { ParaAlign::Default } else { al }); refocus = true; } } ui.separator(); // Lists and special characters - if ui.button("•≡").on_hover_text(crate::tl!("Bulleted list")).clicked() { + if formatting_button(ui, FormattingIcon::Bullets, crate::tl!("Bulleted list"), false) { st.fmt.make_list(range.clone(), ListStyle::Bullet, 3.0 * base_h); refocus = true; } - if ui.button("1≡").on_hover_text(crate::tl!("Numbered list")).clicked() { + if formatting_button(ui, FormattingIcon::Numbers, crate::tl!("Numbered list"), false) { st.fmt.make_list(range.clone(), ListStyle::Number, 3.0 * base_h); refocus = true; } - for (glyph, tip, code) in [ - ("⇥|", crate::tl!("Insert tab"), "\t"), - ("°", crate::tl!("Degree"), "°"), - ("±", crate::tl!("Plus/minus"), "±"), - ("⌀", crate::tl!("Diameter"), "⌀"), - ] { + if formatting_button(ui, FormattingIcon::Tab, crate::tl!("Insert tab"), false) { + insert(st, "\t"); + refocus = true; + } + for (glyph, tip, code) in [("°", crate::tl!("Degree"), "°"), ("±", crate::tl!("Plus/minus"), "±")] { if ui.button(glyph).on_hover_text(tip).clicked() { insert(st, code); refocus = true; } } + if diameter_button(ui) { + insert(st, "⌀"); + refocus = true; + } }); if refocus { ui.ctx().memory_mut(|m| m.request_focus(edit_id())); } } +/// Formatting marks use geometry so the toolbar works with the bundled font stack on web too. +enum FormattingIcon { + Align(ParaAlign), + Bullets, + Numbers, + Tab, +} + +/// Glyph-only MTEXT toolbar button: paints `icon`, exposes `label` as its accessible name and tooltip, and returns whether it was clicked. +fn formatting_button(ui: &mut egui::Ui, icon: FormattingIcon, label: &str, selected: bool) -> bool { + let response = ui + .add(egui::Button::new(" ").selected(selected).min_size(vec2(22.0, ui.spacing().interact_size.y))) + .accessible_name(label) + .on_hover_text(label); + let painter = ui.painter(); + let c = response.rect.center(); + let color = if response.enabled() { ui.visuals().text_color() } else { ui.visuals().weak_text_color() }; + let stroke = Stroke::new(1.2, color); + match icon { + FormattingIcon::Align(align) => { + for row in 0..3 { + let width = if row == 1 && align != ParaAlign::Justify { 8.0 } else { 14.0 }; + let x = match align { + ParaAlign::Center => -width / 2.0, + ParaAlign::Right => 7.0 - width, + _ => -7.0, + }; + let y = (row as f32 - 1.0) * 4.0; + painter.line_segment([c + vec2(x, y), c + vec2(x + width, y)], stroke); + } + } + FormattingIcon::Bullets | FormattingIcon::Numbers => { + for row in 0..3 { + let y = (row as f32 - 1.0) * 5.0; + if matches!(icon, FormattingIcon::Bullets) { + painter.circle_filled(c + vec2(-6.0, y), 1.2, color); + } else { + painter.text(c + vec2(-6.0, y), Align2::CENTER_CENTER, (row + 1).to_string(), FontId::proportional(7.0), color); + } + painter.line_segment([c + vec2(-2.0, y), c + vec2(7.0, y)], stroke); + } + } + FormattingIcon::Tab => { + for [a, b] in [ + [vec2(-7.0, 0.0), vec2(4.0, 0.0)], + [vec2(0.0, -4.0), vec2(4.0, 0.0)], + [vec2(0.0, 4.0), vec2(4.0, 0.0)], + [vec2(7.0, -5.0), vec2(7.0, 5.0)], + ] { + painter.line_segment([c + a, c + b], stroke); + } + } + } + response.clicked() +} + +/// The MTEXT toolbar's height field. It has no visible caption, so the AccessKit label carries +/// the meaning of its hover tooltip; a tooltip alone leaves an unnamed spin button. +fn height_field(ui: &mut egui::Ui, height: &mut f64, base_h: f64) -> bool { + ui.add(egui::DragValue::new(height).speed(base_h * 0.02).range(base_h * 0.05..=base_h * 50.0).max_decimals(4)) + .on_hover_text(crate::tl!("Text height")) + .accessible_name(crate::tl!("Text height")) + .changed() +} + +/// The MTEXT Diameter button. U+2300 is absent from every bundled face (including the +/// monochrome and emoji fallbacks) and the web build has no system fonts, so the sign is painted +/// rather than shaped from text. The semantic button name keeps the glyph-only control +/// discoverable to accessibility tools. +fn diameter_button(ui: &mut egui::Ui) -> bool { + let min_size = vec2(22.0, ui.spacing().interact_size.y); + let resp = ui.add(egui::Button::new(" ").min_size(min_size)).on_hover_text(crate::tl!("Diameter")); + let color = if resp.enabled() { ui.visuals().text_color() } else { ui.visuals().weak_text_color() }; + let stroke = Stroke::new(1.4, color); + let center = resp.rect.center(); + let radius = resp.rect.height() * 0.3; + let diagonal = radius * std::f32::consts::FRAC_1_SQRT_2; + let painter = ui.painter(); + painter.circle_stroke(center, radius, stroke); + painter.line_segment([center + vec2(-diagonal, diagonal), center + vec2(diagonal, -diagonal)], stroke); + // Name the node directly as well as through `WidgetInfo`: on a clicked frame `widget_info` + // emits an output event and does not update the AccessKit node, which would leave the + // glyph-only button unnamed for that frame. + let resp = resp.accessible_name(crate::tl!("Diameter")); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, resp.enabled(), crate::tl!("Diameter"))); + resp.clicked() +} + /// Insert `s` at the caret (over the selection). fn insert(st: &mut State, s: &str) { let (a, b) = (st.sel.0.min(st.sel.1), st.sel.0.max(st.sel.1)); @@ -347,7 +477,7 @@ fn ruler(ui: &mut egui::Ui, st: &mut State, px: f32, width: f32, base_h: f64) { let y = if up { rect.bottom() - 1.0 } else { rect.top() + 1.0 }; let dir = if up { -1.0 } else { 1.0 }; let hit = Rect::from_center_size(pos2(x, y + dir * 4.0), vec2(10.0, 10.0)); - let r = ui.interact(hit, ui.id().with(("mtext_indent", k)), Sense::drag()); + let r = ui.interact(hit, ui.scope_id().with(("mtext_indent", k)), Sense::drag()); let col = if r.hovered() || r.dragged() { t.accent } else { t.icon }; painter.add(Shape::convex_polygon(vec![pos2(x - 4.0, y), pos2(x + 4.0, y), pos2(x, y + dir * 8.0)], col, Stroke::NONE)); if r.dragged() @@ -374,6 +504,7 @@ fn ruler(ui: &mut egui::Ui, st: &mut State, px: f32, width: f32, base_h: f64) { bg.on_hover_text(crate::tl!("Click the ruler to add a tab stop, right-click to remove one")); } +/// MTEXT editor area with the formatted preview; restores a pending caret position before laying text out with `base_h`, `px`, `width` and the theme tokens. fn text_box(ui: &mut egui::Ui, st: &mut State, base_h: f64, px: f32, width: f32, t: &Tokens) { let id = edit_id(); if let Some(at) = st.caret.take() @@ -436,6 +567,7 @@ fn text_box(ui: &mut egui::Ui, st: &mut State, base_h: f64, px: f32, width: f32, .char_limit(cadcraft_engine::mtext_edit::MAX_CHARS) .layouter(&mut layouter) .show(ui); + out.response.response.clone().accessible_name(crate::tl!("Text")); if !out.response.has_focus() && buf.is_empty() && st.fmt.is_empty() { out.response.request_focus(); } @@ -462,9 +594,94 @@ fn text_box(ui: &mut egui::Ui, st: &mut State, base_h: f64, px: f32, width: f32, mod tests { use super::*; use cadcraft_engine::Session; + use egui::accesskit::Role; + use egui_kittest::Harness; + use egui_kittest::kittest::{NodeT, Queryable}; use crate::Services; + /// Clicking Diameter inserts U+2300 into both the formatted text and `to_contents()`, selects it, and leaves no unnamed widget. + #[test] + fn diameter_insertion_keeps_real_text_and_renders_without_optional_fonts() { + let state = State { fmt: Formatted::default(), typing: CharFmt::default(), typing_at: 0, top: 70.0, sel: (0, 0), caret: None }; + let mut harness = Harness::builder().with_size(vec2(1100.0, 300.0)).with_context_setup(install_diameter_glyph).build_ui_state( + |ui, state| { + toolbar(ui, state, 2.5); + text_box(ui, state, 2.5, 20.0, 1000.0, &Tokens::get()); + }, + state, + ); + harness.run(); + harness.get_by_role_and_label(Role::Button, "Diameter").click(); + harness.run(); + assert_eq!(harness.state().fmt.text(), "⌀"); + assert_eq!(harness.state().fmt.to_contents(), "⌀"); + assert_eq!(harness.state().sel, (1, 1)); + assert!(harness.unnamed_widgets().is_empty()); + } + + /// The ten toolbar controls listed in the test are found by semantic name under the harness's default bundled fonts, and no toolbar widget is unnamed. + #[test] + fn the_whole_toolbar_uses_bundled_glyphs_and_names_its_controls() { + let mut state = State { fmt: Formatted::default(), typing: CharFmt::default(), typing_at: 0, top: 70.0, sel: (0, 0), caret: None }; + let mut harness = Harness::builder().with_size(vec2(1100.0, 120.0)).build_ui(move |ui| toolbar(ui, &mut state, 2.5)); + harness.run(); + for name in ["Left", "Center", "Right", "Justify", "Bulleted list", "Numbered list", "Insert tab", "Diameter", "Text color", "Font"] { + assert!(harness.query_by_label(name).is_some(), "missing semantic control {name}"); + } + assert!(harness.unnamed_widgets().is_empty(), "unnamed controls: {:?}", harness.unnamed_widgets()); + } + + /// Strict bundled-font and accessibility regression for the glyph-only Diameter button: the + /// test context uses egui's bundled faces only (no `theme::install_fonts` system prepend), + /// and the harness panics on a missing glyph by default. The sign must be painted, not + /// shaped from U+2300. + #[test] + fn diameter_button_is_painted_and_named_under_bundled_fonts() { + let ctx = egui::Context::default(); + ctx.set_missing_glyph_policy(egui::MissingGlyphPolicy::Panic); + let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(120.0, 60.0))), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| { + diameter_button(ui); + }); + out.textures_delta.clear(); + let mut text = String::new(); + let (mut circle, mut slash) = (false, false); + for shape in &out.shapes { + match &shape.shape { + Shape::Circle(_) => circle = true, + Shape::LineSegment { .. } => slash = true, + Shape::Text(t) => text.push_str(&t.galley.job.text), + _ => {} + } + } + assert!(circle, "the diameter sign's circle is painted"); + assert!(slash, "the diameter sign's slash is painted"); + assert!(!text.contains('\u{2300}'), "U+2300 must not be shaped from a font: {text:?}"); + + let mut harness = Harness::builder().with_size(vec2(240.0, 80.0)).build_ui(|ui| { + diameter_button(ui); + }); + harness.run(); + let button = harness.get_by_label("Diameter"); + assert_eq!(button.accesskit_node().role(), Role::Button); + assert!(harness.unnamed_widgets().is_empty(), "unnamed controls: {:?}", harness.unnamed_widgets()); + } + + /// The height DragValue has no visible caption, so its AccessKit label must be set explicitly. + /// Bundled fonts only: no system face can mask a missing glyph in the harness. + #[test] + fn height_field_is_named_for_accessibility_under_bundled_fonts() { + let mut harness = Harness::builder().with_size(vec2(360.0, 80.0)).build_ui(|ui| { + let mut height = 2.5; + height_field(ui, &mut height, 2.5); + }); + harness.run(); + let field = harness.get_by_role_and_label(Role::SpinButton, "Text height"); + assert_eq!(field.accesskit_node().label().as_deref(), Some("Text height")); + assert!(harness.unnamed_widgets().is_empty(), "unnamed controls: {:?}", harness.unnamed_widgets()); + } + #[test] fn editor_opens_only_for_mtext_text_entry_and_finishes_with_formatted_contents() { let ctx = egui::Context::default(); diff --git a/crates/ui-egui/src/opening.rs b/crates/ui-egui/src/opening.rs index a839850d9..b0b2f8781 100644 --- a/crates/ui-egui/src/opening.rs +++ b/crates/ui-egui/src/opening.rs @@ -77,7 +77,7 @@ impl Opening { pub fn show(&self, ctx: &egui::Context) { let Some(label) = self.label() else { return }; let t = Tokens::get(); - egui::Area::new(egui::Id::new("cc_opening")) + egui::Area::new(egui::Id::unique("cc_opening")) .order(egui::Order::Foreground) .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO) .interactable(false) diff --git a/crates/ui-egui/src/options/dialog.rs b/crates/ui-egui/src/options/dialog.rs index 7c0a22af6..0a514720e 100644 --- a/crates/ui-egui/src/options/dialog.rs +++ b/crates/ui-egui/src/options/dialog.rs @@ -33,7 +33,7 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, name: &str, open: &mut bool let mut close = false; let center = ctx.content_rect().center(); egui::Window::new(crate::tl!("Options")) - .id(egui::Id::new("cc_options")) + .id(egui::Id::unique("cc_options")) .open(open) .resizable(false) .collapsible(false) @@ -110,11 +110,12 @@ fn animations_label(a: AnimationPref) -> &'static str { } /// A colour button, its hex value and a Default button; a change goes into `changes` under `key`. -fn color_row(ui: &mut egui::Ui, current: Option, default: Color32, key: &str, changes: &mut Map) { +/// `label` is the visible row label, so a screen reader names the colour well by it. +fn color_row(ui: &mut egui::Ui, current: Option, default: Color32, key: &str, changes: &mut Map, label: egui::Id) { ui.horizontal(|ui| { let shown = current.unwrap_or(default); let mut rgb = [shown.r(), shown.g(), shown.b()]; - if ui.color_edit_button_srgb(&mut rgb).changed() { + if ui.color_edit_button_srgb(&mut rgb).labelled_by(label).changed() { changes.insert(key.into(), json!(super::hex(Color32::from_rgb(rgb[0], rgb[1], rgb[2])))); } ui.monospace(super::hex(shown)); @@ -135,36 +136,39 @@ fn preview_square(ui: &mut egui::Ui, side: f32, color: Color32) { p.rect_stroke(egui::Rect::from_center_size(rect.center(), vec2(side, side)), 0.0, Stroke::new(2.0, color), egui::StrokeKind::Middle); } +/// Display tab: interface theme, animation preference and scale, drawing-area colours, crosshair size, history length and the system-cursor option; edits accumulate in `changes`. fn display(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map) { grid(ui, "options_display", |ui| { - ui.label(crate::tl!("Interface theme")); + let theme_caption = ui.label(crate::tl!("Interface theme")); let mut theme = app.ui.theme; - egui::ComboBox::from_id_salt("options_theme").selected_text(theme_label(theme)).show_ui(ui, |ui| { + let theme_combo = egui::ComboBox::from_id_salt("options_theme").selected_text(theme_label(theme)).show_ui(ui, |ui| { for t in ThemePref::ALL { ui.selectable_value(&mut theme, t, theme_label(t)); } }); + theme_combo.response.labelled_by(theme_caption.id); if theme != app.ui.theme { changes.insert("theme".into(), json!(theme.as_str())); } ui.end_row(); - ui.label(crate::tl!("Animation effects")); + let animations_caption = ui.label(crate::tl!("Animation effects")); let mut animations = app.ui.animations; - egui::ComboBox::from_id_salt("options_animations").selected_text(animations_label(animations)).show_ui(ui, |ui| { + let animations_combo = egui::ComboBox::from_id_salt("options_animations").selected_text(animations_label(animations)).show_ui(ui, |ui| { for a in AnimationPref::ALL { ui.selectable_value(&mut animations, a, animations_label(a)); } }); + animations_combo.response.labelled_by(animations_caption.id); if animations != app.ui.animations { changes.insert("animations".into(), json!(animations.as_str())); } ui.end_row(); - ui.label(crate::tl!("Interface scale")); + let scale_caption = ui.label(crate::tl!("Interface scale")); let scale = app.options.interface_scale; let percent = |s: f32| format!("{} %", (s * 100.0).round()); - egui::ComboBox::from_id_salt("options_interface_scale").selected_text(percent(scale)).show_ui(ui, |ui| { + let scale_combo = egui::ComboBox::from_id_salt("options_interface_scale").selected_text(percent(scale)).show_ui(ui, |ui| { for n in [75u16, 100, 125, 150, 175, 200, 250, 300] { let s = f32::from(n) / 100.0; if ui.selectable_label((scale - s).abs() < 1e-4, format!("{n} %")).clicked() && (scale - s).abs() >= 1e-4 { @@ -172,26 +176,32 @@ fn display(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map) { } } }); + scale_combo.response.labelled_by(scale_caption.id); ui.end_row(); - ui.label(crate::tl!("Model background")); - color_row(ui, app.options.model_background, Tokens::DARK.canvas, "modelBackground", changes); + let background_caption = ui.label(crate::tl!("Model background")); + color_row(ui, app.options.model_background, Tokens::DARK.canvas, "modelBackground", changes, background_caption.id); ui.end_row(); - ui.label(crate::tl!("Layout paper")); - color_row(ui, app.options.paper_background, Tokens::DARK.paper, "paperBackground", changes); + let paper_caption = ui.label(crate::tl!("Layout paper")); + color_row(ui, app.options.paper_background, Tokens::DARK.paper, "paperBackground", changes, paper_caption.id); ui.end_row(); - ui.label(crate::tl!("Crosshair size")); - let mut size = app.session.settings.cursorsize; - if ui.add(egui::Slider::new(&mut size, 1.0..=100.0).integer().suffix(" %")).changed() { - changes.insert("CURSORSIZE".into(), json!(size)); - } + let crosshair_caption = ui.label(crate::tl!("Crosshair size")); + ui.horizontal(|ui| { + let mut size = app.session.settings.cursorsize; + // The slider's own value box is a separate spin button: name both halves with the row label. + let slider = ui.add(egui::Slider::new(&mut size, 1.0..=100.0).integer().show_value(false)).labelled_by(crosshair_caption.id); + let field = ui.add(egui::DragValue::new(&mut size).range(1.0..=100.0).max_decimals(0).suffix(" %")).labelled_by(crosshair_caption.id); + if slider.changed() || field.changed() { + changes.insert("CURSORSIZE".into(), json!(size)); + } + }); ui.end_row(); - ui.label(crate::tl!("Command line history")); + let history_caption = ui.label(crate::tl!("Command line history")); let mut lines = app.ui.history_lines; - if ui.add(egui::DragValue::new(&mut lines).range(0..=crate::cmdline::MAX_LINES)).changed() { + if ui.add(egui::DragValue::new(&mut lines).range(0..=crate::cmdline::MAX_LINES)).labelled_by(history_caption.id).changed() { changes.insert("historyLines".into(), json!(lines)); } ui.end_row(); @@ -204,70 +214,18 @@ fn display(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map) { hint(ui, crate::tl!("Screen magnifiers, screen readers and recorders follow the system cursor.")); } +/// Files tab: save format and default version, new-drawing units, autosave interval and autosave file location; edits accumulate in `changes`. fn files(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map) { grid(ui, "options_files", |ui| { - ui.label(crate::tl!("Save new drawings as")); - ui.horizontal(|ui| { - for f in crate::SaveFormat::ALL { - let label = match f { - crate::SaveFormat::Dxf => crate::tl!("DXF"), - crate::SaveFormat::Dwg => crate::tl!("DWG"), - }; - if ui.radio(app.ui.save_format == f, label).clicked() && app.ui.save_format != f { - changes.insert("saveFormat".into(), json!(f.as_str())); - } - } - }); + save_format_row(ui, app, changes); ui.end_row(); - - ui.label(crate::tl!("Default save version")); - let current = crate::SaveVersion::parse(&app.ui.save_version).unwrap_or_default(); - let mut version = current; - egui::ComboBox::from_id_salt("options_save_version").selected_text(version.year()).show_ui(ui, |ui| { - for v in crate::SaveVersion::ALL { - ui.selectable_value(&mut version, v, v.year()); - } - }); - if version != current { - changes.insert("saveVersion".into(), json!(version.year())); - } + save_version_row(ui, app, changes); ui.end_row(); - - ui.label(crate::tl!("Units of new drawings")); - ui.horizontal(|ui| { - let metric = app.session.settings.measureinit; - if ui.radio(!metric, crate::tl!("Imperial (inches)")).clicked() && metric { - changes.insert("MEASUREINIT".into(), json!(0)); - } - if ui.radio(metric, crate::tl!("Metric (millimetres)")).clicked() && !metric { - changes.insert("MEASUREINIT".into(), json!(1)); - } - }); + units_row(ui, app, changes); ui.end_row(); - - ui.label(crate::tl!("Automatic save every")); - let mut minutes = cadcraft_engine::autosave::savetime(); - let max = cadcraft_engine::autosave::MAX_SAVETIME; - if ui.add(egui::DragValue::new(&mut minutes).range(0..=max).suffix(format!(" {}", crate::tl!("min")))).changed() { - changes.insert("SAVETIME".into(), json!(minutes)); - } + autosave_row(ui, changes); ui.end_row(); - - ui.label(crate::tl!("Automatic save file location")); - // Edited as text; the folder changes when the field loses focus. - let id = egui::Id::new("cc_options_savefilepath"); - let current = cadcraft_engine::autosave::savefilepath(); - let mut path = ui.data(|d| d.get_temp::(id)).unwrap_or_else(|| current.clone()); - let r = ui.add(egui::TextEdit::singleline(&mut path).desired_width(330.0)); - if r.changed() { - ui.data_mut(|d| d.insert_temp(id, path.clone())); - } - if r.lost_focus() { - ui.data_mut(|d| d.remove::(id)); - if path.trim() != current { - changes.insert("SAVEFILEPATH".into(), json!(path.trim())); - } - } + savefile_row(ui, changes); ui.end_row(); }); hint(ui, crate::tl!("New and the first drawing at startup use these units unless a default template is set (QNEWTEMPLATE).")); @@ -275,23 +233,98 @@ fn files(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map) { hint(ui, crate::tl!("Drawings with no file yet are saved in the default version. 0 minutes turns automatic save off.")); } +fn save_format_row(ui: &mut egui::Ui, app: &CadApp, changes: &mut Map) { + ui.label(crate::tl!("Save new drawings as")); + ui.horizontal(|ui| { + for f in crate::SaveFormat::ALL { + let label = match f { + crate::SaveFormat::Dxf => crate::tl!("DXF"), + crate::SaveFormat::Dwg => crate::tl!("DWG"), + }; + if ui.radio(app.ui.save_format == f, label).clicked() && app.ui.save_format != f { + changes.insert("saveFormat".into(), json!(f.as_str())); + } + } + }); +} + +fn save_version_row(ui: &mut egui::Ui, app: &CadApp, changes: &mut Map) { + let version_caption = ui.label(crate::tl!("Default save version")); + let current = crate::SaveVersion::parse(&app.ui.save_version).unwrap_or_default(); + let mut version = current; + let version_combo = egui::ComboBox::from_id_salt("options_save_version").selected_text(version.year()).show_ui(ui, |ui| { + for v in crate::SaveVersion::ALL { + ui.selectable_value(&mut version, v, v.year()); + } + }); + version_combo.response.labelled_by(version_caption.id); + if version != current { + changes.insert("saveVersion".into(), json!(version.year())); + } +} + +fn units_row(ui: &mut egui::Ui, app: &CadApp, changes: &mut Map) { + ui.label(crate::tl!("Units of new drawings")); + ui.horizontal(|ui| { + let metric = app.session.settings.measureinit; + if ui.radio(!metric, crate::tl!("Imperial (inches)")).clicked() && metric { + changes.insert("MEASUREINIT".into(), json!(0)); + } + if ui.radio(metric, crate::tl!("Metric (millimetres)")).clicked() && !metric { + changes.insert("MEASUREINIT".into(), json!(1)); + } + }); +} + +fn autosave_row(ui: &mut egui::Ui, changes: &mut Map) { + let minutes_caption = ui.label(crate::tl!("Automatic save every")); + let mut minutes = cadcraft_engine::autosave::savetime(); + let max = cadcraft_engine::autosave::MAX_SAVETIME; + if ui.add(egui::DragValue::new(&mut minutes).range(0..=max).suffix(format!(" {}", crate::tl!("min")))).labelled_by(minutes_caption.id).changed() { + changes.insert("SAVETIME".into(), json!(minutes)); + } +} + +fn savefile_row(ui: &mut egui::Ui, changes: &mut Map) { + let savefile_label = ui.label(crate::tl!("Automatic save file location")); + // Edited as text; the folder changes when the field loses focus. + let id = egui::Id::unique("cc_options_savefilepath"); + let current = cadcraft_engine::autosave::savefilepath(); + let mut path = ui.data(|d| d.get_temp::(id)).unwrap_or_else(|| current.clone()); + let r = ui.add(egui::TextEdit::singleline(&mut path).desired_width(330.0)).labelled_by(savefile_label.id); + if r.changed() { + ui.data_mut(|d| d.insert_temp(id, path.clone())); + } + if r.lost_focus() { + ui.data_mut(|d| d.remove::(id)); + if path.trim() != current { + changes.insert("SAVEFILEPATH".into(), json!(path.trim())); + } + } +} + +/// Drafting tab: object-snap marker size and aperture previews plus the Drafting Settings entry; edits accumulate in `changes` and the entry may request a command through `start`. fn drafting(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map, start: &mut Option<&'static str>) { let t = Tokens::get(); grid(ui, "options_drafting", |ui| { - ui.label(crate::tl!("Object snap marker size")); + let marker_caption = ui.label(crate::tl!("Object snap marker size")); ui.horizontal(|ui| { let mut size = app.options.snap_marker_size; - if ui.add(egui::Slider::new(&mut size, super::SNAP_MARKER_SIZES).integer()).changed() { + let slider = ui.add(egui::Slider::new(&mut size, super::SNAP_MARKER_SIZES).integer().show_value(false)).labelled_by(marker_caption.id); + let field = ui.add(egui::DragValue::new(&mut size).range(super::SNAP_MARKER_SIZES).max_decimals(0)).labelled_by(marker_caption.id); + if slider.changed() || field.changed() { changes.insert("snapMarkerSize".into(), json!(size)); } preview_square(ui, size, t.snap); }); ui.end_row(); - ui.label(crate::tl!("Object snap aperture")); + let aperture_caption = ui.label(crate::tl!("Object snap aperture")); ui.horizontal(|ui| { let mut aperture = app.session.settings.aperture; - if ui.add(egui::Slider::new(&mut aperture, 1.0..=50.0).integer().suffix(" px")).changed() { + let slider = ui.add(egui::Slider::new(&mut aperture, 1.0..=50.0).integer().show_value(false)).labelled_by(aperture_caption.id); + let field = ui.add(egui::DragValue::new(&mut aperture).range(1.0..=50.0).max_decimals(0).suffix(" px")).labelled_by(aperture_caption.id); + if slider.changed() || field.changed() { changes.insert("APERTURE".into(), json!(aperture)); } preview_square(ui, aperture as f32, t.crosshair); @@ -308,17 +341,20 @@ fn drafting(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map, s }); } +/// Selection tab: pickbox and selection-mode controls, selection preview/area effects and the Visual Effect Settings window; edits accumulate in `changes`. fn selection(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map) { let t = Tokens::get(); let st = &app.session.settings; - let mut effects = ui.ctx().data(|d| d.get_temp::(egui::Id::new(VISUAL_EFFECTS)).unwrap_or(false)); + let mut effects = ui.ctx().data(|d| d.get_temp::(egui::Id::unique(VISUAL_EFFECTS)).unwrap_or(false)); ui.columns(2, |cols| { let [left, right] = cols else { return }; // Left: how objects are picked. - heading(left, crate::tl!("Pickbox size")); + let pickbox_caption = heading(left, crate::tl!("Pickbox size")); left.horizontal(|ui| { let mut pickbox = st.pickbox; - if ui.add(egui::Slider::new(&mut pickbox, 0.0..=50.0).integer().suffix(" px")).changed() { + let slider = ui.add(egui::Slider::new(&mut pickbox, 0.0..=50.0).integer().show_value(false)).labelled_by(pickbox_caption.id); + let field = ui.add(egui::DragValue::new(&mut pickbox).range(0.0..=50.0).max_decimals(0).suffix(" px")).labelled_by(pickbox_caption.id); + if slider.changed() || field.changed() { changes.insert("PICKBOX".into(), json!(pickbox)); } preview_square(ui, (pickbox * 3.0) as f32, t.crosshair); @@ -350,14 +386,23 @@ fn selection(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map) changes.insert("QPMODE".into(), json!(i32::from(qp))); } left.add_space(4.0); - left.label(crate::tl!("Selection effect color")); - color_row(left, app.options.selection_color, crate::theme::Tokens::of(crate::theme::active()).selection, "selectionColor", changes); + let selection_caption = left.label(crate::tl!("Selection effect color")); + color_row( + left, + app.options.selection_color, + crate::theme::Tokens::of(crate::theme::active()).selection, + "selectionColor", + changes, + selection_caption.id, + ); // Right: grips and the preview under the cursor. - heading(right, crate::tl!("Grip size")); + let grip_caption = heading(right, crate::tl!("Grip size")); right.horizontal(|ui| { let mut grip = st.gripsize; - if ui.add(egui::Slider::new(&mut grip, 1.0..=20.0).integer().suffix(" px")).changed() { + let slider = ui.add(egui::Slider::new(&mut grip, 1.0..=20.0).integer().show_value(false)).labelled_by(grip_caption.id); + let field = ui.add(egui::DragValue::new(&mut grip).range(1.0..=20.0).max_decimals(0).suffix(" px")).labelled_by(grip_caption.id); + if slider.changed() || field.changed() { changes.insert("GRIPSIZE".into(), json!(grip)); } preview_square(ui, (grip * 2.0) as f32, t.grip); @@ -370,10 +415,11 @@ fn selection(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map) } right.horizontal(|ui| { let mut limit = st.gripobjlimit; - if ui.add_enabled(st.grips > 0, egui::DragValue::new(&mut limit).range(0..=32_767)).changed() { + let field = ui.add_enabled(st.grips > 0, egui::DragValue::new(&mut limit).range(0..=32_767)); + let caption = ui.label(crate::tl!("Object selection limit for display of grips")); + if field.labelled_by(caption.id).changed() { changes.insert("GRIPOBJLIMIT".into(), json!(limit)); } - ui.label(crate::tl!("Object selection limit for display of grips")); }); right.add_space(8.0); heading(right, crate::tl!("Preview")); @@ -391,14 +437,15 @@ fn selection(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map) if effects { visual_effects(ui.ctx(), st, &mut effects, changes); } - ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new(VISUAL_EFFECTS), effects)); + ui.ctx().data_mut(|d| d.insert_temp(egui::Id::unique(VISUAL_EFFECTS), effects)); } /// Whether the Visual Effect Settings window is open (egui temp data). const VISUAL_EFFECTS: &str = "cc_options_visual_effects"; -fn heading(ui: &mut egui::Ui, text: &str) { - ui.label(RichText::new(text).strong()); +/// Strong (bold) label for section headings in the Options Selection tab and the Visual Effect Settings window. +fn heading(ui: &mut egui::Ui, text: &str) -> egui::Response { + ui.label(RichText::new(text).strong()) } /// Visual Effect Settings: the selection area (window and crossing fills) and the objects the @@ -406,7 +453,7 @@ fn heading(ui: &mut egui::Ui, text: &str) { fn visual_effects(ctx: &egui::Context, st: &cadcraft_engine::Settings, open: &mut bool, changes: &mut Map) { let mut close = false; egui::Window::new(crate::tl!("Visual Effect Settings")) - .id(egui::Id::new("cc_visual_effects")) + .id(egui::Id::unique("cc_visual_effects")) .open(open) .resizable(false) .collapsible(false) @@ -421,15 +468,19 @@ fn visual_effects(ctx: &egui::Context, st: &cadcraft_engine::Settings, open: &mu changes.insert("SELECTIONAREA".into(), json!(i32::from(area))); } left.add_enabled_ui(st.selectionarea, |ui| { - ui.label(crate::tl!("Window selection color:")); - aci_row(ui, st.windowareacolor, "WINDOWAREACOLOR", changes); - ui.label(crate::tl!("Crossing selection color:")); - aci_row(ui, st.crossingareacolor, "CROSSINGAREACOLOR", changes); - ui.label(crate::tl!("Selection area opacity:")); - let mut opacity = st.selectionareaopacity; - if ui.add(egui::Slider::new(&mut opacity, 0..=100)).changed() { - changes.insert("SELECTIONAREAOPACITY".into(), json!(opacity)); - } + let window_caption = ui.label(crate::tl!("Window selection color:")); + aci_row(ui, st.windowareacolor, "WINDOWAREACOLOR", changes, window_caption.id); + let crossing_caption = ui.label(crate::tl!("Crossing selection color:")); + aci_row(ui, st.crossingareacolor, "CROSSINGAREACOLOR", changes, crossing_caption.id); + let opacity_caption = ui.label(crate::tl!("Selection area opacity:")); + ui.horizontal(|ui| { + let mut opacity = st.selectionareaopacity; + let slider = ui.add(egui::Slider::new(&mut opacity, 0..=100).show_value(false)).labelled_by(opacity_caption.id); + let field = ui.add(egui::DragValue::new(&mut opacity).range(0..=100)).labelled_by(opacity_caption.id); + if slider.changed() || field.changed() { + changes.insert("SELECTIONAREAOPACITY".into(), json!(opacity)); + } + }); }); heading(right, crate::tl!("Selection Preview Filter")); @@ -463,35 +514,41 @@ fn visual_effects(ctx: &egui::Context, st: &cadcraft_engine::Settings, open: &mu } /// A colour by its index (ACI 1-255): a swatch and the number; a change goes into `changes`. -fn aci_row(ui: &mut egui::Ui, current: u8, key: &str, changes: &mut Map) { +fn aci_row(ui: &mut egui::Ui, current: u8, key: &str, changes: &mut Map, label: egui::Id) { ui.horizontal(|ui| { let c = cadcraft_color::aci_rgb(current); let (r, _) = ui.allocate_exact_size(vec2(16.0, 16.0), egui::Sense::hover()); ui.painter().rect_filled(r, 2.0, Color32::from_rgb(c.0, c.1, c.2)); let mut v = current; - if ui.add(egui::DragValue::new(&mut v).range(1..=255).prefix(format!("{} ", crate::tl!("Color")))).changed() { + if ui.add(egui::DragValue::new(&mut v).range(1..=255).prefix(format!("{} ", crate::tl!("Color")))).labelled_by(label).changed() { changes.insert(key.into(), json!(v)); } }); } +/// Preferences tab: interface language, largest-array limit and time-sensitive right-click; edits accumulate in `changes`. fn preferences(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map) { grid(ui, "options_preferences", |ui| { - ui.label(crate::tl!("Interface language")); + let language_caption = ui.label(crate::tl!("Interface language")); let mut lang = app.ui.interface_language; - egui::ComboBox::from_id_salt("options_language").selected_text(lang.name()).show_ui(ui, |ui| { + let language_combo = egui::ComboBox::from_id_salt("options_language").selected_text(lang.name()).show_ui(ui, |ui| { for p in crate::i18n::Preference::for_picker() { ui.selectable_value(&mut lang, p, p.name()); } }); + language_combo.response.labelled_by(language_caption.id); if lang != app.ui.interface_language { changes.insert("interfaceLanguage".into(), json!(lang.code())); } ui.end_row(); - ui.label(crate::tl!("Largest array")); + let array_caption = ui.label(crate::tl!("Largest array")); let mut max = app.session.settings.maxarray; - if ui.add(egui::DragValue::new(&mut max).range(100..=10_000_000).speed(1000.0).suffix(format!(" {}", crate::tl!("objects")))).changed() { + if ui + .add(egui::DragValue::new(&mut max).range(100..=10_000_000).speed(1000.0).suffix(format!(" {}", crate::tl!("objects")))) + .labelled_by(array_caption.id) + .changed() + { changes.insert("MAXARRAY".into(), json!(max)); } ui.end_row(); diff --git a/crates/ui-egui/src/options/mod.rs b/crates/ui-egui/src/options/mod.rs index 37ee2c404..c8b94c310 100644 --- a/crates/ui-egui/src/options/mod.rs +++ b/crates/ui-egui/src/options/mod.rs @@ -107,7 +107,7 @@ pub fn hex(c: Color32) -> String { /// Parse `"#rrggbb"` (the `#` is optional). pub fn parse_hex(s: &str) -> Option { let h = s.trim().trim_start_matches('#'); - if h.len() != 6 || !h.is_ascii() { + if h.len() != 6 || !h.bytes().all(|b| b.is_ascii_hexdigit()) { return None; } let byte = |i: usize| h.get(i..i + 2).and_then(|b| u8::from_str_radix(b, 16).ok()); @@ -387,7 +387,7 @@ mod tests { let c = Color32::from_rgb(0x12, 0xab, 0xff); assert_eq!(parse_hex(&hex(c)), Some(c)); assert_eq!(parse_hex("FFFFFF"), Some(Color32::WHITE)); - for bad in ["", "#fff", "#gggggg", "#12345678", "#ééé", "#1234567"] { + for bad in ["#+f+0+0", "", "#fff", "#gggggg", "#12345678", "#ééé", "#1234567"] { assert_eq!(parse_hex(bad), None, "{bad}"); } } diff --git a/crates/ui-egui/src/palettes.rs b/crates/ui-egui/src/palettes.rs index 29bedf630..78e2d7e93 100644 --- a/crates/ui-egui/src/palettes.rs +++ b/crates/ui-egui/src/palettes.rs @@ -139,6 +139,7 @@ fn command_known(id: &str) -> bool { cadcraft_engine::find_command(id).is_some() || crate::menus::is_ui_command(id) } +/// Tool Sets strip with Drafting/Modeling tabs, their command buttons and the collapse control. pub fn toolsets(app: &mut CadApp, ui: &mut egui::Ui) { let t = Tokens::get(); egui::Frame::NONE.fill(t.panel).show(ui, |ui| { @@ -151,7 +152,7 @@ pub fn toolsets(app: &mut CadApp, ui: &mut egui::Ui) { let w = 92.0; let tr = Rect::from_min_size(pos2(x, r.top()), vec2(w, tab_h)); let active = app.ui.toolset_tab == name; - let resp = ui.interact(tr, ui.id().with(("ts", name)), Sense::click()); + let resp = ui.interact(tr, ui.scope_id().with(("ts", name)), Sense::click()); p.rect_filled(tr, 0.0, if active { t.tab_active } else { t.chrome }); p.text( tr.center(), @@ -168,7 +169,7 @@ pub fn toolsets(app: &mut CadApp, ui: &mut egui::Ui) { } p.rect_filled(Rect::from_min_max(pos2(x, r.top()), pos2(r.right(), r.top() + tab_h)), 0.0, t.chrome); let cr = Rect::from_center_size(pos2(r.right() - 14.0, r.top() + tab_h / 2.0), vec2(14.0, 14.0)); - let cresp = ui.interact(cr, ui.id().with("ts_collapse"), Sense::click()); + let cresp = ui.interact(cr, ui.scope_id().with("ts_collapse"), Sense::click()); icons::paint(&p, cr, Icon::ChevronLeft, false); icons::describe_control(ui, &cresp, "Collapse Tool Sets", None, false); if cresp.on_hover_text(crate::tl!("Collapse Tool Sets")).clicked() { @@ -202,7 +203,7 @@ pub fn toolsets(app: &mut CadApp, ui: &mut egui::Ui) { t.text, ); let gear = Rect::from_center_size(pos2(hr.right() - 12.0, hr.center().y), vec2(13.0, 13.0)); - let gresp = ui.interact(gear, ui.id().with(("ts_gear", name)), Sense::click()); + let gresp = ui.interact(gear, ui.scope_id().with(("ts_gear", name)), Sense::click()); icons::paint(pp, Rect::from_center_size(gear.center(), vec2(11.0, 11.0)), Icon::Gear, false); icons::describe_control(ui, &hresp, name, Some(!collapsed), false); icons::describe_control(ui, &gresp, &format!("{name} options"), None, false); @@ -329,7 +330,7 @@ pub fn toolsets_strip(app: &mut CadApp, ui: &mut egui::Ui) { let t = Tokens::get(); egui::Panel::left("cc_toolsets_strip").exact_size(22.0).resizable(false).frame(egui::Frame::NONE.fill(t.chrome)).show(ui, |ui| { let r = ui.max_rect(); - let resp = ui.interact(r, ui.id().with("ts_expand"), Sense::click()); + let resp = ui.interact(r, ui.scope_id().with("ts_expand"), Sense::click()); icons::paint(ui.painter(), Rect::from_center_size(pos2(r.center().x, r.top() + 14.0), vec2(14.0, 14.0)), Icon::ChevronRight, false); icons::describe_control(ui, &resp, "Expand Tool Sets", None, false); if resp.on_hover_text(crate::tl!("Tool Sets")).clicked() { @@ -338,6 +339,7 @@ pub fn toolsets_strip(app: &mut CadApp, ui: &mut egui::Ui) { }); } +/// Right palette panel (Layers/Blocks/Properties icon strip and collapse control) containing the Layers and Properties sections. pub fn right_palettes(app: &mut CadApp, ui: &mut egui::Ui) { if !app.ui.show_palettes { return; @@ -354,7 +356,7 @@ pub fn right_palettes(app: &mut CadApp, ui: &mut egui::Ui) { icons::paint(&p, br, *icon, false); } let cr = Rect::from_center_size(pos2(r.right() - 14.0, r.top() + tab_h / 2.0), vec2(14.0, 14.0)); - let cresp = ui.interact(cr, ui.id().with("palettes_collapse"), Sense::click()); + let cresp = ui.interact(cr, ui.scope_id().with("palettes_collapse"), Sense::click()); icons::paint(&p, cr, Icon::ChevronRight, false); icons::describe_control(ui, &cresp, "Collapse Palettes", None, false); if cresp.on_hover_text(crate::tl!("Collapse Palettes")).clicked() { @@ -514,15 +516,17 @@ pub(crate) fn layers_section(app: &mut CadApp, ui: &mut egui::Ui) { } } +/// Properties row: a right-aligned translated label in a 106-point layout box that exposes a Label node to accessibility and names the control added by `add`. fn prop_row(ui: &mut egui::Ui, label: &str, add: impl FnOnce(&mut egui::Ui)) { let t = Tokens::get(); ui.horizontal(|ui| { let mut job = egui::text::LayoutJob::simple(crate::i18n::t(label).to_string(), crate::theme::body(), t.text_dim, 106.0); job.halign = egui::Align::RIGHT; let galley = ui.painter().layout_job(job); - let (lr, _) = ui.allocate_exact_size(vec2(118.0, galley.size().y.max(22.0)), Sense::hover()); + let (lr, label_response) = ui.allocate_exact_size(vec2(118.0, galley.size().y.max(22.0)), Sense::hover()); + label_response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, ui.is_enabled(), crate::i18n::t(label))); ui.painter().galley(pos2(lr.right() - 6.0, lr.center().y - galley.size().y / 2.0), galley, t.text_dim); - add(ui); + ui.label_inputs_by(label_response.id, add); }); } @@ -612,7 +616,7 @@ type Custom = Option<(String, Vec Custom { let st = app.session.state().ok()?; - let id = egui::Id::new(("dynamic-block-custom", st.uid, st.revision, h)); + let id = egui::Id::unique(("dynamic-block-custom", st.uid, st.revision, h)); if let Some(c) = ui.data(|d| d.get_temp::(id)) { return c; } @@ -639,7 +643,7 @@ fn properties_header(app: &mut CadApp, ui: &mut egui::Ui) { ui.painter().rect_filled(if app.ui.properties_all { half } else { my }, 3.0, t.control); ui.painter().text(half.center(), egui::Align2::CENTER_CENTER, crate::tl!("All"), crate::theme::small(), t.text); ui.painter().text(my.center(), egui::Align2::CENTER_CENTER, crate::tl!("My"), crate::theme::small(), t.text_faint); - ui.interact(my, ui.id().with("prop_my"), Sense::hover()).on_hover_text(crate::tl!("My properties: not available yet")); + ui.interact(my, ui.scope_id().with("prop_my"), Sense::hover()).on_hover_text(crate::tl!("My properties: not available yet")); let sel = app.session.selection(); let pickadd = app.session.settings.pickadd; let Ok(d) = app.session.doc() else { return }; @@ -701,6 +705,7 @@ fn properties_header(app: &mut CadApp, ui: &mut egui::Ui) { } } +/// Properties palette: the selection's common properties, or the current defaults when nothing is selected; formats lengths per drawing units and dispatches setter commands. pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) { let t = Tokens::get(); properties_header(app, ui); @@ -757,7 +762,7 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) { } }); prop_row(ui, "Linetype scale", |ui| { - if let Some(f) = number(edit_field(ui, ui.id().with("cur_celtscale"), format!("{:.4}", h.f64("CELTSCALE", 1.0))), &mut bad) { + if let Some(f) = number(edit_field(ui, ui.scope_id().with("cur_celtscale"), format!("{:.4}", h.f64("CELTSCALE", 1.0))), &mut bad) { act("setvar", json!({ "name": "CELTSCALE", "value": f })); } }); @@ -779,7 +784,7 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) { }); prop_row(ui, "Transparency", |ui| unavailable(ui, "ByLayer")); prop_row(ui, "Thickness", |ui| { - if let Some(f) = number(edit_field(ui, ui.id().with("cur_thickness"), len(h.f64("THICKNESS", 0.0))), &mut bad) { + if let Some(f) = number(edit_field(ui, ui.scope_id().with("cur_thickness"), len(h.f64("THICKNESS", 0.0))), &mut bad) { act("setvar", json!({ "name": "THICKNESS", "value": f })); } }); @@ -805,7 +810,7 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) { }); prop_row(ui, "Annotation scale", |ui| unavailable(ui, "1:1")); prop_row(ui, "Text height", |ui| { - if let Some(f) = number(edit_field(ui, ui.id().with("cur_textsize"), len(h.f64("TEXTSIZE", 0.2))), &mut bad) { + if let Some(f) = number(edit_field(ui, ui.scope_id().with("cur_textsize"), len(h.f64("TEXTSIZE", 0.2))), &mut bad) { act("setvar", json!({ "name": "TEXTSIZE", "value": f })); } }); @@ -888,7 +893,7 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) { let width = (ui.available_width() - 10.0).max(0.0); if let Some(c) = crate::layers::color_button_resolved( ui, - egui::Id::new(("properties_color", &ids)), + egui::Id::unique(("properties_color", &ids)), selected_color.unwrap_or(Color::ByLayer), resolved_color, &color, @@ -924,7 +929,7 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) { }); }); prop_row(ui, "Linetype scale", |ui| { - if let Some(f) = number(edit_field(ui, ui.id().with(("lts", &ids)), lts.clone()), &mut bad) { + if let Some(f) = number(edit_field(ui, ui.scope_id().with(("lts", &ids)), lts.clone()), &mut bad) { set = Some(json!({ "handles": ids, "ltscale": f })); } }); @@ -954,7 +959,7 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) { } ui.separator(); // A fixed percentage, committed when the drag or edit ends. - let tid = egui::Id::new(("prop_tr", &ids)); + let tid = egui::Id::unique(("prop_tr", &ids)); let cur = tr.parse::().ok(); let mut pct = ui.data_mut(|d| d.get_temp::(tid)).or(cur).unwrap_or(0); ui.horizontal(|ui| { @@ -979,7 +984,7 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) { let angles = app.session.angle_settings(); let num = |ui: &mut egui::Ui, label: &str, key: &str, shown: String, set: &mut Option, bad: &mut Option, ids: &Vec| { prop_row(ui, label, |ui| { - if let Some(f) = number(edit_field(ui, ui.id().with((key, ids)), shown), bad) { + if let Some(f) = number(edit_field(ui, ui.scope_id().with((key, ids)), shown), bad) { *set = Some(json!({ "handles": ids, key: f })); } }); @@ -987,22 +992,22 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) { match &e.kind { EntityKind::Line(l) => { prop_row(ui, "Start X", |ui| { - if let Some(f) = number(edit_field(ui, ui.id().with(("sx", &ids)), len(l.a.x)), &mut bad) { + if let Some(f) = number(edit_field(ui, ui.scope_id().with(("sx", &ids)), len(l.a.x)), &mut bad) { set = Some(json!({ "handles": ids, "start": [f, l.a.y] })); } }); prop_row(ui, "Start Y", |ui| { - if let Some(f) = number(edit_field(ui, ui.id().with(("sy", &ids)), len(l.a.y)), &mut bad) { + if let Some(f) = number(edit_field(ui, ui.scope_id().with(("sy", &ids)), len(l.a.y)), &mut bad) { set = Some(json!({ "handles": ids, "start": [l.a.x, f] })); } }); prop_row(ui, "End X", |ui| { - if let Some(f) = number(edit_field(ui, ui.id().with(("ex", &ids)), len(l.b.x)), &mut bad) { + if let Some(f) = number(edit_field(ui, ui.scope_id().with(("ex", &ids)), len(l.b.x)), &mut bad) { set = Some(json!({ "handles": ids, "end": [f, l.b.y] })); } }); prop_row(ui, "End Y", |ui| { - if let Some(f) = number(edit_field(ui, ui.id().with(("ey", &ids)), len(l.b.y)), &mut bad) { + if let Some(f) = number(edit_field(ui, ui.scope_id().with(("ey", &ids)), len(l.b.y)), &mut bad) { set = Some(json!({ "handles": ids, "end": [l.b.x, f] })); } }); @@ -1038,7 +1043,7 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) { } EntityKind::Text(tx) => { prop_row(ui, "Contents", |ui| { - if let Some(s) = edit_field(ui, ui.id().with(("txt", &ids)), tx.value.clone()) { + if let Some(s) = edit_field(ui, ui.scope_id().with(("txt", &ids)), tx.value.clone()) { set = Some(json!({ "handles": ids, "text": s })); } }); @@ -1048,7 +1053,7 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) { } EntityKind::MText(tx) => { prop_row(ui, "Contents", |ui| { - if let Some(s) = edit_field(ui, ui.id().with(("mtxt", &ids)), tx.contents.clone()) { + if let Some(s) = edit_field(ui, ui.scope_id().with(("mtxt", &ids)), tx.contents.clone()) { set = Some(json!({ "handles": ids, "text": s })); } }); @@ -1084,7 +1089,7 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) { } EntityKind::Dimension(dm) => { prop_row(ui, "Text override", |ui| { - if let Some(s) = edit_field(ui, ui.id().with(("dimtxt", &ids)), dm.text.clone()) { + if let Some(s) = edit_field(ui, ui.scope_id().with(("dimtxt", &ids)), dm.text.clone()) { set = Some(json!({ "handles": ids, "textOverride": s })); } }); @@ -1123,7 +1128,7 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) { prop_row(ui, &p.name, |ui| { let picked = match (p.editable, p.choices.is_empty()) { (true, false) => pick_menu(ui, &p.value, p.choices.iter().cloned()), - (true, true) => edit_field(ui, ui.id().with(("dynprop", e.handle, &p.name)), p.value.clone()), + (true, true) => edit_field(ui, ui.scope_id().with(("dynprop", e.handle, &p.name)), p.value.clone()), (false, _) => { read_only(ui, &p.value); None diff --git a/crates/ui-egui/src/palettes/patterns.rs b/crates/ui-egui/src/palettes/patterns.rs index 6d628dc26..746cbd6a1 100644 --- a/crates/ui-egui/src/palettes/patterns.rs +++ b/crates/ui-egui/src/palettes/patterns.rs @@ -124,18 +124,19 @@ pub fn menu(ui: &mut egui::Ui, current: &str) -> Option { /// HATCHEDIT's pattern prompt): a click enters the pattern's name at the prompt. pub fn prompt_picker(app: &mut CadApp, ctx: &egui::Context) { let Some(Picker::HatchPattern(current)) = app.session.current_prompt().and_then(|p| p.picker) else { return }; - let filter_id = egui::Id::new("hatch_pattern_filter"); + let filter_id = egui::Id::unique("hatch_pattern_filter"); let mut filter: String = ctx.data_mut(|d| d.get_temp(filter_id)).unwrap_or_default(); let mut chosen = None; let area = ctx.content_rect(); egui::Window::new(crate::tl!("Hatch Pattern")) - .id(egui::Id::new("Hatch Pattern")) + .id(egui::Id::unique("Hatch Pattern")) .collapsible(true) .resizable(true) .default_size(vec2(5.0 * (CELL + GAP) + 12.0, 5.0 * (CELL + LABEL_H + GAP) + 40.0)) .default_pos(pos2(area.max.x - 5.0 * (CELL + GAP) - 330.0, area.min.y + 120.0)) .show(ctx, |ui| { - ui.add(egui::TextEdit::singleline(&mut filter).hint_text(crate::tl!("Search for pattern")).desired_width(f32::INFINITY)); + ui.add(egui::TextEdit::singleline(&mut filter).hint_text(crate::tl!("Search for pattern")).desired_width(f32::INFINITY)) + .on_hover_text(crate::tl!("Search for pattern")); ui.add_space(4.0); egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| { chosen = grid(ui, ¤t, &filter); diff --git a/crates/ui-egui/src/palettes/quickcalc.rs b/crates/ui-egui/src/palettes/quickcalc.rs index f573f701e..a4cabe1fd 100644 --- a/crates/ui-egui/src/palettes/quickcalc.rs +++ b/crates/ui-egui/src/palettes/quickcalc.rs @@ -40,7 +40,7 @@ struct State { } fn id() -> egui::Id { - egui::Id::new("cadcraft-quickcalc") + egui::Id::unique("cadcraft-quickcalc") } /// A variable's value as typed into an expression: a number, or a point as `[x,y]`. @@ -208,6 +208,15 @@ pub fn open(app: &mut CadApp) -> Result { crate::docking::command(app, false, &json!({ "operation": "float", "panel": panel.id(), "rect": rect })) } +/// The unit-conversion value row. The visible "Value" label names the numeric field (its +/// AccessKit label becomes "Value"), and the placeholder repeats the meaning instead of the +/// unexplained "1" example. +fn value_row(ui: &mut egui::Ui, value: &mut String) { + let label = ui.label(crate::tl!("Value")); + ui.add(egui::TextEdit::singleline(value).hint_text(crate::tl!("Value")).desired_width(120.0)).labelled_by(label.id); +} + +/// Quick Calculator dock body: expression entry with evaluation, variables and unit conversion. pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { let t = Tokens::get(); let mut st: State = ui.ctx().data_mut(|d| d.get_temp(id())).unwrap_or_default(); @@ -320,8 +329,7 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { }); ui.end_row(); } - ui.label(crate::tl!("Value")); - ui.add(egui::TextEdit::singleline(&mut st.conv_value).hint_text("1").desired_width(120.0)); + value_row(ui, &mut st.conv_value); ui.end_row(); if ui.button(crate::tl!("Convert")).clicked() { st.convert(app); @@ -365,3 +373,27 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { }); ui.ctx().data_mut(|d| d.insert_temp(id(), st)); } + +#[cfg(test)] +mod tests { + use egui::accesskit::Role; + use egui_kittest::Harness; + use egui_kittest::kittest::{NodeT, Queryable}; + + use super::value_row; + + /// The conversion value field is named by its visible label under bundled fonts only, so a + /// system face cannot hide a missing glyph and a bare "1" placeholder cannot pass as a name. + #[test] + fn value_field_is_named_by_its_visible_label() { + let mut harness = Harness::builder().with_size(egui::vec2(320.0, 80.0)).build_ui(|ui| { + let mut value = String::new(); + value_row(ui, &mut value); + }); + harness.run(); + let field = harness.get_by_role_and_label(Role::TextInput, "Value"); + assert!(field.accesskit_node().labelled_by().next().is_some(), "named by the visible Value label"); + assert_eq!(field.accesskit_node().placeholder(), Some("Value")); + assert!(harness.unnamed_widgets().is_empty(), "unnamed controls: {:?}", harness.unnamed_widgets()); + } +} diff --git a/crates/ui-egui/src/panel_state.rs b/crates/ui-egui/src/panel_state.rs new file mode 100644 index 000000000..39ad79657 --- /dev/null +++ b/crates/ui-egui/src/panel_state.rs @@ -0,0 +1,76 @@ +//! Additive migration of persisted panel sizes from the 0.36 root-id key to 0.37's +//! `parent_ui.scope_id().with_salt(name)` key. +//! +//! CADCraft disables egui memory persistence today, but the app's own saved workspace or a host +//! that enables persistence must not lose a resized panel once. The old key is retained for +//! rollback; a value already stored under the new key always wins. + +/// The top-level panel id salts used by CADCraft. Keep these strings stable: they are names in +/// saved test fixtures and in `ui.elements` expectations, not disposable implementation details. +pub(crate) const PANEL_IDS: &[&str] = &["cc_title_toolbar", "cc_menubar", "cc_status", "cc_file_tabs", "cc_toolsets_strip", "cc_palettes"]; + +/// Copy an old-key `PanelState` to the 0.37 key before panels are shown. Call once on the first +/// pass, while the root `Ui` scope id is available. +pub(crate) fn migrate(ui: &egui::Ui) { + let ctx = ui.ctx(); + let parent = ui.scope_id(); + for name in PANEL_IDS { + let old = egui::Id::unique(name); + let new = parent.with_salt(egui::IdSalt::new(name)); + if old == new { + continue; + } + let Some(state) = ctx.data_mut(|data| data.get_persisted::(old)) else { + continue; + }; + ctx.data_mut(|data| { + if data.get_persisted::(new).is_none() { + data.insert_persisted(new, state); + } + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Test panel state `width` points wide and 26 points high. + fn state(width: f32) -> egui::PanelState { + egui::PanelState { outer_rect: egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 26.0)) } + } + + /// `migrate` copies the old root-id panel state into the 0.37 scoped key and leaves the old key in place for rollback. + #[test] + fn old_key_is_copied_to_the_scoped_key_once() { + let ctx = egui::Context::default(); + let old = egui::Id::unique("cc_status"); + ctx.data_mut(|data| data.insert_persisted(old, state(41.0))); + let mut new_key = None; + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { + migrate(ui); + let new = ui.scope_id().with_salt(egui::IdSalt::new("cc_status")); + new_key = Some(new); + assert_eq!(egui::PanelState::load(ui.ctx(), new).map(|state| state.size().x), Some(41.0)); + }); + output.textures_delta.clear(); + let new_key = new_key.expect("a root Ui"); + assert_eq!(egui::PanelState::load(&ctx, new_key).map(|state| state.size().x), Some(41.0)); + assert_eq!(egui::PanelState::load(&ctx, old).map(|state| state.size().x), Some(41.0), "the old key is kept for rollback"); + } + + /// `migrate` never replaces a value already stored under the new scoped key. + #[test] + fn a_new_key_is_never_overwritten() { + let ctx = egui::Context::default(); + let old = egui::Id::unique("cc_status"); + ctx.data_mut(|data| data.insert_persisted(old, state(41.0))); + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { + let new = ui.scope_id().with_salt(egui::IdSalt::new("cc_status")); + ctx.data_mut(|data| data.insert_persisted(new, state(64.0))); + migrate(ui); + assert_eq!(egui::PanelState::load(ui.ctx(), new).map(|state| state.size().x), Some(64.0)); + }); + output.textures_delta.clear(); + } +} diff --git a/crates/ui-egui/src/parametric.rs b/crates/ui-egui/src/parametric.rs index 83a4328bd..ade7a39fa 100644 --- a/crates/ui-egui/src/parametric.rs +++ b/crates/ui-egui/src/parametric.rs @@ -392,7 +392,7 @@ pub fn draw_overlay(app: &mut CadApp, ui: &mut egui::Ui, painter: &egui::Painter painter.rect_filled(r, 2.0, t.canvas.gamma_multiply(0.85)); lock(painter, Rect::from_min_size(r.min + vec2(3.0, (r.height() - 13.0) / 2.0), vec2(13.0, 13.0)), col); painter.galley(r.min + vec2(19.0, 2.0), galley, col); - let resp = ui.interact(r, ui.id().with(("dimcon", c.id)), Sense::click()); + let resp = ui.interact(r, ui.scope_id().with(("dimcon", c.id)), Sense::click()); if resp.double_clicked() { act = Some(Act::Edit(c.name.clone(), c.expr.clone(), r.left_center())); } @@ -464,7 +464,7 @@ pub fn draw_overlay(app: &mut CadApp, ui: &mut egui::Ui, painter: &egui::Painter painter.rect_stroke(r, 4.0, Stroke::new(1.0, Color32::from_rgb(0x6e, 0x75, 0x80).gamma_multiply(alpha)), egui::StrokeKind::Inside); for (i, (kind, id)) in kinds.iter().take(16).enumerate() { let cr = Rect::from_min_size(r.min + vec2(2.0 + i as f32 * cell, 2.0), vec2(cell, cell)); - let resp = ui.interact(cr, ui.id().with(("cbar", h.0, *id)), Sense::hover()); + let resp = ui.interact(cr, ui.scope_id().with(("cbar", h.0, *id)), Sense::hover()); if resp.hovered() { painter.rect_filled(cr.shrink(1.0), 3.0, Color32::from_rgb(0xf2, 0xf4, 0xf7)); } @@ -475,7 +475,7 @@ pub fn draw_overlay(app: &mut CadApp, ui: &mut egui::Ui, painter: &egui::Painter if let Some((text, col)) = status { let sr = Rect::from_min_max(pos2(r.right() - strip - 1.0, r.top() + 3.0), pos2(r.right() - 2.0, r.bottom() - 3.0)); painter.rect_filled(sr, 2.0, col.gamma_multiply(alpha.max(0.5))); - ui.interact(sr.expand(2.0), ui.id().with(("cbar_dof", h.0)), Sense::hover()).on_hover_text(text); + ui.interact(sr.expand(2.0), ui.scope_id().with(("cbar_dof", h.0)), Sense::hover()).on_hover_text(text); } } // The drawing's constraint state. @@ -498,11 +498,12 @@ fn act_take(app: &mut CadApp, act: Option, ui: &mut egui::Ui) -> Option<()> // The inline expression editor. let (name, mut buf, at) = app.canvas.param.editing.clone()?; let mut done: Option = None; - egui::Area::new(egui::Id::new("dimcon_edit")).order(egui::Order::Foreground).fixed_pos(at - vec2(0.0, 12.0)).show(ui.ctx(), |ui| { + egui::Area::new(egui::Id::unique("dimcon_edit")).order(egui::Order::Foreground).fixed_pos(at - vec2(0.0, 12.0)).show(ui.ctx(), |ui| { egui::Frame::popup(ui.style()).show(ui, |ui| { ui.horizontal(|ui| { - ui.label(format!("{name} =")); - let r = ui.add(egui::TextEdit::singleline(&mut buf).desired_width(140.0).id(egui::Id::new("dimcon_edit_te"))); + let label = ui.label(format!("{name} =")); + let r = + ui.add(egui::TextEdit::singleline(&mut buf).desired_width(140.0).id(egui::Id::unique("dimcon_edit_te"))).labelled_by(label.id); if !r.has_focus() && !r.lost_focus() { r.request_focus(); } @@ -571,14 +572,14 @@ pub fn parameters_dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) .and_then(|v| v.get("parameters").cloned()) .and_then(|v| serde_json::from_value(v).ok()) .unwrap_or_default(); - let filter_id = egui::Id::new("par_filter"); + let filter_id = egui::Id::unique("par_filter"); let mut filter = ctx.data_mut(|d| d.get_temp::(filter_id)).unwrap_or_default(); let mut action: Option = None; // The name being renamed in place: (old name, text typed so far). - let rename_id = egui::Id::new("par_rename"); + let rename_id = egui::Id::unique("par_rename"); let mut renaming = ctx.data_mut(|d| d.get_temp::<(String, String)>(rename_id)); egui::Window::new(crate::tl!("Parameters Manager")) - .id(egui::Id::new("Parameters Manager")) + .id(egui::Id::unique("Parameters Manager")) .open(open) .default_size(vec2(560.0, 300.0)) .resizable(true) @@ -592,7 +593,8 @@ pub fn parameters_dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) action = Some(json!({ "name": format!("user{n}"), "expr": "1" })); } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - ui.add(egui::TextEdit::singleline(&mut filter).hint_text(crate::tl!("Search for parameter")).desired_width(170.0)); + ui.add(egui::TextEdit::singleline(&mut filter).hint_text(crate::tl!("Search for parameter")).desired_width(170.0)) + .accessible_name(crate::tl!("Search for parameter")); }); }); ui.separator(); @@ -609,6 +611,7 @@ pub fn parameters_dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) ui.label(egui::RichText::new(crate::i18n::t(title)).color(t.text_dim).small()); ui.end_row(); for r in group { + let mut name_label = None; if let Some((old, buf)) = renaming.as_mut().filter(|(old, _)| *old == r.name) { if let Some(name) = rename_field(ui, rename_id.with("te"), old, buf) { if !name.is_empty() && name != *old { @@ -616,11 +619,15 @@ pub fn parameters_dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) } renaming = None; } - } else if ui.add(egui::Label::new(&r.name).selectable(false).sense(egui::Sense::click())).double_clicked() { - renaming = Some((r.name.clone(), r.name.clone())); + } else { + let label = ui.add(egui::Label::new(&r.name).selectable(false).sense(egui::Sense::click())); + if label.double_clicked() { + renaming = Some((r.name.clone(), r.name.clone())); + } + name_label = Some(label.id); } - let id = egui::Id::new(("par_expr", &r.name)); - if let Some(v) = expr_field(ui, id, &r.expr) { + let id = egui::Id::unique(("par_expr", &r.name)); + if let Some(v) = expr_field(ui, id, &r.expr, name_label) { action = Some(json!({ "name": r.name, "expr": v })); } match (&r.value, &r.error) { @@ -662,8 +669,11 @@ pub fn parameters_dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) /// The in-place name editor: focused with the old name selected when it opens, so typing goes /// here and not to the command line. `Some` when it closes: the new name on Enter, `""` otherwise. fn rename_field(ui: &mut egui::Ui, id: egui::Id, old: &str, buf: &mut String) -> Option { + // The id must be the same on every frame; `Id::unique` hashes its source deterministically. + // A `TextEdit` stores its state while it renders, so after requesting focus here the + // select-all range is loaded from that state and takes effect on the next frame. let opening = egui::TextEdit::load_state(ui.ctx(), id).is_none(); - let resp = ui.add(egui::TextEdit::singleline(buf).id(id).desired_width(120.0)); + let resp = ui.add(egui::TextEdit::singleline(buf).id(id).desired_width(120.0)).accessible_name(crate::tl!("Name")); if opening { resp.request_focus(); if let Some(mut state) = egui::TextEdit::load_state(ui.ctx(), id) { @@ -680,9 +690,16 @@ fn rename_field(ui: &mut egui::Ui, id: egui::Id, old: &str, buf: &mut String) -> } /// A text field committing on Enter / focus loss when changed. -pub fn expr_field(ui: &mut egui::Ui, id: egui::Id, value: &str) -> Option { +/// +/// `label` is the id of the visible label beside the field, so a screen reader reads the field as +/// that label instead of an unnamed text box. +pub fn expr_field(ui: &mut egui::Ui, id: egui::Id, value: &str, label: Option) -> Option { let mut buf = ui.data_mut(|d| d.get_temp::(id)).unwrap_or_else(|| value.to_string()); let resp = ui.add(egui::TextEdit::singleline(&mut buf).id(id.with("te")).desired_width(150.0)); + let resp = match label { + Some(label) => resp.labelled_by(label), + None => resp.accessible_name(crate::tl!("Value")), + }; // Focus from memory: `Response::has_focus` is false while the OS window is unfocused, // which dropped the pending edit (matches `palettes::edit_field`). if ui.memory(|m| m.has_focus(id.with("te"))) { @@ -713,14 +730,14 @@ mod tests { fn frame(ctx: &egui::Context, focused: bool, events: Vec) -> Option { let mut out = None; let input = egui::RawInput { focused, events, ..Default::default() }; - ctx.run_ui(input, |ui| out = expr_field(ui, egui::Id::new("e"), "1")).drop_without_applying_deltas(); + ctx.run_ui(input, |ui| out = expr_field(ui, egui::Id::unique("e"), "1", None)).drop_without_applying_deltas(); out } #[test] fn pending_expression_survives_window_blur() { let ctx = egui::Context::default(); - let id = egui::Id::new("e"); + let id = egui::Id::unique("e"); frame(&ctx, true, vec![]); ctx.memory_mut(|m| m.request_focus(id.with("te"))); frame(&ctx, true, vec![egui::Event::Text("+2".into())]); diff --git a/crates/ui-egui/src/plotstyles.rs b/crates/ui-egui/src/plotstyles.rs index bc3df8b0f..54727162c 100644 --- a/crates/ui-egui/src/plotstyles.rs +++ b/crates/ui-egui/src/plotstyles.rs @@ -20,11 +20,11 @@ pub fn route(app: &mut CadApp, id: &str, params: &Value) -> Option(ctx: &egui::Context, id: &str) -> Option { - ctx.data_mut(|d| d.get_temp::(egui::Id::new(id))) + ctx.data_mut(|d| d.get_temp::(egui::Id::unique(id))) } fn set_temp(ctx: &egui::Context, id: &str, v: T) { - ctx.data_mut(|d| d.insert_temp(egui::Id::new(id), v)); + ctx.data_mut(|d| d.insert_temp(egui::Id::unique(id), v)); } /// Lineweights offered for a style, in millimetres. @@ -41,6 +41,7 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { } } +/// Plot Style Manager window; returns the command (`stylesmanager` or `pagesetup`) to run after drawing. fn window(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action { let t = Tokens::get(); let title = crate::tl!("Plot Style Manager"); @@ -65,7 +66,7 @@ fn window(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action { let mut new_style = temp::(ctx, "pstyle_new").unwrap_or_default(); let mut action: Action = None; let set = |table: &str, style: Value| Some(("stylesmanager", json!({ "action": "set", "name": table, "style": style }))); - egui::Window::new(title).id(egui::Id::new("Plot Style Manager")).open(open).default_size(vec2(760.0, 460.0)).resizable(true).show(ctx, |ui| { + egui::Window::new(title).id(egui::Id::unique("Plot Style Manager")).open(open).default_size(vec2(760.0, 460.0)).resizable(true).show(ctx, |ui| { ui.horizontal(|ui| { ui.label(crate::tl!("Plot style table")); egui::ComboBox::from_id_salt("pstyle_table").selected_text(sel.as_str()).width(200.0).show_ui(ui, |ui| { @@ -197,8 +198,8 @@ fn window(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action { if named { ui.separator(); ui.horizontal(|ui| { - ui.label(crate::tl!("New style")); - ui.add(egui::TextEdit::singleline(&mut new_style).desired_width(160.0)); + let label = ui.label(crate::tl!("New style")); + ui.add(egui::TextEdit::singleline(&mut new_style).desired_width(160.0)).labelled_by(label.id); if ui.add_enabled(!new_style.trim().is_empty(), egui::Button::new(crate::tl!("Add"))).clicked() { action = set(&table.name, json!({ "name": new_style.trim() })); new_style.clear(); diff --git a/crates/ui-egui/src/quick.rs b/crates/ui-egui/src/quick.rs index 91f6740ea..6eb7c45b9 100644 --- a/crates/ui-egui/src/quick.rs +++ b/crates/ui-egui/src/quick.rs @@ -59,9 +59,10 @@ fn pretty(k: &str) -> String { out } +/// Quick Select window; caches `qselect.info` per drawing, apply-to choice and object type, then runs the selection with the chosen criteria. pub fn qselect_dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { let t = Tokens::get(); - let sid = egui::Id::new("qselect_state"); + let sid = egui::Id::unique("qselect_state"); let mut st: QsState = ctx.data_mut(|d| d.get_temp(sid)).unwrap_or_default(); let key = app.session.state().map(|s| std::sync::Arc::as_ptr(&s.doc) as usize).unwrap_or(0); let want = (key, st.apply_selection, st.ty.clone()); @@ -77,7 +78,7 @@ pub fn qselect_dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { let mut run: Option = None; let mut close = false; egui::Window::new(crate::tl!("Quick Select")) - .id(egui::Id::new("Quick Select")) + .id(egui::Id::unique("Quick Select")) .open(open) .collapsible(false) .resizable(false) @@ -141,7 +142,7 @@ pub fn qselect_dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { } }); ui.end_row(); - ui.label(crate::tl!("Value:")); + let value_label = ui.label(crate::tl!("Value:")); let choices: Vec = match st.property.as_str() { "color" => { ["ByLayer", "ByBlock", "Red", "Yellow", "Green", "Cyan", "Blue", "Magenta", "White"].iter().map(ToString::to_string).collect() @@ -152,7 +153,8 @@ pub fn qselect_dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { _ => Vec::new(), }; ui.horizontal(|ui| { - ui.add(egui::TextEdit::singleline(&mut st.value).desired_width(if choices.is_empty() { 220.0 } else { 190.0 })); + ui.add(egui::TextEdit::singleline(&mut st.value).desired_width(if choices.is_empty() { 220.0 } else { 190.0 })) + .labelled_by(value_label.id); if !choices.is_empty() { ui.menu_button("▼", |ui| { egui::ScrollArea::vertical().max_height(240.0).show(ui, |ui| { @@ -228,10 +230,11 @@ struct QpState { at: Option, } +/// Quick Properties numeric row: an expression or distance field labelled by `label`; commits only a finite value under `key` into `set`. fn num_row(ui: &mut egui::Ui, label: &str, id: egui::Id, v: f64, set: &mut Option<(String, Value)>, key: &str) { - ui.label(RichText::new(crate::i18n::t(label)).color(Tokens::get().text_dim)); + let label = ui.label(RichText::new(crate::i18n::t(label)).color(Tokens::get().text_dim)); let s = format!("{v:.4}"); - if let Some(n) = crate::parametric::expr_field(ui, id, &s) + if let Some(n) = crate::parametric::expr_field(ui, id, &s, Some(label.id)) && let Some(f) = cadcraft_engine::units::parse_distance(&n).or_else(|| n.parse::().ok()).filter(|f| f.is_finite()) { *set = Some((key.to_string(), json!(f))); @@ -247,7 +250,7 @@ fn ro_row(ui: &mut egui::Ui, label: &str, v: String) { /// The Quick Properties palette, near where the selection was made. pub fn quick_properties(app: &mut CadApp, ctx: &egui::Context) { - let sid = egui::Id::new("qp_state"); + let sid = egui::Id::unique("qp_state"); let sel = app.session.selection(); let active = app.session.settings.qpmode && !sel.is_empty() @@ -299,7 +302,7 @@ pub fn quick_properties(app: &mut CadApp, ctx: &egui::Context) { let angles = app.session.angle_settings(); let mut close = false; egui::Window::new(title) - .id(egui::Id::new(("quick_properties", &st.sel))) + .id(egui::Id::unique(("quick_properties", &st.sel))) .default_pos(at) .collapsible(false) .resizable(false) @@ -309,7 +312,7 @@ pub fn quick_properties(app: &mut CadApp, ctx: &egui::Context) { egui::Grid::new("qp_grid").num_columns(2).spacing(vec2(10.0, 4.0)).min_col_width(80.0).show(ui, |ui| { ui.label(RichText::new(crate::tl!("Color")).color(Tokens::get().text_dim)); let cur = color.unwrap_or(cadcraft_color::Color::ByLayer); - if let Some(c) = crate::layers::color_button(ui, egui::Id::new(("qp_color", &ids)), cur, true, 150.0) { + if let Some(c) = crate::layers::color_button(ui, egui::Id::unique(("qp_color", &ids)), cur, true, 150.0) { set = Some(("color".into(), json!(c.name()))); } ui.end_row(); @@ -334,7 +337,7 @@ pub fn quick_properties(app: &mut CadApp, ctx: &egui::Context) { } }); ui.end_row(); - let id = |k: &str| egui::Id::new(("qp", k, &ids)); + let id = |k: &str| egui::Id::unique(("qp", k, &ids)); match &kind { Some(EntityKind::Line(l)) => { let dl = l.b.xy() - l.a.xy(); @@ -369,8 +372,8 @@ pub fn quick_properties(app: &mut CadApp, ctx: &egui::Context) { num_row(ui, "Global width", id("w"), p.const_width, &mut set, "width"); } Some(EntityKind::Text(tx)) => { - ui.label(RichText::new(crate::tl!("Contents")).color(Tokens::get().text_dim)); - if let Some(v) = crate::parametric::expr_field(ui, id("txt"), &tx.value) { + let label = ui.label(RichText::new(crate::tl!("Contents")).color(Tokens::get().text_dim)); + if let Some(v) = crate::parametric::expr_field(ui, id("txt"), &tx.value, Some(label.id)) { set = Some(("text".into(), json!(v))); } ui.end_row(); @@ -378,8 +381,8 @@ pub fn quick_properties(app: &mut CadApp, ctx: &egui::Context) { num_row(ui, "Rotation", id("rot"), angles.shown(tx.rotation).to_degrees(), &mut set, "rotation"); } Some(EntityKind::MText(tx)) => { - ui.label(RichText::new(crate::tl!("Contents")).color(Tokens::get().text_dim)); - if let Some(v) = crate::parametric::expr_field(ui, id("mtxt"), &tx.contents) { + let label = ui.label(RichText::new(crate::tl!("Contents")).color(Tokens::get().text_dim)); + if let Some(v) = crate::parametric::expr_field(ui, id("mtxt"), &tx.contents, Some(label.id)) { set = Some(("text".into(), json!(v))); } ui.end_row(); @@ -391,8 +394,8 @@ pub fn quick_properties(app: &mut CadApp, ctx: &egui::Context) { num_row(ui, "Scale", id("sc"), i.scale.x, &mut set, "scale"); } Some(EntityKind::Dimension(dm)) => { - ui.label(RichText::new(crate::tl!("Text override")).color(Tokens::get().text_dim)); - if let Some(v) = crate::parametric::expr_field(ui, id("dt"), &dm.text) { + let label = ui.label(RichText::new(crate::tl!("Text override")).color(Tokens::get().text_dim)); + if let Some(v) = crate::parametric::expr_field(ui, id("dt"), &dm.text, Some(label.id)) { set = Some(("textOverride".into(), json!(v))); } ui.end_row(); diff --git a/crates/ui-egui/src/spacemouse.rs b/crates/ui-egui/src/spacemouse.rs index 8f2c90e12..3a9e2ba32 100644 --- a/crates/ui-egui/src/spacemouse.rs +++ b/crates/ui-egui/src/spacemouse.rs @@ -132,8 +132,9 @@ fn navigate(app: &mut CadApp, params: &Value) -> Result { Ok(json!({"center": [center.x, center.y], "height": height})) } +/// 3D Mouse window showing connection status, detected device/detail and the navigation settings, with a Calibrate action. pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { - egui::Window::new(crate::tl!("3D Mouse")).id(egui::Id::new("spacemouse")).open(open).resizable(false).show(ctx, |ui| { + egui::Window::new(crate::tl!("3D Mouse")).id(egui::Id::unique("spacemouse")).open(open).resizable(false).show(ctx, |ui| { ui.label(crate::i18n::t(app.spacemouse.status.message())); if let Some(device) = &app.spacemouse.device { ui.label(device); diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index fe1fd43fb..9aeac31a1 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -307,8 +307,10 @@ pub fn mono() -> FontId { FontId::new(12.0, FontFamily::Monospace) } -/// Load a system UI font at run time when one is installed (not bundled), else keep egui's; -/// then CJK fonts after the Latin ones, so Latin text keeps its look, in every interface +/// Load a system UI font at run time when one is installed (not bundled), else keep egui's +/// bundled bounded Latin/icon faces (`default_fonts` + `monochrome_emoji_fonts`, enabled in the +/// workspace manifest for native and web); then CJK fonts after the Latin ones, so Latin text +/// keeps its look, in every interface /// language: file, layout and layer names can be Japanese or Chinese whatever the interface /// shows (#513). In a Chinese interface (`language` "zh…") the Chinese font comes first, else a /// Japanese one, so names take their own glyph forms; in an Arabic interface an installed @@ -317,6 +319,7 @@ pub fn mono() -> FontId { /// interface adds a Hangul font only when the CJK font found doesn't cover Hangul /// ([`korean_font`]). pub fn install_fonts(ctx: &egui::Context, language: &str) { + crate::mtext_editor::install_diameter_glyph(ctx); let mut fonts = egui::FontDefinitions::default(); let mut added: Vec<(String, std::sync::Arc)> = Vec::new(); #[cfg(not(target_arch = "wasm32"))] @@ -341,6 +344,11 @@ pub fn install_fonts(ctx: &egui::Context, language: &str) { if let Some(f) = fonts.families.get_mut(&FontFamily::Proportional) { f.insert(0, "system-ui".into()); } + // Keep Hack first in Monospace, then use the system face for glyphs Hack lacks. + if let Some(f) = fonts.families.get_mut(&FontFamily::Monospace) { + let at = f.iter().position(|name| name == "Hack").map_or(0, |i| i + 1); + f.insert(at, "system-ui".into()); + } break; } } @@ -388,6 +396,16 @@ pub fn install_fonts(ctx: &egui::Context, language: &str) { } } } + // `default_fonts` supplies Ubuntu-Light (Proportional) and Hack (Monospace). Hack also + // carries the modifier symbols (⇧) that proportional shortcut labels use; keep it as a + // bounded late fallback when Ubuntu-Light lacks one. The monochrome emoji face (enabled by + // `monochrome_emoji_fonts`) supplies ⌘ in both families. + if fonts.font_data.contains_key("Hack") + && let Some(f) = fonts.families.get_mut(&FontFamily::Proportional) + && !f.iter().any(|name| name == "Hack") + { + f.push("Hack".into()); + } ctx.set_fonts(fonts); } diff --git a/crates/ui-egui/src/updates_ui.rs b/crates/ui-egui/src/updates_ui.rs new file mode 100644 index 000000000..1bd05c6f8 --- /dev/null +++ b/crates/ui-egui/src/updates_ui.rs @@ -0,0 +1,71 @@ +//! One background update check at startup, the "Update and Restart" prompt, and Help ▸ Check for +//! Updates… (desktop only; the web build is always the latest). The flow itself is +//! `craft_update::updater::Updater`. + +use craft_update::suite::CADCRAFT; +use craft_update::updater::{Action, Status, Updater}; + +use crate::CadApp; + +/// The version this build reports to the update check. +const VERSION: &str = env!("CARGO_PKG_VERSION"); + +/// What the update check and prompt are doing between frames. +pub struct Runtime { + updater: Updater, + /// Whether the host asked for a startup check (see [`CadApp::enable_update_check`]). + enabled: bool, + /// Help ▸ Check for Updates… was chosen; the next frame's `tick` starts it with the context. + check_requested: bool, +} + +impl Default for Runtime { + fn default() -> Self { + Self { updater: Updater::new(&CADCRAFT, VERSION), enabled: false, check_requested: false } + } +} + +impl CadApp { + /// Turn on the startup update check (the desktop host does; tests and the web build do not). + pub fn enable_update_check(&mut self) { + self.updates_rt.enabled = true; + } +} + +/// Help ▸ Check for Updates…: check now, and say so when there is nothing newer. +pub fn check_now(app: &mut CadApp) { + app.updates_rt.check_requested = true; +} + +/// Start the checks and collect the answers. +pub fn tick(app: &mut CadApp, ctx: &egui::Context) { + let rt = &mut app.updates_rt; + if std::mem::take(&mut rt.check_requested) { + rt.updater.check_now(ctx); + } else if rt.enabled { + rt.updater.check_at_start(ctx); + } + rt.updater.tick(ctx); + let outcome = match rt.updater.status() { + Status::UpToDate => Some(crate::tl!("CADCraft is up to date.")), + Status::Failed(_) => Some(crate::tl!("Could not check for updates.")), + Status::Idle | Status::Checking => None, + }; + if let Some(message) = outcome { + rt.updater.dismiss_status(); + app.session.echo(message); + } +} + +/// Draw the prompt while there is one and act on its answer. +pub fn show(app: &mut CadApp, ctx: &egui::Context) { + // The restart helper is scheduled before the app closes, so no update starts over unsaved work. + let clean = !app.session.docs.iter().any(|d| d.is_dirty()); + match app.updates_rt.updater.show_if(ctx, clean) { + Action::OpenUrl(url) => ctx.open_url(egui::OpenUrl::new_tab(url)), + // The guarded close path asks about unsaved changes; the restart helper relaunches once the + // process has exited. + Action::Close => ctx.send_viewport_cmd(egui::ViewportCommand::Close), + Action::None => {} + } +} diff --git a/crates/ui-egui/src/viewcube.rs b/crates/ui-egui/src/viewcube.rs index 6f3ec0a3d..8f96e82fe 100644 --- a/crates/ui-egui/src/viewcube.rs +++ b/crates/ui-egui/src/viewcube.rs @@ -80,7 +80,7 @@ impl Part { /// Paint the ViewCube's ring, compass letters, Top face and Home button, and handle the pointer. /// The canvas paints the WCS pill itself; this returns the pointer's part for its highlight. pub fn show(app: &mut CadApp, ui: &mut egui::Ui, l: &Layout) { - let resp = ui.interact(l.bounds, ui.id().with("viewcube"), Sense::click_and_drag()); + let resp = ui.interact(l.bounds, ui.scope_id().with("viewcube"), Sense::click_and_drag()); let part = resp.hover_pos().and_then(|p| l.part_at(p)); let p = ui.painter_at(l.bounds.union(l.pill)); let ring_col = if part == Some(Part::Ring) { Color32::from_rgb(0x5c, 0x67, 0x7c) } else { Color32::from_rgb(0x48, 0x50, 0x5c) }; @@ -132,7 +132,7 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui, l: &Layout) { let _ = app.run(id, params); } // The WCS pill (painted by the canvas) opens the coordinate-system menu. - let pill = ui.interact(l.pill, ui.id().with("viewcube_wcs"), Sense::click_and_drag()).on_hover_cursor(CursorIcon::PointingHand); + let pill = ui.interact(l.pill, ui.scope_id().with("viewcube_wcs"), Sense::click_and_drag()).on_hover_cursor(CursorIcon::PointingHand); let pill = pill.on_hover_text("Coordinate system"); ucs_menu(app, egui::Popup::menu(&pill)); } @@ -164,8 +164,8 @@ pub fn ucs_arms(canvas: Rect) -> [Rect; 2] { pub fn ucs_icon(app: &mut CadApp, ui: &mut egui::Ui, canvas: Rect) { let t = Tokens::get(); let [x, y] = ucs_arms(canvas); - let rx = ui.interact(x, ui.id().with("ucs_x"), Sense::click_and_drag()); - let ry = ui.interact(y, ui.id().with("ucs_y"), Sense::click_and_drag()); + let rx = ui.interact(x, ui.scope_id().with("ucs_x"), Sense::click_and_drag()); + let ry = ui.interact(y, ui.scope_id().with("ucs_y"), Sense::click_and_drag()); let resp = (rx | ry).on_hover_cursor(CursorIcon::PointingHand).on_hover_text("UCS icon: World Coordinate System"); if resp.hovered() { let o = pos2(x.left() + 8.0, x.center().y); diff --git a/crates/ui-egui/src/vpscale.rs b/crates/ui-egui/src/vpscale.rs index 8f19c85e3..304797c19 100644 --- a/crates/ui-egui/src/vpscale.rs +++ b/crates/ui-egui/src/vpscale.rs @@ -120,7 +120,7 @@ pub fn status(app: &mut CadApp, ui: &mut egui::Ui, right: f32, cy: f32) -> f32 { let p = ui.painter().clone(); // Lock toggle. let lr = Rect::from_min_size(pos2(right - 20.0, cy - 9.0), vec2(18.0, 18.0)); - let lresp = ui.interact(lr, ui.id().with("vp_lock"), Sense::click()); + let lresp = ui.interact(lr, ui.scope_id().with("vp_lock"), Sense::click()); if lresp.hovered() { p.rect_filled(lr, 3.0, t.control_hover.gamma_multiply(0.5)); } @@ -138,7 +138,7 @@ pub fn status(app: &mut CadApp, ui: &mut egui::Ui, right: f32, cy: f32) -> f32 { let g = p.layout_no_wrap(text, crate::theme::small(), if vp.locked { t.text_dim } else { t.text }); let w = g.size().x + 22.0; let sr = Rect::from_min_max(pos2(lr.left() - 4.0 - w, cy - 9.0), pos2(lr.left() - 4.0, cy + 9.0)); - let sresp = ui.interact(sr, ui.id().with("vp_scale"), Sense::click()); + let sresp = ui.interact(sr, ui.scope_id().with("vp_scale"), Sense::click()); p.rect_filled(sr, 3.0, if sresp.hovered() { t.control_hover } else { t.control.gamma_multiply(0.5) }); p.galley(pos2(sr.left() + 6.0, sr.center().y - g.size().y / 2.0), g, t.text); let (ax, ay) = (sr.right() - 9.0, sr.center().y); diff --git a/crates/ui-egui/src/xrefs.rs b/crates/ui-egui/src/xrefs.rs index fb9cb9ef1..51a21834f 100644 --- a/crates/ui-egui/src/xrefs.rs +++ b/crates/ui-egui/src/xrefs.rs @@ -37,19 +37,10 @@ pub fn route(app: &mut CadApp, id: &str, params: &Value) -> Option Result { - let panel = Panel::ExternalRefs; - let first = !app.ui.docking.layout.contains(&panel) && !app.ui.docking.hidden.iter().any(|(p, _)| *p == panel); - let r = crate::docking::command(app, false, &json!({ "operation": "open", "panel": panel.id() }))?; - if first && app.ui.docking.layout.contains(&Panel::Layers) { - let moved = json!({ "operation": "move", "panel": panel.id(), "target": Panel::Layers.id(), "zone": "bottom" }); - if let Ok(r) = crate::docking::command(app, false, &moved) { - return Ok(r); - } - } - Ok(r) + crate::docking::command(app, false, &json!({ "operation": "open", "panel": Panel::ExternalRefs.id() })) } /// A picked file inside the host drawing's folder is attached by its relative path, so the @@ -77,7 +68,7 @@ struct Row { fn rows(app: &CadApp, ui: &egui::Ui) -> Vec { let Ok(st) = app.session.state() else { return Vec::new() }; let key = (st.uid, st.revision); - let id = egui::Id::new("xref-rows"); + let id = egui::Id::unique("xref-rows"); if let Some((k, rows)) = ui.data(|d| d.get_temp::<((u64, u64), Vec)>(id)) && k == key { @@ -129,8 +120,8 @@ enum Act { pub fn section(app: &mut CadApp, ui: &mut egui::Ui) { let t = crate::theme::Tokens::get(); let rows = rows(app, ui); - let sel_id = egui::Id::new("xref-selected"); - let path_id = egui::Id::new("xref-path-edit"); + let sel_id = egui::Id::unique("xref-selected"); + let path_id = egui::Id::unique("xref-path-edit"); let mut selected: Option = ui.data(|d| d.get_temp(sel_id)); if !selected.as_ref().is_some_and(|s| rows.iter().any(|r| &r.name == s)) { selected = rows.first().map(|r| r.name.clone()); @@ -245,10 +236,10 @@ fn details(ui: &mut egui::Ui, r: &Row, path_id: egui::Id, act: &mut Option) ui.add_space(4.0); ui.horizontal(|ui| { ui.add_space(8.0); - ui.label(crate::tl!("Saved Path")); + let label = ui.label(crate::tl!("Saved Path")); let mut text: String = ui.data(|d| d.get_temp(path_id)).unwrap_or_else(|| r.path.clone()); let w = (ui.available_width() - 70.0).max(60.0); - let edit = ui.add(egui::TextEdit::singleline(&mut text).desired_width(w)); + let edit = ui.add(egui::TextEdit::singleline(&mut text).desired_width(w)).labelled_by(label.id); let apply = ui.add_enabled(!text.trim().is_empty() && text != r.path, egui::Button::new(crate::tl!("Apply"))).clicked() || (edit.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) && text != r.path); if apply { diff --git a/crates/ui-egui/tests/accessibility_names.rs b/crates/ui-egui/tests/accessibility_names.rs new file mode 100644 index 000000000..2b5ff8cef --- /dev/null +++ b/crates/ui-egui/tests/accessibility_names.rs @@ -0,0 +1,100 @@ +//! Semantic accessibility: controls expose an accessible name, not only a placeholder, and the +//! AccessKit root carries the language the interface is shown in. +//! +//! `hint_text` becomes an AccessKit placeholder, so a screen reader sees an unnamed text input +//! unless the widget is associated with a visible label (`labelled_by`) or given a name. + +use cadcraft_engine::Session; +use cadcraft_ui_egui::{CadApp, Services, i18n, menus}; +use egui::accesskit::Role; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use serde_json::json; + +/// A 1400×900 kittest harness whose frame runs `logic` and then `ui` on the supplied app. +fn harness(app: CadApp) -> Harness<'static, CadApp> { + Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_ui_state( + |ui, app: &mut CadApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ) +} + +/// Every editable text input must advertise a non-empty accessible name: its own label or the +/// visible label it is associated with. A placeholder alone is not a name. +fn unnamed_text_inputs(h: &Harness<'_, CadApp>) -> Vec { + let text_inputs = h.query_all_by_role(Role::TextInput).chain(h.query_all_by_role(Role::MultilineTextInput)); + text_inputs + .filter(|node| { + let node = node.accesskit_node(); + node.label().as_deref().is_none_or(str::is_empty) && node.labelled_by().next().is_none() + }) + .map(|node| { + let value = node.accesskit_node().value().unwrap_or_default().to_string(); + let placeholder = node.accesskit_node().placeholder().unwrap_or_default().to_string(); + format!("value={value:?} placeholder={placeholder:?}") + }) + .collect() +} + +/// The Options ▸ Files autosave path field is labelled by its visible row label, and no text input in the dialog is unnamed. +#[test] +fn options_autosave_path_has_a_semantic_label() { + let mut app = CadApp::new(Session::new(), Services::default()); + menus::activate(&mut app, "options"); + // The path field lives on Open and Save, not on the default Display tab. + app.ui.dialog = Some("options:files".into()); + let h = harness(app); + let name = "Automatic save file location"; + let field = h.get_by_role_and_label(Role::TextInput, name); + let labelled_by: Vec<_> = field.accesskit_node().labelled_by().map(|node| node.id()).collect(); + assert_eq!(labelled_by.len(), 1, "the field is labelled by the visible row label"); + let label = h.get_all_by_role(Role::Label).find(|node| node.accesskit_node().id() == labelled_by[0]).expect("the referenced label node"); + assert_eq!(label.accesskit_node().value().as_deref(), Some(name)); + let unnamed = unnamed_text_inputs(&h); + assert!(unnamed.is_empty(), "unnamed text inputs in OPTIONS: {unnamed:?}"); +} + +/// Every interactive control on every Options tab is named by the visible label beside it. The +/// harness's own strict accessibility check already runs while it settles; this test makes the +/// tab-by-tab coverage explicit and reports the offending tab in its own assertion. +#[test] +fn every_options_tab_names_its_controls() { + for tab in ["display", "files", "drafting", "selection", "preferences", "profiles"] { + let mut app = CadApp::new(Session::new(), Services::default()); + menus::activate(&mut app, "options"); + app.ui.dialog = Some(format!("options:{tab}")); + let h = harness(app); + let unnamed = h.unnamed_widgets(); + assert!(unnamed.is_empty(), "unnamed controls on Options {tab}: {unnamed:?}"); + } +} + +/// The Parameters Manager's expression fields are the concrete unnamed fields the review found; +/// each one is named by the parameter's name label in the same row. +#[test] +fn parameters_manager_fields_have_a_semantic_label() { + let mut app = CadApp::new(Session::new(), Services::default()); + app.run("parameters", json!({ "name": "user1", "expr": "4" })).unwrap(); + app.run("parameters", json!({ "name": "user2", "expr": "user1*2" })).unwrap(); + app.ui.dialog = Some("parameters".into()); + let h = harness(app); + let unnamed = unnamed_text_inputs(&h); + assert!(unnamed.is_empty(), "unnamed text inputs in the Parameters Manager: {unnamed:?}"); + let labels: Vec = h.query_all_by_role(Role::TextInput).filter_map(|node| node.accesskit_node().label()).collect(); + assert!(labels.iter().any(|label| label == "user1"), "the first expression field is named by its parameter: {labels:?}"); + assert!(labels.iter().any(|label| label == "user2"), "the second expression field is named by its parameter: {labels:?}"); +} + +/// The AccessKit root names the language the interface is actually shown in, so a screen reader +/// switches voice instead of reading a translated interface with the OS default. +#[test] +fn accesskit_root_carries_the_interface_language() { + let mut app = CadApp::new(Session::new(), Services::default()); + app.ui.interface_language = i18n::Preference::German; + let h = harness(app); + assert_eq!(h.state().language(), "de"); + assert_eq!(h.root().accesskit_node().data().language(), Some("de"), "the root node carries the shown language"); +} diff --git a/crates/ui-egui/tests/it/animation_effects.rs b/crates/ui-egui/tests/it/animation_effects.rs index 926b084e5..ba18afb72 100644 --- a/crates/ui-egui/tests/it/animation_effects.rs +++ b/crates/ui-egui/tests/it/animation_effects.rs @@ -23,8 +23,8 @@ fn frame( let mut seen = None; let mut out = ctx.run_ui(raw, |ui| { app.logic(ui.ctx()); - seen = Some(look(ui.ctx())); app.ui(ui); + seen = Some(look(ui.ctx())); }); out.textures_delta.clear(); // no renderer here (seen.unwrap(), out.shapes) @@ -101,6 +101,7 @@ fn wheel_scrolling_glides_only_while_animation_effects_show() { let notches = || { vec![egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Line, + source: egui::MouseWheelSource::Unknown, delta: egui::vec2(0.0, -3.0), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::NONE, @@ -130,7 +131,13 @@ fn wheel_zoom(app: &mut CadApp, modifiers: egui::Modifiers) -> Vec { frame(app, &ctx, vec![egui::Event::PointerMoved(over)], |_| ()); } let before = height(app); - let notch = egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Line, delta: egui::vec2(0.0, 1.0), phase: egui::TouchPhase::Move, modifiers }; + let notch = egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: egui::vec2(0.0, 1.0), + phase: egui::TouchPhase::Move, + source: egui::MouseWheelSource::Unknown, + modifiers, + }; let mut events = vec![notch]; (0..40) .map(|_| { diff --git a/crates/ui-egui/tests/it/chrome_geometry.rs b/crates/ui-egui/tests/it/chrome_geometry.rs index ab9c5b8fa..320cabf37 100644 --- a/crates/ui-egui/tests/it/chrome_geometry.rs +++ b/crates/ui-egui/tests/it/chrome_geometry.rs @@ -1,4 +1,5 @@ -//! The real app's painted geometry at native and compact window sizes. +//! The real app's painted geometry at native and compact window sizes. Layout tabs stay anchored +//! to the status bar independently of palette visibility and docking, and remain interactive. use cadcraft_engine::Session; use cadcraft_geom::Vec2; @@ -83,6 +84,57 @@ fn status_labels_never_overlap_at_standard_and_compact_sizes() { assert!(overlaps.is_empty(), "{}", overlaps.join("\n")); } +#[test] +fn layout_tabs_stay_anchored_when_toolsets_toggle_resize_or_move() { + for scale in [1.0, 2.0] { + let mut h = harness(1200.0, 760.0, scale, false); + let tab_rects = + |h: &Harness<'_, CadApp>| ["Model", "Layout1", "Layout2"].map(|name| h.get_by_role_and_label(egui::accesskit::Role::Tab, name).rect()); + let original = tab_rects(&h); + for (command, params) in [ + ("ui.toggle.toolsets", serde_json::json!({})), + ("ui.toggle.toolsets", serde_json::json!({})), + ("ui.dock", serde_json::json!({"operation":"resizeSplit", "path":[], "size":{"FixedFirst":360.0}})), + ("ui.dock", serde_json::json!({"operation":"float", "panel":"toolSets", "rect":[400,120,260,480]})), + ("ui.dock", serde_json::json!({"operation":"move", "panel":"toolSets", "target":"canvas", "zone":"right"})), + ("ui.toggle.toolsets", serde_json::json!({})), + ("ui.toggle.toolsets", serde_json::json!({})), + ("ui.dock.reset", serde_json::json!({})), + ] { + h.state_mut().run(command, params.clone()).unwrap(); + h.run_steps(5); + assert_eq!(tab_rects(&h), original, "{command} {params} at scale {scale} must not move the tabs"); + } + // A fixed but oversized blank reservation would stop the jump without fixing the layout. + let gap = original[0].left() - h.get_by_label("Layout list").rect().right(); + assert!((0.0..=16.0).contains(&gap), "tabs must follow the layout actions, not reserve palette space: {gap}"); + } +} + +#[test] +fn layout_tabs_and_add_button_work_with_toolsets_shown_or_hidden() { + for visible in [true, false] { + let mut h = harness(1400.0, 760.0, 1.0, false); + h.state_mut().run("ui.toggle.toolsets", serde_json::json!({"on":visible})).unwrap(); + h.run_steps(5); + h.get_by_role_and_label(egui::accesskit::Role::Tab, "Layout1").click(); + h.run_steps(3); + assert_eq!(h.state().session.state().unwrap().space, cadcraft_engine::doc::Space::Paper("Layout1".into())); + h.get_by_role_and_label(egui::accesskit::Role::Tab, "Model").click(); + h.run_steps(3); + assert_eq!(h.state().session.state().unwrap().space, cadcraft_engine::doc::Space::Model); + // There are two add buttons; exercise the one between Model and the paper-layout tabs. + let model = h.get_by_role_and_label(egui::accesskit::Role::Tab, "Model").rect(); + h.get_all_by_label("New layout").find(|node| node.rect().left() > model.right()).unwrap().click(); + h.run_steps(3); + assert_eq!(h.state().session.state().unwrap().space, cadcraft_engine::doc::Space::Paper("Layout3".into())); + assert_eq!(h.state().session.doc().unwrap().layouts.len(), 3); + h.get_by_role_and_label(egui::accesskit::Role::Tab, "Layout2").click(); + h.run_steps(3); + assert_eq!(h.state().session.state().unwrap().space, cadcraft_engine::doc::Space::Paper("Layout2".into())); + } +} + #[test] fn compact_toolbar_overflow_keeps_help_reachable() { let mut h = harness(900.0, 560.0, 1.0, false); diff --git a/crates/ui-egui/tests/it/docking_workflows.rs b/crates/ui-egui/tests/it/docking_workflows.rs index 1e3ee118c..c457052ff 100644 --- a/crates/ui-egui/tests/it/docking_workflows.rs +++ b/crates/ui-egui/tests/it/docking_workflows.rs @@ -1,7 +1,8 @@ //! Panel moves use the real app shell, retain commands, and survive a complete UI-state reload. use cadcraft_engine::Session; -use cadcraft_ui_egui::{CadApp, Services, UiState, docking::Panel}; +use cadcraft_ui_egui::{CadApp, Services, UiState, docking::Panel, menus}; +use craft_ui::docking::{Column, Divider, DockArea, Hit, Layout, Node, Side}; use egui::{Event, Modifiers, PointerButton, vec2}; use egui_kittest::Harness; use egui_kittest::kittest::Queryable; @@ -42,6 +43,110 @@ fn drag(harness: &mut Harness<'_, CadApp>, from: egui::Pos2, to: egui::Pos2) { harness.run_steps(4); } +fn area() -> egui::Id { + egui::Id::unique("cadcraft-docking") +} + +/// The rect the dock area drew last frame for `hit`. +fn hit(h: &Harness<'_, CadApp>, hit: Hit) -> egui::Rect { + let hits = DockArea::::hit_rects(&h.ctx, area()); + hits.iter().find(|(drawn, _)| *drawn == hit).map(|(_, rect)| *rect).unwrap_or_else(|| panic!("{hit:?} is not drawn: {hits:?}")) +} + +/// The columns of the dock on `side`, from the window edge inwards. +fn columns(layout: &Layout, side: Side) -> Vec> { + let mut nodes: Vec<&Node> = layout.root.iter().collect(); + while let Some(node) = nodes.pop() { + match node { + Node::Split { first, second, .. } => nodes.extend([&**first, &**second]), + Node::Dock { side: s, columns } if *s == side => return columns.clone(), + _ => {} + } + } + Vec::new() +} + +/// The panels of each group of each column of the dock on `side`. +fn dock_panels(layout: &Layout, side: Side) -> Vec>> { + columns(layout, side).iter().map(|column| column.groups.iter().map(|group| group.panels.clone()).collect()).collect() +} + +/// The tab groups outside the docks: the frames of the split tree. +fn frames(layout: &Layout) -> Vec> { + let mut out = Vec::new(); + let mut nodes: Vec<&Node> = layout.root.iter().collect(); + while let Some(node) = nodes.pop() { + match node { + Node::Split { first, second, .. } => nodes.extend([&**first, &**second]), + Node::Tabs { panels, .. } => out.push(panels.clone()), + Node::Stack { entries } => out.push(entries.iter().map(|entry| entry.panel).collect()), + _ => {} + } + } + out +} + +#[test] +fn default_layout_is_tool_sets_left_and_the_palettes_right_in_dock_columns() { + use cadcraft_ui_egui::docking::{PALETTES_WIDTH, TOOL_SETS_WIDTH, default_workspace, validate}; + let workspace = default_workspace(); + validate(&workspace).unwrap(); + assert!(workspace.hidden.is_empty() && workspace.layout.floating.is_empty()); + assert_eq!(frames(&workspace.layout), [[Panel::Canvas]]); + let start = columns(&workspace.layout, Side::Start); + let end = columns(&workspace.layout, Side::End); + assert_eq!(dock_panels(&workspace.layout, Side::Start), [[[Panel::ToolSets]]]); + assert_eq!(dock_panels(&workspace.layout, Side::End), [vec![vec![Panel::Layers], vec![Panel::Properties]]]); + assert_eq!((start[0].width, end[0].width), (TOOL_SETS_WIDTH, PALETTES_WIDTH)); + assert!(start.iter().chain(&end).all(|column| !column.iconic)); + assert!(start.iter().chain(&end).flat_map(|column| &column.groups).all(|group| group.height.is_none() && !group.minimized)); + assert!(!workspace.layout.contains(&Panel::QuickCalc) && !workspace.layout.contains(&Panel::ExternalRefs)); + + // Drawn: Tool Sets left of the drawing, Layers above Properties right of it, Layers at its + // own height and Properties taking the rest of the column. + let h = harness(None, 1280.0, 1.0); + let drawing = h.state().canvas.rect.expect("the drawing view is drawn"); + let tools = hit(&h, Hit::Tab(Panel::ToolSets)); + let layers = hit(&h, Hit::Tab(Panel::Layers)); + let properties = hit(&h, Hit::Tab(Panel::Properties)); + assert!(tools.right() <= drawing.left() + 1.0, "{tools:?} {drawing:?}"); + assert!(layers.left() >= drawing.right() - 1.0 && properties.left() >= drawing.right() - 1.0, "{layers:?} {properties:?} {drawing:?}"); + let seam = hit(&h, Hit::Divider(Divider::Group(Panel::Layers))); + assert!((seam.center().y - layers.bottom() - 280.0).abs() < 8.0, "{layers:?} {seam:?}"); + assert!(properties.top() > seam.top()); +} + +#[test] +fn palette_icons_are_painted_in_code_and_shown_in_an_iconic_column() { + use cadcraft_ui_egui::docking::Panels; + let mut app = CadApp::new(Session::default(), Services::default()); + let ctx = egui::Context::default(); + let mut paint = |panel: Panel| { + let mut painted = false; + let mut content = Panels { app: &mut app }; + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { + let painter = ui.ctx().layer_painter(egui::LayerId::background()); + let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(20.0, 20.0)); + painted = craft_ui::docking::DockContent::paint_icon(&mut content, &painter, rect, &panel, egui::Color32::RED); + }); + output.textures_delta.clear(); + (painted, output.shapes.len()) + }; + for panel in [Panel::ToolSets, Panel::QuickCalc, Panel::Layers, Panel::Properties, Panel::ExternalRefs] { + let (painted, shapes) = paint(panel); + assert!(painted && shapes > 0, "{panel:?}: {painted} {shapes}"); + } + assert_eq!(paint(Panel::Canvas), (false, 0)); + + let mut h = harness(None, 1280.0, 1.0); + h.state_mut().run("ui.dock", json!({"action":{"SetIconic":{"panel":"layers","iconic":true}}})).unwrap(); + h.run_steps(4); + let drawing = h.state().canvas.rect.unwrap(); + let layers = hit(&h, Hit::Icon(Panel::Layers)); + let properties = hit(&h, Hit::Icon(Panel::Properties)); + assert!(layers.left() >= drawing.right() - 1.0 && layers.top() < properties.top(), "{layers:?} {properties:?}"); +} + #[test] fn real_panel_drag_float_close_reopen_and_full_workspace_reload() { let mut harness = harness(None, 1280.0, 1.0); @@ -52,19 +157,19 @@ fn real_panel_drag_float_close_reopen_and_full_workspace_reload() { assert_eq!(location.anchor, Some(Panel::ToolSets)); harness.get_by_role_and_label(egui::accesskit::Role::Tab, "Layers").click_button(PointerButton::Secondary); harness.run_steps(3); - harness.get_by_label("Float panel").click(); + harness.get_by_label("Float Panel").click(); harness.run_steps(5); - let rect = harness.state().ui.docking.layout.floating.iter().find(|group| group.panels.contains(&Panel::Layers)).unwrap().rect; + let rect = harness.state().ui.docking.layout.floating.iter().find(|group| group.contains(&Panel::Layers)).unwrap().rect; harness.get_by_role_and_label(egui::accesskit::Role::Tab, "Layers").click_button(PointerButton::Secondary); harness.run_steps(3); - harness.get_by_label("Close panel").click(); + harness.get_by_label("Close Panel").click(); harness.run_steps(4); assert!(!harness.state().ui.docking.layout.contains(&Panel::Layers)); harness.state_mut().run("ui.toggle.palettes", json!({"on": false})).unwrap(); harness.run_steps(3); harness.state_mut().run("ui.toggle.palettes", json!({"on": true})).unwrap(); harness.run_steps(4); - assert_eq!(harness.state().ui.docking.layout.floating.iter().find(|group| group.panels.contains(&Panel::Layers)).unwrap().rect, rect); + assert_eq!(harness.state().ui.docking.layout.floating.iter().find(|group| group.contains(&Panel::Layers)).unwrap().rect, rect); let saved = serde_json::to_string(&harness.state().ui).unwrap(); let expected = serde_json::to_value(&harness.state().ui.docking).unwrap(); let mut reloaded = self::harness(Some(serde_json::from_str(&saved).unwrap()), 1280.0, 1.0); @@ -88,8 +193,17 @@ fn invalid_requests_leave_document_and_panel_layout_unchanged() { json!({"operation":"move", "panel":"toolSets", "target":"canvas", "zone":"center"}), json!({"operation":"float", "panel":"layers", "rect":[0,0,-1,200]}), json!({"operation":"move", "panel":"missing", "target":"canvas", "zone":"right"}), + json!({"operation":"float", "panel":"layers", "rect":[1e39,0,300,200]}), + json!({"operation":"resizeStack", "panel":"layers", "height":1e39}), + json!({"operation":"resizeSplit", "path":[true,true,true,true,true,true,true,true], "size":{"Ratio":0.5}}), + json!({"operation":"move", "panel":"layers", "target":"properties", "zone":"right", "before":null}), + json!({"operation":"spin", "panel":"layers"}), + json!({"action":{"Float":{"panel":"canvas","rect":[0,0,100,100]}}}), + json!({"action":{"MovePanel":{"panel":"canvas","to":{"NewDock":{"side":"Start"}}}}}), + json!({"action":{"Move":{"panel":"layers","anchor":"canvas","placement":{"Tab":{"before":null}}}}}), + json!(null), ] { - assert!(harness.state_mut().run("ui.dock", params).is_err()); + assert!(harness.state_mut().run("ui.dock", params.clone()).is_err(), "{params}"); assert_eq!(serde_json::to_value(&harness.state().ui.docking).unwrap(), before); assert_eq!(harness.state().session.doc().unwrap(), &drawing); } @@ -124,75 +238,146 @@ fn docking_renders_default_float_and_redocked_at_normal_and_narrow_sizes() { } #[test] -fn automation_reaches_split_stack_resize_and_tab_reorder_through_the_same_handler() { - use craft_ui::{docking::Action, layout::SplitSize}; +fn automation_reaches_column_and_group_resizes_tab_reorder_and_minimize_through_the_same_handler() { + use craft_ui::docking::Action; let mut h = harness(None, 1280.0, 1.0); - h.state_mut() - .run( - "ui.dock", - json!({"action":serde_json::to_value(Action::::ResizeSplit { path: vec![], size: SplitSize::FixedFirst(280.0) }).unwrap()}), - ) - .unwrap(); - h.state_mut().run("ui.dock", json!({"operation":"setStackOpen","panel":"layers","open":false})).unwrap(); - h.state_mut().run("ui.dock", json!({"operation":"resizeStack","panel":"layers","height":160.0})).unwrap(); - assert_eq!(h.state().ui.docking.layout.location(&Panel::Layers).unwrap().stack, Some((false, Some(160.0)))); + let act = |h: &mut Harness<'_, CadApp>, action: Action| { + h.state_mut().run("ui.dock", json!({"action": serde_json::to_value(action).unwrap()})).unwrap(); + }; + act(&mut h, Action::ResizeColumn { panel: Panel::ToolSets, width: 280.0 }); + act(&mut h, Action::ResizeGroup { panel: Panel::Layers, height: Some(160.0) }); + let layout = &h.state().ui.docking.layout; + assert_eq!(columns(layout, Side::Start)[0].width, 280.0); + assert_eq!(columns(layout, Side::End)[0].groups[0].height, Some(160.0)); h.state_mut().run("ui.dock", json!({"operation":"move","panel":"layers","target":"properties","zone":"center","before":null})).unwrap(); + assert_eq!(dock_panels(&h.state().ui.docking.layout, Side::End), [[[Panel::Properties, Panel::Layers]]]); + act(&mut h, Action::SetMinimized { panel: Panel::Layers, minimized: true }); h.run_steps(4); - let panels = h.state().ui.docking.layout.panels(); - assert!(panels.iter().position(|p| **p == Panel::Properties).unwrap() < panels.iter().position(|p| **p == Panel::Layers).unwrap()); - assert_eq!(h.state().ui.docking.layout.location(&Panel::ToolSets).unwrap().size, Some(SplitSize::FixedFirst(280.0))); + assert!(columns(&h.state().ui.docking.layout, Side::End)[0].groups[0].minimized); + assert_eq!(columns(&h.state().ui.docking.layout, Side::Start)[0].width, 280.0); } #[test] -fn stack_boundary_resizes_by_pointer_and_keyboard_without_changing_the_drawing() { +fn group_divider_resizes_by_pointer_and_keyboard_without_changing_the_drawing() { let mut h = harness(None, 1280.0, 1.0); let drawing = h.state().session.doc().unwrap().clone(); - let center = h - .get_all_by_role_and_label(egui::accesskit::Role::Splitter, "Resize panels") - .into_iter() - .find(|node| node.rect().width() > node.rect().height()) - .unwrap() - .rect() - .center(); + let layers_height = |h: &Harness<'_, CadApp>| columns(&h.state().ui.docking.layout, Side::End)[0].groups[0].height; + let center = hit(&h, Hit::Divider(Divider::Group(Panel::Layers))).center(); drag(&mut h, center, center + vec2(0.0, -45.0)); - let height = h.state().ui.docking.layout.location(&Panel::Layers).unwrap().stack.unwrap().1.unwrap(); - h.get_all_by_role_and_label(egui::accesskit::Role::Splitter, "Resize panels") - .into_iter() - .find(|node| node.rect().width() > node.rect().height()) - .unwrap() - .focus(); + let height = layers_height(&h).expect("the drag sets the Layers group's height"); + h.ctx.memory_mut(|memory| memory.request_focus(DockArea::::hit_id(area(), &Hit::Divider(Divider::Group(Panel::Layers))))); h.run_steps(2); h.key_press(egui::Key::ArrowDown); h.run_steps(4); - let after = h.state().ui.docking.layout.location(&Panel::Layers).unwrap().stack.unwrap().1.unwrap(); - assert!(after > height, "keyboard resize must reach the same panel command handler"); + let after = layers_height(&h).unwrap(); + assert!(after > height, "keyboard resize must reach the same panel command handler: {height} {after}"); assert_eq!(h.state().session.doc().unwrap(), &drawing); } #[test] fn grouped_restore_preserves_order_and_rectangle_in_both_close_orders_after_reload() { + use cadcraft_ui_egui::docking::{default_workspace, permissions, reveal_panel}; use craft_ui::docking::{Action, Placement}; for order in [[Panel::Layers, Panel::Properties], [Panel::Properties, Panel::Layers]] { - let mut workspace = cadcraft_ui_egui::docking::Workspace::default(); + let mut workspace = default_workspace(); let rect = [50.0, 60.0, 320.0, 400.0]; - workspace.apply(Action::Float { panel: Panel::Layers, rect }).unwrap(); + workspace.apply(Action::Float { panel: Panel::Layers, rect }, permissions).unwrap(); workspace - .apply(Action::Move { panel: Panel::Properties, anchor: Panel::Layers, placement: Placement::Tab { before: Some(Panel::Layers) } }) + .apply( + Action::Move { panel: Panel::Properties, anchor: Panel::Layers, placement: Placement::Tab { before: Some(Panel::Layers) } }, + permissions, + ) .unwrap(); - let expected = workspace.layout.floating[0].panels.clone(); + let expected: Vec = workspace.layout.floating[0].panels().copied().collect(); for panel in order { - workspace.set_visible(panel, false).unwrap(); + workspace.set_visible(panel, false, None, permissions).unwrap(); } let saved = serde_json::to_string(&workspace).unwrap(); workspace = serde_json::from_str(&saved).unwrap(); - workspace.reveal_panel(Panel::Properties).unwrap(); - workspace.reveal_panel(Panel::Layers).unwrap(); + reveal_panel(&mut workspace, Panel::Properties).unwrap(); + reveal_panel(&mut workspace, Panel::Layers).unwrap(); assert_eq!(workspace.layout.floating.len(), 1); - assert_eq!(workspace.layout.floating[0].panels, expected); + assert_eq!(workspace.layout.floating[0].panels().copied().collect::>(), expected); assert_eq!(workspace.layout.floating[0].rect, rect); } } +#[test] +fn palettes_off_and_on_reopen_a_floating_group_as_one_window() { + let mut h = harness(None, 1280.0, 1.0); + h.state_mut().run("ui.dock", json!({"operation":"float","panel":"layers","rect":[400,250,300,350]})).unwrap(); + h.state_mut().run("ui.dock", json!({"operation":"move","panel":"properties","target":"layers","zone":"center"})).unwrap(); + h.state_mut().run("ui.dock", json!({"operation":"close","panel":"properties"})).unwrap(); + h.state_mut().run("ui.toggle.palettes", json!({"on": false})).unwrap(); + h.run_steps(3); + h.state_mut().run("ui.toggle.palettes", json!({"on": true})).unwrap(); + h.run_steps(3); + let floating = &h.state().ui.docking.layout.floating; + assert_eq!(floating.len(), 1, "{floating:?}"); + assert!(floating[0].contains(&Panel::Layers) && floating[0].contains(&Panel::Properties), "{floating:?}"); +} + +#[test] +fn palettes_off_and_on_restore_the_right_dock_column() { + let mut h = harness(None, 1280.0, 1.0); + let before = h.state().ui.docking.layout.clone(); + h.state_mut().run("ui.toggle.palettes", json!({"on": false})).unwrap(); + h.run_steps(3); + assert!(!h.state().ui.docking.layout.contains(&Panel::Layers) && !h.state().ui.docking.layout.contains(&Panel::Properties)); + h.state_mut().run("ui.toggle.palettes", json!({"on": true})).unwrap(); + h.run_steps(3); + let layout = &h.state().ui.docking.layout; + assert_eq!(dock_panels(layout, Side::End), dock_panels(&before, Side::End)); + assert_eq!(columns(layout, Side::End)[0].width, columns(&before, Side::End)[0].width); +} + +#[test] +fn window_menu_reopens_closed_and_new_palettes_in_their_dock_groups() { + use cadcraft_ui_egui::docking::{PALETTES_WIDTH, TOOL_SETS_WIDTH}; + let mut h = harness(None, 1280.0, 1.0); + // A closed palette comes back where it was. + h.state_mut().run("ui.dock", json!({"operation":"close","panel":"layers"})).unwrap(); + h.run_steps(3); + menus::activate(h.state_mut(), "ui.panel.layers"); + h.run_steps(3); + assert_eq!(dock_panels(&h.state().ui.docking.layout, Side::End), [vec![vec![Panel::Layers], vec![Panel::Properties]]]); + // Tool Sets turned off and on in the Window menu comes back in the left dock at its width. + menus::activate(h.state_mut(), "ui.toggle.toolsets"); + h.run_steps(3); + assert!(!h.state().ui.docking.layout.contains(&Panel::ToolSets)); + menus::activate(h.state_mut(), "ui.toggle.toolsets"); + h.run_steps(3); + let layout = &h.state().ui.docking.layout; + assert_eq!(dock_panels(layout, Side::Start), [[[Panel::ToolSets]]]); + assert_eq!(columns(layout, Side::Start)[0].width, TOOL_SETS_WIDTH); + // After Reset nothing has a recorded place: External References opens below Properties. + h.state_mut().run("ui.dock.reset", json!({})).unwrap(); + h.state_mut().run("xref", serde_json::Value::Null).unwrap(); + h.run_steps(3); + assert_eq!(dock_panels(&h.state().ui.docking.layout, Side::End), [vec![vec![Panel::Layers], vec![Panel::Properties], vec![Panel::ExternalRefs]]]); + // Tool Sets with no recorded place and no left dock makes one at its width. + let mut workspace = cadcraft_ui_egui::docking::default_workspace(); + workspace.apply(craft_ui::docking::Action::Close { panel: Panel::ToolSets }, cadcraft_ui_egui::docking::permissions).unwrap(); + workspace.hidden.clear(); + h.state_mut().ui.docking = workspace; + h.state_mut().ui.show_toolsets = false; + menus::activate(h.state_mut(), "ui.toggle.toolsets"); + h.run_steps(3); + let layout = &h.state().ui.docking.layout; + assert_eq!(dock_panels(layout, Side::Start), [[[Panel::ToolSets]]]); + assert_eq!(columns(layout, Side::Start)[0].width, TOOL_SETS_WIDTH); + // With the palettes off, External References makes a new right dock at the palettes' width. + h.state_mut().run("ui.dock.reset", json!({})).unwrap(); + h.state_mut().run("ui.toggle.palettes", json!({"on": false})).unwrap(); + h.run_steps(3); + h.state_mut().run("xref", serde_json::Value::Null).unwrap(); + h.run_steps(3); + let layout = &h.state().ui.docking.layout; + assert_eq!(dock_panels(layout, Side::End), [[[Panel::ExternalRefs]]]); + assert_eq!(columns(layout, Side::End)[0].width, PALETTES_WIDTH); + assert_eq!(frames(layout), [[Panel::Canvas]]); +} + #[test] fn explicit_properties_open_restores_closed_panel_and_activates_inactive_tab() { let mut harness = harness(None, 1000.0, 1.0); @@ -206,8 +391,7 @@ fn explicit_properties_open_restores_closed_panel_and_activates_inactive_tab() { harness.run_steps(3); let location = harness.state().ui.docking.layout.location(&Panel::Properties).unwrap(); assert_eq!(location.anchor, Some(Panel::Layers)); - let group = harness.state().ui.docking.layout.floating.iter().find(|group| group.panels.contains(&Panel::Properties)).unwrap(); - assert_eq!(group.panels.get(group.active), Some(&Panel::Properties)); + assert_eq!(harness.state().ui.docking.layout.panel_state(&Panel::Properties), craft_ui::docking::PanelState::Front); } #[test] @@ -251,23 +435,16 @@ fn properties_typed_in_rendered_command_line_reopens_and_selects_actual_workspac let drawing = h.state().session.doc().unwrap().clone(); let tools = h.state().ui.docking.layout.location(&Panel::ToolSets).unwrap(); type_command(&mut h, "PROPERTIES"); - assert!(h.state().ui.docking.layout.contains(&Panel::Properties)); - assert!(h.state().ui.docking.layout.contains(&Panel::Layers)); - let restored_tools = h.state().ui.docking.layout.location(&Panel::ToolSets).unwrap(); - assert_eq!(restored_tools.anchor, tools.anchor); - assert_eq!(restored_tools.placement, tools.placement); - assert_eq!(restored_tools.size, tools.size); - assert_eq!(restored_tools.floating, tools.floating); - assert_eq!(restored_tools.stack, tools.stack); - assert!(restored_tools.sibling.contains(&Panel::Properties)); + assert_eq!(dock_panels(&h.state().ui.docking.layout, Side::End), [vec![vec![Panel::Layers], vec![Panel::Properties]]]); + assert_eq!(h.state().ui.docking.layout.location(&Panel::ToolSets).unwrap(), tools); h.state_mut().run("ui.dock", json!({"operation":"float","panel":"layers","rect":[400,250,300,350]})).unwrap(); h.state_mut().run("ui.dock", json!({"operation":"move","panel":"properties","target":"layers","zone":"center"})).unwrap(); h.state_mut().run("ui.dock", json!({"operation":"activate","panel":"layers"})).unwrap(); h.run_steps(3); type_command(&mut h, "PROPERTIES"); - let group = h.state().ui.docking.layout.floating.iter().find(|group| group.panels.contains(&Panel::Properties)).unwrap(); - assert_eq!(group.panels.get(group.active), Some(&Panel::Properties)); + assert!(h.state().ui.docking.layout.floating.iter().any(|group| group.contains(&Panel::Properties))); + assert_eq!(h.state().ui.docking.layout.panel_state(&Panel::Properties), craft_ui::docking::PanelState::Front); assert_eq!(h.state().session.doc().unwrap(), &drawing); } @@ -278,53 +455,46 @@ fn reset_typed_in_canvas_preserves_authoritative_workspace_replacement() { h.run_steps(3); let drawing = h.state().session.doc().unwrap().clone(); type_command(&mut h, "ui.dock.reset"); - let expected = cadcraft_ui_egui::docking::Workspace::default(); + let expected = cadcraft_ui_egui::docking::default_workspace(); assert_eq!(h.state().ui.docking.layout, expected.layout); assert_eq!(h.state().ui.docking.hidden, expected.hidden); assert_eq!(h.state().session.doc().unwrap(), &drawing); } #[test] -fn canvas_reset_during_splitter_drag_discards_old_resize_and_ends_gesture() { +fn canvas_reset_during_column_drag_discards_old_resize_and_ends_gesture() { use craft_ui::docking::Action; - use craft_ui::layout::SplitSize; let mut h = harness(None, 1280.0, 1.0); - // Give the old root divider a size different from Reset's 220px ToolSets pane. + // Give the Tool Sets column a width different from Reset's. h.state_mut() - .run( - "ui.dock", - json!({"action":Action::::ResizeSplit { - path: Vec::new(), size: SplitSize::FixedFirst(280.0), - }}), - ) + .run("ui.dock", json!({"action": serde_json::to_value(Action::ResizeColumn { panel: Panel::ToolSets, width: 280.0 }).unwrap()})) .unwrap(); h.run_steps(3); let drawing = h.state().session.doc().unwrap().clone(); - let splitter = egui::Id::new("cadcraft-docking").with(("split", Vec::::new())); - let from = h.ctx.read_response(splitter).expect("rendered root divider").rect.center(); + let divider = Hit::Divider(Divider::Column(Panel::ToolSets)); + let handle = DockArea::::hit_id(area(), ÷r); + let from = hit(&h, divider).center(); h.event(Event::PointerMoved(from)); h.run_steps(1); h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); h.run_steps(1); - // Canvas is visited before the ancestor ResizeHandle. The command runs before - // that ancestor queues this frame's ResizeSplit against the old immutable tree. - assert_eq!(h.ctx.dragged_id(), Some(splitter), "actual divider owns the pointer"); + assert_eq!(h.ctx.dragged_id(), Some(handle), "the column's divider owns the pointer"); assert!(h.ctx.input(|input| input.pointer.primary_down())); - // A ResizeHandle takes keyboard focus on press. Release only that keyboard focus - // so the real Canvas command line can submit while the pointer remains captured. - h.ctx.memory_mut(|memory| memory.surrender_focus(splitter)); + // A divider takes keyboard focus on press. Release only that keyboard focus so the real + // Canvas command line can submit while the pointer remains captured. + h.ctx.memory_mut(|memory| memory.surrender_focus(handle)); assert!(!h.ctx.egui_wants_keyboard_input()); let to = from + vec2(36.0, 0.0); h.event(Event::PointerMoved(to)); h.event(Event::Text("ui.dock.reset".into())); h.event(Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); h.run_steps(1); - let expected = cadcraft_ui_egui::docking::Workspace::default(); + let expected = cadcraft_ui_egui::docking::default_workspace(); assert!(h.state().cmd.buffer.is_empty(), "Canvas submitted the actual reset command"); - assert_eq!(h.state().ui.docking.layout, expected.layout, "old-frame resize must not modify Reset's tree"); + assert_eq!(h.state().ui.docking.layout, expected.layout, "old-frame resize must not modify Reset's layout"); assert_eq!(h.state().ui.docking.hidden, expected.hidden); - // Keep the physical button down: replacement must terminate the old divider gesture, - // rather than letting its stable root ID continue resizing the new workspace. + // Keep the physical button down: replacement must end the old divider gesture rather than + // letting it go on resizing the new workspace. let later = to + vec2(24.0, 0.0); h.event(Event::PointerMoved(later)); h.run_steps(2); @@ -359,3 +529,67 @@ fn collapsed_tool_sets_leave_a_visible_control_that_expands_them() { h.run_steps(4); assert!(h.state().ui.show_toolsets); } + +#[test] +fn palettes_dock_only_in_the_left_and_right_columns() { + let mut h = harness(None, 1280.0, 1.0); + let document = h.state().session.doc().unwrap().clone(); + let drawing = h.state().canvas.rect.expect("the drawing view is drawn"); + // Neither the drawing nor the bottom edge takes a drop: the palette floats there. + for to in [drawing.center(), egui::pos2(drawing.center().x, drawing.bottom() - 4.0)] { + h.state_mut().run("ui.dock.reset", json!({})).unwrap(); + h.run_steps(3); + let from = hit(&h, Hit::Tab(Panel::Layers)).center(); + drag(&mut h, from, to); + let layout = &h.state().ui.docking.layout; + assert_eq!(frames(layout), [[Panel::Canvas]], "{to:?}: {:?}", layout.root); + assert_eq!(dock_panels(layout, Side::End), [[[Panel::Properties]]], "{to:?}"); + assert!(layout.floating.iter().any(|window| window.contains(&Panel::Layers)), "{to:?}: {layout:?}"); + } + // The left window edge, where Tool Sets docks, takes the palette as a new outer column. + h.state_mut().run("ui.dock.reset", json!({})).unwrap(); + h.run_steps(3); + let column = hit(&h, Hit::ColumnBar(Panel::ToolSets)); + let from = hit(&h, Hit::Tab(Panel::Layers)).center(); + drag(&mut h, from, egui::pos2(column.left() + 3.0, drawing.center().y)); + let layout = &h.state().ui.docking.layout; + assert_eq!(dock_panels(layout, Side::Start), [[[Panel::Layers]], [[Panel::ToolSets]]], "{:?}", layout.root); + assert_eq!(frames(layout), [[Panel::Canvas]]); + // Without a left dock, the left window edge makes one. + h.state_mut().run("ui.dock.reset", json!({})).unwrap(); + h.state_mut().run("ui.toggle.toolsets", json!({"on": false})).unwrap(); + h.run_steps(4); + assert!(columns(&h.state().ui.docking.layout, Side::Start).iter().all(|column| column.groups.is_empty())); + let drawing = h.state().canvas.rect.unwrap(); + let from = hit(&h, Hit::Tab(Panel::Layers)).center(); + drag(&mut h, from, egui::pos2(drawing.left() + 3.0, drawing.center().y)); + let layout = &h.state().ui.docking.layout; + assert_eq!(dock_panels(layout, Side::Start), [[[Panel::Layers]]], "{:?}", layout.root); + assert_eq!(frames(layout), [[Panel::Canvas]]); + assert_eq!(h.state().session.doc().unwrap(), &document); +} + +#[test] +fn group_drag_bar_moves_the_whole_group_into_the_other_dock() { + let mut h = harness(None, 1280.0, 1.0); + h.state_mut().run("ui.dock", json!({"operation":"move", "panel":"layers", "target":"toolSets", "zone":"center", "before":null})).unwrap(); + h.run_steps(4); + let bar = DockArea::::hit_rects(&h.ctx, area()) + .into_iter() + .find(|(hit, _)| matches!(hit, Hit::DragBar(panel) if *panel == Panel::ToolSets || *panel == Panel::Layers)) + .map(|(_, rect)| rect) + .expect("the Tool Sets group draws a drag bar"); + // Into the middle of the Properties group, as tabs. + let properties = hit(&h, Hit::Tab(Panel::Properties)); + let column = hit(&h, Hit::ColumnBar(Panel::Properties)); + let to = egui::pos2(column.center().x, properties.bottom() + 120.0); + let document = h.state().session.doc().unwrap().clone(); + drag(&mut h, bar.center(), to); + let layout = &h.state().ui.docking.layout; + assert!(layout.floating.is_empty(), "{layout:?}"); + let end = dock_panels(layout, Side::End); + assert_eq!(end.len(), 1, "{end:?}"); + assert!(end[0].iter().any(|group| group.contains(&Panel::ToolSets) && group.contains(&Panel::Layers)), "{end:?}"); + assert_eq!(frames(layout), [[Panel::Canvas]]); + assert_eq!(h.state().session.doc().unwrap(), &document); +} diff --git a/crates/ui-egui/tests/it/hidden_command_line.rs b/crates/ui-egui/tests/it/hidden_command_line.rs index 58427f625..0da43aa42 100644 --- a/crates/ui-egui/tests/it/hidden_command_line.rs +++ b/crates/ui-egui/tests/it/hidden_command_line.rs @@ -18,7 +18,7 @@ fn call(app: &mut CadApp, ctx: &egui::Context, method: &str, params: Value) -> V fn frame(app: &mut CadApp, ctx: &egui::Context) { let mut raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1280.0, 800.0))), ..Default::default() }; - app.raw_input_hook(&mut raw); + app.raw_input_hook(ctx, &mut raw); cadcraft_ui_egui::cmdline::capture_tab(ctx, &mut raw); let mut out = ctx.run_ui(raw, |ui| { app.logic(ui.ctx()); diff --git a/crates/ui-egui/tests/it/instant_modals.rs b/crates/ui-egui/tests/it/instant_modals.rs new file mode 100644 index 000000000..7a7797803 --- /dev/null +++ b/crates/ui-egui/tests/it/instant_modals.rs @@ -0,0 +1,41 @@ +//! "Save changes?" is fully drawn, dim included, in the frame of the Close that asks it. + +use cadcraft_engine::Session; +use cadcraft_ui_egui::{CadApp, Services, menus}; +use egui::epaint::Shape; +use egui::vec2; +use egui_kittest::Harness; +use serde_json::json; + +/// The opacity of each painted text containing `needle`. +fn text_opacity(shapes: &[egui::epaint::ClippedShape], needle: &str) -> Vec { + fn walk(shape: &Shape, needle: &str, out: &mut Vec) { + match shape { + Shape::Vec(shapes) => shapes.iter().for_each(|s| walk(s, needle, out)), + Shape::Text(text) if text.galley.text().contains(needle) => out.push(text.opacity_factor), + _ => {} + } + } + let mut out = Vec::new(); + shapes.iter().for_each(|s| walk(&s.shape, needle, &mut out)); + out +} + +#[test] +fn close_prompt_is_fully_drawn_in_the_frame_of_the_menu_choice() { + let mut app = CadApp::new(Session::default(), Services::default()); + app.ui.start_tab = false; + let mut h = Harness::builder().with_size(vec2(1280.0, 800.0)).build_ui_state( + |ui, app: &mut CadApp| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }, + app, + ); + h.run_steps(5); + h.state_mut().run("line", json!({"points": [[0, 0], [10, 0]]})).unwrap(); + h.run_steps(2); + menus::activate(h.state_mut(), "close"); + h.step(); + assert_eq!(text_opacity(&h.output().shapes, "Save changes to"), [1.0]); +} diff --git a/crates/ui-egui/tests/it/lisp_files.rs b/crates/ui-egui/tests/it/lisp_files.rs index abd7f6a81..42d6d6e7e 100644 --- a/crates/ui-egui/tests/it/lisp_files.rs +++ b/crates/ui-egui/tests/it/lisp_files.rs @@ -23,7 +23,10 @@ fn a_long_routine_runs_across_frames() { let ctx = egui::Context::default(); let mut frames = 0; while app.session.lisp_running() && frames < 1000 { - let mut out = ctx.run_ui(egui::RawInput::default(), |ui| app.logic(ui.ctx())); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); out.textures_delta.clear(); // no renderer here frames += 1; } diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 2e2451579..92554f7dd 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -12,10 +12,13 @@ mod drafting_spacing; mod file_tab_titles; mod gpu_viewport_stencil; mod hidden_command_line; +mod instant_modals; mod lisp_files; mod measure_distance_snapping; mod menubar_hover; mod mspace_viewport_view; +mod native_menu_text_keys; +mod native_windows; mod options_dialog; mod parameters_rename; mod pickdrag; diff --git a/crates/ui-egui/tests/it/menubar_hover.rs b/crates/ui-egui/tests/it/menubar_hover.rs index 2a0ec4da9..798776268 100644 --- a/crates/ui-egui/tests/it/menubar_hover.rs +++ b/crates/ui-egui/tests/it/menubar_hover.rs @@ -100,7 +100,8 @@ fn clicking_the_open_title_closes_it() { fn submenus_open_and_close_with_their_menu() { let mut harness = harness(); click_title(&mut harness, "View"); - harness.get_by_label("Zoom ⏵").hover(); + // egui 0.37 names the submenu button "Zoom"; the arrow is painted, not part of the label. + harness.get_by_role_and_label(Role::Button, "Zoom").hover(); harness.run_steps(3); assert!(shown(&harness, IN_ZOOM), "pointing at Zoom opens its submenu"); diff --git a/crates/ui-egui/tests/it/native_menu_text_keys.rs b/crates/ui-egui/tests/it/native_menu_text_keys.rs new file mode 100644 index 000000000..f68e5828e --- /dev/null +++ b/crates/ui-egui/tests/it/native_menu_text_keys.rs @@ -0,0 +1,65 @@ +//! The macOS menu bar takes the Edit menu's key equivalents before the window does: with a text +//! field focused they edit the field, and only without one do they run the drawing command. + +use cadcraft_engine::Session; +use cadcraft_ui_egui::{CadApp, Services, menus}; +use egui::{accesskit::Role, vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use serde_json::json; + +fn harness() -> Harness<'static, CadApp> { + let mut app = CadApp::new(Session::default(), Services::default()); + app.ui.start_tab = false; + app.ui.in_window_menu = false; + let mut h = Harness::builder().with_size(vec2(1280.0, 800.0)).build_ui_state( + |ui, app: &mut CadApp| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }, + app, + ); + h.ctx.set_os(egui::os::OperatingSystem::Mac); + h.run_steps(5); + h +} + +/// A native menu choice, then the frames that deliver what it queued for the main window. +fn menu(h: &mut Harness<'_, CadApp>, id: &str) { + let ctx = h.ctx.clone(); + menus::activate_native(h.state_mut(), &ctx, id); + let mut raw = egui::RawInput::default(); + h.state_mut().raw_input_hook(&ctx, &mut raw); + h.input_mut().events.extend(raw.events); + h.run_steps(3); +} + +fn expression<'a>(h: &'a Harness<'_, CadApp>) -> egui_kittest::Node<'a> { + h.get_by_role_and_label(Role::Tab, "QuickCalc").parent().unwrap().get_all_by_role(Role::TextInput).next().unwrap() +} + +#[test] +fn edit_menu_keys_edit_the_focused_text_field_and_otherwise_the_drawing() { + let mut h = harness(); + h.state_mut().run("line", json!({"points": [[0, 0], [10, 0]]})).unwrap(); + let drawing = h.state().session.doc().unwrap().clone(); + menus::activate(h.state_mut(), "ui.toggle.quickcalc"); + h.run_steps(5); + expression(&h).click(); + h.run_steps(3); + expression(&h).type_text("2+3"); + h.run_steps(3); + assert_eq!(menus::text_field_window(&h.ctx), Some(egui::ViewportId::ROOT)); + assert!(menus::edits_text("pasteclip") && !menus::edits_text("line")); + menu(&mut h, "selectall"); + menu(&mut h, "cutclip"); + assert_eq!(expression(&h).value().as_deref(), Some(""), "Select All and Cut act on the field"); + assert!(h.state().session.running.is_none()); + assert_eq!(h.state().session.doc().unwrap(), &drawing); + + h.ctx.memory_mut(|m| m.stop_text_input()); + h.run_steps(3); + assert_eq!(menus::text_field_window(&h.ctx), None); + menu(&mut h, "u"); + assert_ne!(h.state().session.doc().unwrap(), &drawing, "without a focused field, Undo undoes the line"); +} diff --git a/crates/ui-egui/tests/it/native_windows.rs b/crates/ui-egui/tests/it/native_windows.rs new file mode 100644 index 000000000..e98825a80 --- /dev/null +++ b/crates/ui-egui/tests/it/native_windows.rs @@ -0,0 +1,116 @@ +//! Floating panels in their own OS windows: a modal dialog blocks their input, and keys typed in +//! one run the main window's shortcuts and command line. + +use cadcraft_engine::Session; +use cadcraft_engine::autosave::Recoverable; +use cadcraft_ui_egui::closing::Choice; +use cadcraft_ui_egui::docking::native_input_blocked; +use cadcraft_ui_egui::{CadApp, Services}; +use egui::{Event, Key, Modifiers}; +use serde_json::json; + +fn frame(app: &mut CadApp, ctx: &egui::Context) { + frame_with(app, ctx, Vec::new()); +} + +/// A frame whose main-window input carries `events`. +fn frame_with(app: &mut CadApp, ctx: &egui::Context, events: Vec) { + let mut raw = + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1280.0, 800.0))), events, ..Default::default() }; + app.raw_input_hook(ctx, &mut raw); + cadcraft_ui_egui::cmdline::capture_tab(ctx, &mut raw); + let mut out = ctx.run_ui(raw, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); +} + +fn pump(app: &mut CadApp, ctx: &egui::Context) { + for _ in 0..3 { + frame(app, ctx); + } +} + +fn key(key: Key, pressed: bool, modifiers: Modifiers) -> Event { + Event::Key { key, physical_key: None, pressed, repeat: false, modifiers } +} + +fn running(app: &CadApp) -> Option<&str> { + app.session.running.as_ref().map(|command| command.id.as_str()) +} + +#[test] +fn modal_dialogs_block_native_panel_windows() { + let ctx = egui::Context::default(); + let mut app = CadApp::new(Session::new(), Services::default()); + pump(&mut app, &ctx); + assert!(!native_input_blocked(&app, &ctx)); + + // "Save changes?" while a dirty drawing closes. + app.run("circle", json!({"center": [0, 0], "radius": 1})).unwrap(); + app.request_close(None); + assert!(native_input_blocked(&app, &ctx), "close prompt"); + pump(&mut app, &ctx); + assert!(native_input_blocked(&app, &ctx), "close prompt shown"); + app.answer_close(Choice::Cancel); + pump(&mut app, &ctx); + assert!(!native_input_blocked(&app, &ctx), "after Cancel"); + + // Drawing Recovery. + app.recovery.items.push(Recoverable { path: "missing.sv$".into(), drawing: "Plan.dxf".into(), modified: 0, bytes: 0 }); + assert!(native_input_blocked(&app, &ctx), "drawing recovery"); + app.recovery.items.clear(); + pump(&mut app, &ctx); + assert!(!native_input_blocked(&app, &ctx)); + + // Any other egui modal, from the frame after it is shown. + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + egui::Modal::new(egui::Id::unique("test_modal")).show(ui.ctx(), |ui| ui.label("modal")); + }); + out.textures_delta.clear(); + assert!(native_input_blocked(&app, &ctx), "egui modal"); +} + +#[test] +fn keys_forwarded_from_a_native_window_reach_the_command_line_and_shortcuts() { + let ctx = egui::Context::default(); + let mut app = CadApp::new(Session::new(), Services::default()); + pump(&mut app, &ctx); + + let typed = vec![Event::Text("LINE".into()), key(Key::Enter, true, Modifiers::NONE), key(Key::Enter, false, Modifiers::NONE)]; + app.forward_native_input(&ctx, typed, Some(Modifiers::NONE)); + pump(&mut app, &ctx); + assert_eq!(running(&app), Some("line")); + assert_eq!(app.cmd.history, ["LINE"]); + + let escape = vec![key(Key::Escape, true, Modifiers::NONE), key(Key::Escape, false, Modifiers::NONE)]; + app.forward_native_input(&ctx, escape, Some(Modifiers::NONE)); + pump(&mut app, &ctx); + assert_eq!(running(&app), None); + + let ortho = app.session.settings.orthomode; + app.forward_native_input(&ctx, vec![key(Key::F8, true, Modifiers::NONE), key(Key::F8, false, Modifiers::NONE)], Some(Modifiers::NONE)); + pump(&mut app, &ctx); + assert_eq!(app.session.settings.orthomode, !ortho, "F8"); + + // Shift held in the native window: Cmd/Ctrl+Shift+C arrives as Copy and runs COPYBASE. The + // main window's own modifiers come back afterwards. + app.forward_native_input(&ctx, vec![Event::Copy], Some(Modifiers::SHIFT | Modifiers::COMMAND)); + pump(&mut app, &ctx); + assert_eq!(running(&app), Some("copybase")); + assert_eq!(ctx.input(|i| i.modifiers), Modifiers::NONE); +} + +#[test] +fn a_main_window_modifier_change_outlasts_keys_forwarded_meanwhile() { + let ctx = egui::Context::default(); + let mut app = CadApp::new(Session::new(), Services::default()); + pump(&mut app, &ctx); + frame_with(&mut app, &ctx, vec![Event::ModifiersChanged(Modifiers::SHIFT)]); + app.forward_native_input(&ctx, vec![key(Key::F8, true, Modifiers::COMMAND), key(Key::F8, false, Modifiers::COMMAND)], Some(Modifiers::COMMAND)); + // Shift is released in the main window in the frame the forwarded keys go in. + frame_with(&mut app, &ctx, vec![Event::ModifiersChanged(Modifiers::NONE)]); + pump(&mut app, &ctx); + assert_eq!(ctx.input(|i| i.modifiers), Modifiers::NONE, "the released Shift is not put back"); +} diff --git a/crates/ui-egui/tests/it/options_dialog.rs b/crates/ui-egui/tests/it/options_dialog.rs index eb9cdeaea..be6869e4a 100644 --- a/crates/ui-egui/tests/it/options_dialog.rs +++ b/crates/ui-egui/tests/it/options_dialog.rs @@ -150,10 +150,14 @@ fn hostile_saved_profiles_are_ignored() { fn model_background_reaches_the_drawing_area_tokens() { let mut a = app(); let ctx = egui::Context::default(); - a.logic(&ctx); + let frame = |a: &mut CadApp| { + let mut out = ctx.run_ui(Default::default(), |ui| a.ui(ui)); + out.textures_delta.clear(); + }; + frame(&mut a); assert_eq!(Tokens::get().canvas, Tokens::DARK.canvas); a.run("options", json!({"modelBackground": "#ffffff", "paperBackground": "#202020"})).unwrap(); - a.logic(&ctx); + frame(&mut a); let t = Tokens::get(); assert_eq!(t.canvas, Color32::WHITE); assert_eq!(t.paper, Color32::from_gray(0x20)); @@ -161,7 +165,7 @@ fn model_background_reaches_the_drawing_area_tokens() { assert_ne!(t.grid_minor, Tokens::DARK.grid_minor); assert_eq!(t.chrome, Tokens::DARK.chrome, "the interface keeps its theme"); a.run("options", json!({"reset": true})).unwrap(); - a.logic(&ctx); + frame(&mut a); assert_eq!(Tokens::get(), Tokens::DARK); } @@ -234,7 +238,7 @@ fn selection_highlight_settings_set_save_and_show_in_the_selection_tab() { assert!(!text.contains(label), "{label} offered"); } // Visual Effect Settings: the selection area and the preview filter. - ctx.data_mut(|d| d.insert_temp(egui::Id::new("cc_options_visual_effects"), true)); + ctx.data_mut(|d| d.insert_temp(egui::Id::unique("cc_options_visual_effects"), true)); let text = render(&mut a, &ctx); for label in [ "Selection Area Effect", diff --git a/crates/ui-egui/tests/it/parameters_rename.rs b/crates/ui-egui/tests/it/parameters_rename.rs index 0754bbcb0..9530bbd17 100644 --- a/crates/ui-egui/tests/it/parameters_rename.rs +++ b/crates/ui-egui/tests/it/parameters_rename.rs @@ -3,9 +3,10 @@ use cadcraft_engine::Session; use cadcraft_ui_egui::{CadApp, Services}; +use egui::accesskit::Role; use egui::{Event, Modifiers, PointerButton, vec2}; use egui_kittest::Harness; -use egui_kittest::kittest::Queryable; +use egui_kittest::kittest::{By, Queryable}; use serde_json::json; fn harness() -> Harness<'static, CadApp> { @@ -26,7 +27,11 @@ fn harness() -> Harness<'static, CadApp> { } fn double_click_label(h: &mut Harness<'_, CadApp>, label: &str) { - let pos = h.get_by_label(label).rect().center(); + // Click the visible name label itself. The expression field beside it is `labelled_by` that + // label, and kittest's `get_by_label` deliberately returns the labelled widget (the field), + // so a plain `get_by_label` would double-click the wrong node. `include_labels` plus the + // Label role selects the row's name text, keeping this a real pointer/keyboard test. + let pos = h.get(By::new().role(Role::Label).label(label).include_labels()).rect().center(); h.event(Event::PointerMoved(pos)); h.run_steps(2); for _ in 0..2 { @@ -47,6 +52,7 @@ fn parameters(h: &Harness<'_, CadApp>) -> Vec<(String, String)> { fn double_click_types_a_new_name_and_enter_renames() { let mut h = harness(); double_click_label(&mut h, "user1"); + assert!(h.get_by_role_and_label(Role::TextInput, "Name").is_focused(), "the rename field has the keyboard focus"); h.event(Event::Text("width".into())); h.run_steps(2); assert_eq!(h.state().cmd.buffer, "", "the command line got the keys"); diff --git a/crates/ui-egui/tests/it/properties_units.rs b/crates/ui-egui/tests/it/properties_units.rs index 5b9eb31c9..596af84c3 100644 --- a/crates/ui-egui/tests/it/properties_units.rs +++ b/crates/ui-egui/tests/it/properties_units.rs @@ -4,7 +4,7 @@ use cadcraft_engine::Session; use cadcraft_engine::doc::EntityKind; -use cadcraft_ui_egui::{CadApp, Services}; +use cadcraft_ui_egui::{CadApp, Services, theme}; use egui::{Key, vec2}; use egui_kittest::Harness; use egui_kittest::kittest::{Queryable, by}; @@ -16,7 +16,9 @@ fn palettes(lunits: i64) -> Harness<'static, CadApp> { app.run("setvar", json!({ "name": "LUNITS", "value": lunits })).unwrap(); let h = app.run("text", json!({ "at": [0, 0], "text": "Note", "height": 2.5 })).unwrap(); app.run("select", json!({ "handles": [h["handle"]] })).unwrap(); - let mut h = Harness::builder().with_size(vec2(400.0, 1400.0)).build_ui_state( + // Install the production font chain before the first pass; the fixture draws palettes + // directly and would otherwise fall back to egui's fonts instead of the app's chain. + let mut h = Harness::builder().with_size(vec2(400.0, 1400.0)).with_context_setup(|ctx| theme::install_fonts(ctx, "en")).build_ui_state( |ui, app: &mut CadApp| { app.logic(&ui.ctx().clone()); cadcraft_ui_egui::palettes::right_palettes(app, ui); diff --git a/crates/ui-egui/tests/it/shared_controls.rs b/crates/ui-egui/tests/it/shared_controls.rs index 2c6bc2ab8..76cb38979 100644 --- a/crates/ui-egui/tests/it/shared_controls.rs +++ b/crates/ui-egui/tests/it/shared_controls.rs @@ -116,7 +116,9 @@ fn drafting_palette_pointer_and_keyboard_start_the_same_commands() { #[test] fn right_palettes_collapse_arrow_hides_menu() { - let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state( + // Install the production font chain before the first pass; the fixture draws the palette + // directly and would otherwise fall back to egui's fonts instead of the app's chain. + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).with_context_setup(|ctx| theme::install_fonts(ctx, "en")).build_ui_state( |ui, app: &mut CadApp| { app.logic(&ui.ctx().clone()); cadcraft_ui_egui::palettes::right_palettes(app, ui); diff --git a/crates/ui-egui/tests/it/text_double_click.rs b/crates/ui-egui/tests/it/text_double_click.rs index 782234d10..5cedfbf61 100644 --- a/crates/ui-egui/tests/it/text_double_click.rs +++ b/crates/ui-egui/tests/it/text_double_click.rs @@ -4,7 +4,7 @@ use cadcraft_engine::doc::EntityKind; use cadcraft_engine::geom::Vec2; use cadcraft_engine::{Input, Session}; use cadcraft_ui_egui::{CadApp, Services}; -use egui::{Event, Modifiers, PointerButton, Pos2, vec2}; +use egui::{Event, ImeEvent, Key, Modifiers, PointerButton, Pos2, vec2}; use egui_kittest::Harness; use serde_json::json; @@ -68,6 +68,25 @@ fn double_click_on_text_starts_textedit_for_that_object() { } } +#[test] +fn double_clicked_text_takes_ime_composition_and_commit() { + let mut h = harness(); + double_click(&mut h, Vec2::new(4.0, 1.2)); + assert_eq!(h.state().session.current_prompt().unwrap().message, "Enter new text"); + // Composition is shown, not saved; the commit is inserted exactly once (#1761). + h.event(Event::Ime(ImeEvent::Preedit { text: "zhong".into(), active_range_chars: None })); + h.run_steps(1); + assert_eq!(h.state().cmd.preedit, "zhong"); + assert!(h.state().cmd.buffer.is_empty()); + h.event(Event::Ime(ImeEvent::Commit("中文".into()))); + h.run_steps(1); + assert!(h.state().cmd.preedit.is_empty()); + assert_eq!(h.state().cmd.buffer, "中文"); + h.key_press(Key::Enter); + h.run_steps(1); + assert_eq!(text_at(&h, 0), "中文"); +} + #[test] fn double_click_elsewhere_starts_no_command() { for at in [Vec2::new(30.0, 5.0), Vec2::new(15.0, 30.0)] { diff --git a/crates/ui-egui/tests/options_followups.rs b/crates/ui-egui/tests/options_followups.rs index 0f470baeb..9c7327905 100644 --- a/crates/ui-egui/tests/options_followups.rs +++ b/crates/ui-egui/tests/options_followups.rs @@ -117,6 +117,8 @@ fn interface_scale_in_options() { assert_eq!(r["interfaceScale"], json!(1.5)); let ctx = egui::Context::default(); frame(&mut a, &ctx, Vec::new(), 0.0); + // egui applies a newly requested zoom factor at the start of the next pass. + frame(&mut a, &ctx, Vec::new(), 0.1); assert_eq!(ctx.zoom_factor(), 1.5, "applied as egui's zoom factor"); // Refused all or nothing. @@ -143,13 +145,14 @@ fn interface_scale_in_options() { // Reset brings back the system's own size. assert_eq!(a.run("options", json!({"reset": true})).unwrap()["interfaceScale"], json!(1.0)); - frame(&mut a, &ctx, Vec::new(), 0.1); + frame(&mut a, &ctx, Vec::new(), 0.2); + frame(&mut a, &ctx, Vec::new(), 0.3); assert_eq!(ctx.zoom_factor(), 1.0); // The Display tab shows it. a.run("options", json!({"interfaceScale": 1.25})).unwrap(); a.ui.dialog = Some("options:display".into()); - frame(&mut a, &ctx, Vec::new(), 0.2); - let text = frame(&mut a, &ctx, Vec::new(), 0.3); + frame(&mut a, &ctx, Vec::new(), 0.4); + let text = frame(&mut a, &ctx, Vec::new(), 0.5); assert!(text.contains("Interface scale") && text.contains("125 %"), "{text}"); } diff --git a/crates/ui-egui/tests/quickcalc_toggle.rs b/crates/ui-egui/tests/quickcalc_toggle.rs index 7f643c90d..9b6bec6d1 100644 --- a/crates/ui-egui/tests/quickcalc_toggle.rs +++ b/crates/ui-egui/tests/quickcalc_toggle.rs @@ -43,7 +43,7 @@ fn platform_shortcut_toggles_with_expression_focus_and_preserves_drafting() { h.key_press_modifiers(modifiers, Key::Num8); h.run_steps(5); assert!(h.state().ui.docking.layout.contains(&Panel::QuickCalc)); - let floating = h.state().ui.docking.layout.floating.iter().find(|group| group.panels.contains(&Panel::QuickCalc)).unwrap().rect; + let floating = h.state().ui.docking.layout.floating.iter().find(|group| group.contains(&Panel::QuickCalc)).unwrap().rect; expression(&h).click(); h.run_steps(3); expression(&h).type_text("2+3"); @@ -55,7 +55,7 @@ fn platform_shortcut_toggles_with_expression_focus_and_preserves_drafting() { h.key_press_modifiers(modifiers, Key::Num8); h.run_steps(5); assert_eq!(h.get(by().role(Role::TextInput).value("2+3")).value().as_deref(), Some("2+3")); - assert_eq!(h.state().ui.docking.layout.floating.iter().find(|group| group.panels.contains(&Panel::QuickCalc)).unwrap().rect, floating); + assert_eq!(h.state().ui.docking.layout.floating.iter().find(|group| group.contains(&Panel::QuickCalc)).unwrap().rect, floating); assert_eq!(h.state().session.running.as_ref().unwrap().id, "line"); assert_eq!(h.state().cmd.buffer, "12,34"); assert_eq!(h.state().session.doc().unwrap(), &drawing); @@ -131,7 +131,7 @@ fn explicit_opens_still_reveal_and_calculations_do_not_open_the_palette() { app.run("ui.dock", json!({"operation":"activate", "panel":"toolSets"})).unwrap(); app.run("ui.panel.quickcalc", Value::Null).unwrap(); let mut workspace = app.ui.docking.clone(); - workspace.reveal_panel(Panel::QuickCalc).unwrap(); + cadcraft_ui_egui::docking::reveal_panel(&mut workspace, Panel::QuickCalc).unwrap(); assert_eq!(app.ui.docking.layout, workspace.layout, "explicit open must activate an inactive tab"); } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 424d7cd60..1c3f30626 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -50,7 +50,7 @@ field. `engine.commands` returns a separate command catalog, not a list of addit | `engine.execute` | `{command, params}` | Run any command with JSON parameters. Never opens a dialog. `command` is an alias of this method, with the same parameters and result. | | `engine.commands` | | Every command: id, label, menu path, shortcut, aliases, params doc, enabled. | | `drawing.inspect` | `{entities?, limit?}` | Drawing summary (counts, extents, layers, styles, blocks, layouts, selection, undo) and entities with handles and geometry. | -| `ui.inspect` | | UI state, canvas rect, view, cursor and snap, performance counters; `closePrompt`: the drawing a "Save changes?" prompt is asking about (null when none); and actual egui keyboard focus (`focus.id`, `focus.label`, `focus.role`). | +| `ui.inspect` | | UI state, canvas rect, view, cursor and snap, performance counters (`uiFrames`, `passIndex` included); `closePrompt`: the drawing a "Save changes?" prompt is asking about (null when none); and actual egui keyboard focus (`focus.id`, `focus.label`, `focus.role`). | | `ui.elements` | `{label?, role?, focused?}` | Observed toolbar, tab, drafting and layer-palette controls from the latest UI frame. Filters match exactly. Each entry has its real egui `id`, label, role, rectangle `[x,y,width,height]`, enabled/visible/selected state and current focus. | | `ui.menu.list` / `ui.menu.invoke` | `{command}` | The menu tree; invoke an item like a click (interactive). | | `ui.pointer` | `{x, y, space?: "world"\|"screen", button?, action?: "click"\|"move"}` | Click in the drawing area (world coordinates by default). Right button = Enter. | @@ -60,7 +60,7 @@ field. `engine.commands` returns a separate command catalog, not a list of addit | `ui.set` | UiState fields | Show/hide palettes, toolbars, command line, ViewCube…; `theme: "system"\|"light"\|"dark"` (also `engine.execute` with `ui.theme {theme}`); `ui.inspect` reports the theme shown (`theme`: light/dark). `animations: "system"\|"on"\|"off"` (also `ui.animations {animations}`) shows or hides animation effects (dialog and menu fades, smoothed scrolling), `system` following the operating system's setting; `ui.inspect` reports whether they show (`animations`: true/false). `saveFormat: "dxf"\|"dwg"` (also `ui.saveformat {format}`) is the format Save As suggests for new drawings; `saveVersion: "2000"`…`"2018"` (also `ui.saveversion {version}`) the version it saves them in (SAVEAS also takes `version` and `binary` directly). | | `ui.resize` | `{width, height}` | Resize the window. | | `ui.focus` | | Activate the native window. Does not assign focus to a widget. | -| `ui.screenshot` | `{path?}` | PNG of the window (needs a presented frame). | +| `ui.screenshot` | `{path?}` | PNG of the window (needs a presented frame). Bounded: at most 4 queued/in-flight/completed requests, 64 MiB per image and 64 MiB total (completed but undrained images still count), an 8 s deadline, and cancellation when the document or keyboard focus changes while the request is outstanding. A queued request adopts the settled window size when issued. After issue, a change in window size, scale, OS window focus, minimization or occlusion cancels it. Every reply carries `request`; failures include `ok:false,error`. Files are written only after the deadline and identity checks pass. Without `path`, each request gets a unique PNG in a private owner-only per-process directory under the temp dir (created exclusively; an existing symlink is never followed), so concurrent app instances cannot collide; the caller owns those files and should remove them when done. | | `ui.render` | `{path?, width?, height?, fit?}` | Headless render. `fit` (default true) frames the whole drawing; `false` uses the current view. Replies `{pngBase64, width, height, bytes, path?}`. | | `app.open` / `app.save` / `app.quit` | `{path}` | File operations. | | `control.methods` | | Every method this server answers: `{method, params, description}`. Headless (`cadcraft-cli mcp`) lists only the methods it serves. | @@ -134,7 +134,7 @@ Tool Sets, Layers, and Properties can be regrouped, split, floated within the ap Panel IDs: `toolSets`, `layers`, `properties`, `externalReferences`. The protected center is `canvas`. -`ui.dock` accepts `operation`; basic per-panel operations also require `panel`. The advanced `resizeSplit` operation below uses a split `path` instead of a panel. Basic operations are `open`, `close`, `activate`, `float` (with finite positive `rect: [x, y, width, height]`), or `move` (with `target` and `zone: center|left|right|top|bottom`). Center groups with the target; side zones split beside it. `ui.dock.reset` restores the default arrangement. Invalid moves return an error without changing the layout. +`ui.dock` accepts `operation`; basic per-panel operations also require `panel`. The advanced `resizeSplit` operation below uses a split `path` instead of a panel. Basic operations are `open`, `close`, `activate`, `float` (with finite positive `rect: [x, y, width, height]`), or `move` (with `target` and `zone: center|tab|left|right|top|bottom|above|below`, default `center`). Center and tab group with the target; side zones split beside it; above and below add a group in a floating window. `open` and `activate` show a hidden panel at its last place and select it. `ui.dock.reset` restores the default arrangement. Invalid moves return an error without changing the layout. The QuickCalc palette (`quickCalc`) is not part of the default arrangement. Typed `QUICKCALC`/`QC` and `ui.panel.quickcalc` explicitly open and activate it: it floats the first time and later @@ -150,9 +150,9 @@ Advanced `ui.dock` operations use the same validated transaction as pointer gest `moveFloating` updates `rect`, `resizeSplit` accepts a boolean child `path` and `size` (`{"Ratio":0.5}`, `{"FixedFirst":280}`, or `{"FixedSecond":280}`), `setStackOpen` takes `open`, and `resizeStack` takes a point `height` or null. -A center `move` can include `before` (panel ID or null to append) for tab order. +A center or tab `move` can include `before` (panel ID or null to append) for tab order. The equivalent `{"action": ...}` form accepts a serialized shared docking action; -this is also the form emitted by the renderer. Protected-canvas and app-specific panel +this is also the form emitted by the renderer, and it takes every action of craft-ui's docking, group, column and window moves included. Protected-canvas and app-specific panel rules apply equally to both forms. ## Command-line replies diff --git a/docs/egui-037-migration-plan.md b/docs/egui-037-migration-plan.md new file mode 100644 index 000000000..9859dfe1a --- /dev/null +++ b/docs/egui-037-migration-plan.md @@ -0,0 +1,1475 @@ +# CADCraft — egui 0.37 migration plan + +Current reconciliation (2026-10-25): the local tree now incorporates main `e86d895ba1086a7efc2a83048348f89098282d08`. Managed run `egui037-cad-maine86-ci-20261025-0825` passed all seven CI stages: 2,515 passing test executions, zero failures and one ignored across 103 summaries. Independent review verified preservation of both parents and dependency pins; the compare-export regression now verifies document-epoch advancement. Earlier validation counts below describe prior candidates. Native/browser GUI, platform and paired performance checks remain open. Publication requires explicit approval; human review and human merge are required. + +**Status (2026-10-25):** reconciled main `b90165666917ec3ac6c308c2ea1c55bb6ea8317d` and passed all 7 mandatory CI stages. Managed job `egui037-cadcraft-main-refresh-ci-20261025-0615` recorded 2,451 passing test executions, zero failures and 1 ignored. The shared-docking adoption uses the reviewed craft-ui revision `650b1c0b058017c4eb3af06d5b39586d6732e07b`; the lock changed only that package and its dependency list. This revision is reachable from the published craft-libs migration branch. Native/platform GUI and IME workflows, final packages and paired app performance remain open. Earlier source identities and counts below are historical. + +Current landing status, 2026-10-25: the implementation integrates main `fcfdf33001cf2462bdafb6e1237becd8ef0ea1cb` and selects egui `6d518fc799681571963842f95b55a6b68431b15a`, winit `a517b3846ee8457d84ee4975110bfda6797c44dc`, shared UI `25da1314d443171b4adeaad2cd9454b273f35c6f` and independent PDF/document-path revisions in the lockfile. Full managed CI0300 passed all seven stages, with 2,448 passing tests, no failures and one ignored across 103 summaries. Fresh review0320 verified the tested source, all seven latest-main paths and all 13 new source files. + +The review also identified inherited upstream issues in MEASUREINIT reset/web startup and PDF font hardening; these are carried explicitly in the review packet. The egui licence-source URL has been updated to the selected revision; the font bytes are unchanged. Final notice validation is pending. Current-source Windows/macOS/FreeBSD, visible GUI, native IME and performance acceptance are not established by the Linux CI result. Human review and human merge are required. + +The original planning and research record follows; its baseline pins and planning-only status are historical. + +Status: **planning only**. This document changes no production code, manifest, lockfile, +branch, tag, PR or release. No build, test or GUI run was performed while writing it. +Every future push or new PR requires fresh, explicit human approval after the exact +contents and validation results are shown; nothing in this document grants that approval. +Every future host build/test and owned-VM command goes through +`maintenance/task.py run/status/finish` with its own task, source and evidence. + +Scope: the `storytold/cadcraft` checkout at +`/home/codex/work/CraftApps/egui037-migration-20261022/cadcraft` only. The shared +`storytold/egui` fork, `storytold/winit`, `storytold/craft-libs` and the sibling apps +are dependencies and hand-off points, not repositories changed by this plan. + +Claims are written to be checked against the named source. Historical compiler output +and measurements are clues, never current acceptance. + +Capture acceptance follows the suite contract: a queued-not-issued hidden request and +each settling frame must request `RequestPaintWhileHidden` from logic; only an issued +screenshot command forces paint itself. Callback arrival has no implicit wake, so the +live request owns the viewport waker. Record known issuance UI bounds and a geometry +epoch, reject/retry any intervening resize/DPI/zoom/surface change even if it changes +back, and derive pixel scale from returned dimensions only under an unchanged epoch. +Default limits are 4 requests, 128 MiB/image and 256 MiB aggregate; CADCraft's explicit +64 MiB limits remain stricter. Count reserved in-flight and completed images together. + + +## 1. Decision in one page + +CADCraft's committed dependency state does not currently build as written: +`Cargo.toml:52-57` asks for egui/eframe/egui-wgpu/egui_kittest 0.37 while `craft-ui` +is pinned to `5c874d51…`, whose egui dependency is 0.36.2. The lock therefore resolves +two incompatible egui families, and `DockArea::show` receives a 0.37 `Ui` from a +0.36-typed API. This is a reconciliation task, not a version bump. + +Target state: + +1. One immutable egui family from the **final reviewed `storytold/egui` revision**. + That revision is not known or pinned by this plan; it exists only after the approved + publication of the fork's reviewed patch series. The aiongg candidate + `a4f9a17f64a6bae61350fb85804c9df0d9865916` and upstream `841a328b` are comparison + inputs, never production pins. +2. One immutable winit from the **final reviewed `storytold/winit` revision** containing + the W1-W15 base, W17-W27 language/IME series and the new **W15P** PRIMARY path. The + aiongg candidate `8b35388bd719e8cc86a95971ba0bc026c9a8f058` contains W17-W27 but no + PRIMARY and is only a comparison input. +3. `craft-ui` and `craft-docpath` from one **final reviewed `storytold/craft-libs` + revision** that is current `main` (`49190884…`) plus only the egui-API hunks ported + from PR #199's `8614e4a…`. The PR head `b68de8c…` and audit tree `ec8840a…` are + stale unsafe trees and are never merged or checked out as the target. +4. CADCraft source changes limited to: the real compile-error classes + (`UserData`/`Event::Screenshot`, `MouseWheel.source`, private `FontsView.fonts`), the + deprecations `clippy -D warnings` rejects, panel-id/`PanelState` migration, the strict + kittest names/glyphs needed for a green first compatibility PR, and the deliberate + lifecycle/screenshot/PRIMARY/IME changes below. There is no `update`→`ui` rewrite: + `eframe::App::ui` and a root `Ui` are already used (`apps/cadcraft/src/main.rs:91`, + `crates/ui-egui/src/lib.rs:627`). +5. A lifecycle split that is correct under the **actual pinned integration**: both native + and web call `App::logic` and `App::ui` inside every `Context::run_ui` pass. A + completed `run_ui` leaves `current_pass_index() == 0`; a logic-only tick also reports + zero. Visibility is not a phase. Frame-once work uses the zero-pass-index guard; + input-dependent work moves into `App::ui`; consume-based queues run on every logic + callback. This is a correction of an existing 0.36/0.37 hazard, not a new 0.37 + lifecycle. +6. Screenshot completion uses the shared craft-ui CL-2 design: one bounded, cancelable + completion slot per admitted request, a callback holding only a **weak** reference to + that slot, capture-time viewport/geometry/DPI binding, and aggregate byte bounds. The + callback never holds a strong `Context` (which would create a Context→command→callback + cycle) and never writes files or encodes PNGs. +7. One atomic compatibility PR (S2), then behaviour PRs as in §12. S2 must already be + green under strict kittest accessibility and missing-glyph policy, so it owns the + accessible-name and `default_fonts`/`monochrome_emoji_fonts` work; S4 then owns the + broader language, AccessKit-language and Arabic checks. + +## 2. Baselines, exact revisions and evidence + +### 2.1 Checkouts and revisions + +| Role | Revision | Notes | +|---|---|---| +| Assigned CADCraft checkout | `1977a493c7d8340249185126744c5143f096dc86` | `HEAD`; branch `codex/egui-037-plan-20261022`; tracked tree clean; this plan is currently untracked | +| Last coherent 0.36.2 CADCraft | `a08fd7c1d1d7c16f40dc31c25f815370c136a1d4` | Parent of the Dependabot bump that moved direct UI deps to 0.37 | +| craft-libs `main` inspected | `49190884278f16c9a9f5823d7698a8ffb5deb062` | Baseline for the S1 rebase | +| craft-libs PR #199 API commit | `8614e4a04fae516fba16c22b31ff1008cb6be10b` | Source of the port hunks only | +| craft-libs PR #199 head | `b68de8c5f4f1c060904d211a7b1320aa0f7bce78` | Stale tree; never a target | +| craft-libs audit merge | `ec8840af8c5fda695cf195cdd05e5ae078415c07` | Stale audit tree; evidence only | +| Upstream egui 0.37.0 | `841a328ba2dd8f6996beb7189ebb7fc00e668a2a` | Stock control and API source | +| aiongg egui candidate | `a4f9a17f64a6bae61350fb85804c9df0d9865916` | Comparison input; not a production pin | +| winit published base | `ffae5198770d7730e718043f035a772b7dbc3b1b` | `craft/0.30.13`, W1-W15 | +| aiongg winit candidate | `8b35388bd719e8cc86a95971ba0bc026c9a8f058` | W17-W27; no W15P; comparison input | +| Published egui 0.36.2 | `767b1cad294120689a514bb4bd68c077e46679b9` | Rollback patch inventory | + +**Final production pins are unresolved until approved publication.** The manifest edits +below use ``, `` +and ``. A candidate SHA may not be copied into a +production manifest, and a moving branch is never a pin. + +Governing instructions: repository `AGENTS.md`, suite `PLANNING-CONTRACT.md` and the +server instructions. `.agents/` and `.codex/` are empty; there is no nested `AGENTS.md`. +`AGENTS.md` (68 lines) and `CLAUDE.md` (49 lines) are **not** the same text: `CLAUDE.md` +lacks the chrono rule and the contributor-credit section. `AGENTS.md` governs. + +### 2.2 Dependency facts in the committed lockfile + +`Cargo.lock` currently contains: + +* `egui` 0.36.2 **and** 0.37.0; likewise `emath`, `ecolor`, `epaint`. +* `accesskit` 0.24.1 and 0.25.1 (one per egui family). +* `craft-ui` at `git+…?rev=5c874d51…#5c874d51…`, depending on `egui 0.36.2`. +* `craft-docpath` at `git+…?rev=82a24b0…#82a24b0…` (different craft-libs revision, + no egui dependency). +* `eframe`, `egui-wgpu`, `egui-winit`, `egui_kittest` at 0.37.0, and `winit` 0.30.13 + from crates.io. +* `arboard` 3.7.0 is present in the lockfile, but `cargo tree -i arboard --locked` + on the native graph prints nothing: the 0.37 `eframe` feature list + (`apps/cadcraft/Cargo.toml:19`) does not enable `eframe/clipboard`, which forwards + to `egui-winit/clipboard` (0.36's eframe enabled it unconditionally). **Without + adding `clipboard`, copy/paste silently stops reaching the OS clipboard**; + `crates/ui-egui/src/clipboard.rs:29-60` depends on `ctx.copy_text` and + `Event::Paste`. This is a verified graph fact, not a warning from the old audit. +* No root `[patch.crates-io]`, no `vendor/` and no **external/local path patch** in + this repository. Internal workspace members are path dependencies by design. +* Read-only `cargo tree -d --locked --offline` shows the 0.36 family reachable only + through `craft-ui`, and the 0.37 family through CADCraft/eframe/egui-wgpu/kittest. + This is graph evidence, not a build. +* The candidate fork's own `a4f9a17f/Cargo.toml:178-179` still has + `[patch.crates-io] winit = { …, branch = "craft/0.30.13-lang" }`. Consumers' patches + override it, but the fork's own build is not immutable. The final storytold fork/tag + must replace that branch with the reviewed rev before publication. + +### 2.3 Historical compiler clues (not acceptance) + +`/home/codex/work/ssd2/task-evidence/egui037-audit-20261022/fork/cadcraft.log` was +produced by a path-patched trial, not by the committed manifests. It reports: + +* 3 error classes (16 diagnostics in the `stock` run): `lib.rs:696` (`egui::UserData`), + `lib.rs:716` (`Event::Screenshot`), `animation.rs:78` (`MouseWheel` pattern lacks + `source`). The `stock` run additionally reports `Event::MouseWheel` initializers at + `animation.rs:145,197`, `canvas.rs:2806`, and private `FontsView.fonts` at + `i18n/tests.rs:407,423,442,557,607,803,912,1017,1125,1168`. +* Warnings include 92 `egui::Id::new` calls (90 in `crates/ui-egui/src`, 2 in tests: + `tests/docking_workflows.rs:303`, `tests/options_dialog.rs:237`), 51 `ui.id()` calls, + 2 `WidgetType`, 1 deprecated `AsId`, and private-font/dependency warnings. +* The trial patched 11 crates.io crates and both craft-libs crates by path; those path + patches are evidence of the API delta and must never be committed. + +These are clues to re-check on the migration tip, not a fixed work list. + +## 3. Current implementation inventory + +### 3.1 Manifests and feature targets + +| File | Facts that matter here | +|---|---| +| `Cargo.toml:6-11` | workspace version 0.12.0, edition 2024, `rust-version = "1.90"` | +| `Cargo.toml:52-59` | `craft-ui` git rev `5c874d51…`; `egui`, `egui_kittest`, `eframe`, `egui-wgpu` set to `0.37`; unused `arboard` workspace entry | +| `crates/ui-egui/Cargo.toml:7` | UI crate already declares `rust-version = "1.95"` | +| `crates/ui-egui/Cargo.toml:20-32` | `egui`, `egui-wgpu`; dev `egui_kittest` with `wgpu,snapshot`; dev `naga 30` | +| `apps/cadcraft/Cargo.toml:19` | `eframe` features: `default_fonts,wgpu,accesskit,wayland,x11,links,persistence` — **missing `clipboard`** | +| `apps/cadcraft/Cargo.toml:29` | direct `winit 0.30` (x11) for the XWayland event-loop choice | +| `apps/cadcraft-web/Cargo.toml:29` | `eframe` web features: `persistence,default_fonts,wgpu,accesskit` | +| `apps/cadcraft-web/Cargo.toml:26-32` | `web-sys` for file input; `upload.rs` uses `DroppedFile::web_file` | +| `crates/render/Cargo.toml` | rayon only off wasm; not UI | +| `xtask/Cargo.toml` | no egui dependency | + +Platform cfgs that must keep compiling: + +| Cfgs | Where | What they gate | +|---|---|---| +| `cfg(target_arch = "wasm32")` | `ui-egui/lib.rs` (`now_ms`, `FrameCap`, autosave, dropped files), `ui-egui/theme.rs` (system-font probes), `render`, `dwg`, `apps/cadcraft-web` | no threads/system fonts/files on web; `now_ms` is 0 on wasm; `web_file` is wasm-only | +| `cfg(not(target_arch = "wasm32"))` | `apps/cadcraft` `rfd`, `sys-locale`; `ui-egui` autosave and frame cap | native-only dialogs/locale/autosave | +| `cfg(unix)` / `cfg(all(unix, not(macos)))` | `apps/cadcraft` `winit` x11, `zbus`; `desktop_theme.rs` | XWayland choice, portal theme | +| `cfg(target_os = "macos")` | `muda`, `objc2-app-kit`, `native_menu.rs`, `system_animations.rs`, integrated titlebar | native menu, reduce-motion, traffic-light layout | +| `cfg(target_os = "windows")` | `hidapi` windows-native, `windows` `UI_ViewManagement`, `graphics.rs`, `windows_subsystem` | DX12/FXC startup, animation settings | +| `cfg(target_os = "linux")` | `hidapi` linux-native-basic-udev | spacemouse HID | + +`crates/fonts/build.rs` (`CRAFT_FONTS_DIR`) and `crates/ui-egui/build.rs` +(`contributors/*`) are build inputs unrelated to egui; do not change their contracts. + +Packages/binaries: `cadcraft` (desktop, eframe), `cadcraft-web` (wasm, eframe web +runner), `cadcraft-cli` (headless, no egui), `cadcraft-ui-egui` (library, kittest), +`xtask` (layers/wasm/parity). There are no examples besides `examples/apartment.dxf` +(48,769 bytes, tracked), no benches, no committed screenshot fixtures. + +### 3.2 Entry points + +* Native: `apps/cadcraft/src/main.rs:228` `main`; `NativeOptions` at `:262-283`; + Windows DX12/FXC at `:285` via `graphics::configure`; wgpu buffer-limit workaround + at `:289-297`; XWayland event loop at `:301-306`; `run_native` at `:307`; + `set_wgpu(rs, depth_format_from_bits(0, 8))` at `:347`. +* `eframe::App` at `apps/cadcraft/src/main.rs:67-110`: `logic`, `raw_input_hook`, + `ui`, `on_exit`, `save`, `persist_egui_memory = false`. +* Web: `apps/cadcraft-web/src/main.rs:31-58` `Shell` (same `logic`/`ui` split); + `WebRunner::start` at `:114`; `?cpu`/`?webgl` at `:108-113`; + `CadApp::set_wgpu(rs, None)` for web (no stencil). +* Headless: `cadcraft-cli` and `cadcraft-mcp` never construct egui; `ui.render` + is the CPU raster path in `crates/ui-egui/src/control.rs:470-545`. +* Control channel: `apps/cadcraft/src/control_server.rs`; methods in + `crates/ui-egui/src/control.rs:195-215` and `docs/control-protocol.md`, enforced + by `tests/control_methods.rs`. + +### 3.3 Actual frame lifecycle and the pinned integration + +Verified against native `crates/eframe/src/native/epi_integration.rs:338-365` and +`native/run.rs:235-281`, web `crates/eframe/src/web/app_runner.rs:280-325`, and egui +`context.rs:1099-1158,1190-1218,2124-2129`: + +* **Both native and web call `App::logic` and `App::ui` inside the `run_ui` closure, + once per pass.** Native `update` calls `app.logic(ui.ctx(), frame)` then `app.ui(ui, + frame)`; web does the same. On a second discard pass the same two calls happen again + with empty input. This is already true on 0.36.2 (`767b1cad` has the same order); it + is not a 0.37 introduction. +* A hidden root window runs `ctx.run_logic` → `App::logic` only. Native reaches this + through `PassMode::LogicOnly` when a redraw is overdue (`native/run.rs:267-281`); + web through `run_logic` when the tab viewport is not visible (`app_runner.rs:318-328`). + No egui pass runs, so `current_pass_index()` stays 0 and input/layout remain from the + last pass. +* `current_pass_index()` is `0` during pass 1 and during a logic-only call, `1+` during + later passes. After a completed `run_ui`, `end_pass` takes the viewport output + (`mem::take`), so it is **0** again; do not expect a nonzero index after completion. +* `ViewportInfo::visible()` is derived from `minimized`/`occluded` + (`egui/src/data/input/viewport_info.rs:98-104`); it is `None` when either flag is + unknown and `Some(true)` only when both say visible. A compositor can withhold a + frame without setting either flag, so a logic-only overdue tick can still see + `None` or `Some(true)`, and a hidden window can still run a full `run_ui` + when a screenshot or `RequestPaintWhileHidden` asks for pixels + (`native/wgpu_integration.rs:695-727`). **Visibility cannot distinguish a logic-only + tick from a forced hidden UI pass.** +* `App::raw_input_hook` is called by `prepare_raw_input` for both `update` and + `update_logic_only` (`epi_integration.rs:338-344,385-425`) and before the visibility + branch on web (`app_runner.rs:284`). It must not assume that a UI pass follows. +* `Context::run_ui` re-runs its closure for each pass requested by + `Context::request_discard` (default `max_passes = 2`). `Context::root_ui` panics while + the root `Ui` is borrowed (#8535); CADCraft keeps the handed-in `&mut Ui`. +* Screenshots are callback based: `ViewportCommand::Screenshot(ScreenshotCallback)` / + `Context::request_screenshot` (#8489); the callback receives only + `Arc`, runs at most once, and does **not** request a repaint. +* #8748 is a **closed, unmerged** PR (“run the ui of an overdue window without + painting”) and is not in the pinned 0.37 source. The pinned source has + `PassMode::LogicOnly`, not `PassMode::Overdue`; its body confirms that such a window + “only runs `App::logic`”. Do not use #8748 to justify current behavior. + +### 3.4 UI state, commands, undo and persistence + +* UI state is `UiState` (`lib.rs:70-112`) and is persisted by the desktop + `apps/cadcraft/src/workspace.rs` as JSON (`docking`, panel visibility, theme, language, + recent files), version 1, bounded at 64 KiB. +* `persist_egui_memory` is false (`apps/cadcraft/src/main.rs:107`), so eframe does not + save egui `Memory`. The app's own `Workspace` JSON does not store egui ids or + `PanelState`. +* All user-visible edits go through `CadApp::run` / `start` / `cmdline` + (`lib.rs:315-359`) or menus; engine undo is the single transaction owner. +* Docking is `craft_ui::docking::Layout` (`docking.rs:1-130`), serialized + without egui ids; the surface is created with `egui::Id::new("cadcraft-docking")` + and `cancel_drag` with the same id (`docking.rs:339,375`). +* Top-level panels are `exact_size(...)` and non-resizable: `chrome.rs:69,180,300`, + `menus.rs:573`, `palettes.rs:330,346`. + +### 3.5 GPU, control and screenshots + +* `crates/ui-egui/src/gpu.rs:681` `install` registers error/device-lost handlers and + inserts `Resources` into `egui_wgpu::Renderer::callback_resources`. +* `impl egui_wgpu::CallbackTrait for CanvasCallback` (`gpu.rs:693-780`) uses + `ScreenDescriptor`/`PaintCallbackInfo` fields unchanged in 0.37. +* `main.rs:188` asks for an 8-bit stencil buffer; clipped paper-space viewports are + drawn through `Stencil::Mask`/`Stencil::Inside`. +* `ui.screenshot` currently queues `(token, deadline, frames)`, then requests a + viewport screenshot and matches `Event::Screenshot` by token (`lib.rs:688-740`); + replies are correlated by `shot_token` only. `save_screenshot` + (`control.rs:547-559`) writes RGBA PNG. +* `ui.render`, `ui.inspect` and `ui.elements` are CPU/state paths and do not depend + on a presented frame. `ui.inspect` currently exposes `frameMs`, `buildMs`, `drawMs`, + `meshMs`, `prims`, `building`, `renderer` (`control.rs:270`) but **not** + `cumulative_frame_nr`, pass index or `cpu_usage`. +* There is no `view.set` control method. View changes use `cmdline.input` (ZOOM/PAN), + `engine.execute`, `ui.pointer`, `ui.click`/`ui.move`/`ui.drag`, or `ui.set` for + `UiState` fields. + +### 3.6 Text, fonts, language and accessibility + +* `theme::install_fonts` (`theme.rs:314-387`) starts from + `egui::FontDefinitions::default()`, prepends an installed system UI face, appends + CJK/Japanese/Arabic/Hangul faces and embedded craft-fonts faces. Tests inspect the + installed character maps through `ctx.fonts_mut(|view| view.fonts.font(&family))` + (`i18n/tests.rs:407,423,442,557,607,803,912,1017,1125,1168`). In 0.37 + `FontsView.fonts` is private; use `view.characters(&family)`/`view.has_glyph`. +* The interface catalog has **16** languages (`i18n/mod.rs:173`), including Arabic + (`ar.tsv` 1235 lines). `theme::can_show` (`theme.rs:640-647`) gates Arabic, Chinese + and Korean on an installed or embedded face. Bundled epaint fonts cover Latin/Greek/ + Cyrillic but not Arabic, CJK or Hangul. +* Shortcut labels and catalogs contain `⌘` U+2318, `⇧` U+21E7, `↩` U+21A9, `−` + U+2212, `…` and `°`. In 0.37 `⌘` is only in `emoji-icon-font`, included only with + `monochrome_emoji_fonts`; `⇧` is only in Hack (Monospace). U+22EE is absent from both + 0.36.2 and 0.37 bundled fonts, so its absence is not a 0.37 change. +* Accessibility metadata is built with `egui::WidgetInfo` in `icons.rs:965-989`, + `craft_ui` buttons, `egui::accesskit_node_builder` for tab roles, and control-channel + role strings (`control.rs:113-180`). There is no explicit UI language in AccessKit. +* There are 27 `TextEdit`, 18 `DragValue`, 32 `ComboBox` and 8 `Slider` sites. CADCraft + currently has no password field and no custom `IMEOutput` producer: its editors use + the built-in `egui::TextEdit` (27 sites), including the MTEXT editor and dialog/layer + rename fields. + +### 3.7 Tests, gates and CI + +* `cargo xtask ci` = fmt, clippy `-D warnings`, workspace tests, assets, layers, wasm + (`xtask/src/main.rs:151-205`). The wasm step covers L0-L4 plus `ui-egui`, **not** + `cadcraft-web`. +* `apps/cadcraft` and `apps/cadcraft-web` are app packages; the web runner needs its + own `cargo check --target wasm32-unknown-unknown` and `trunk build --release`. +* `crates/ui-egui/tests` has **43** `*.rs` files. The affected inventory includes + `alias_dialog.rs` (an unnamed filter `TextEdit`, §8.3), `animation_effects.rs` + (two more `MouseWheel` constructors), `chrome_geometry.rs` (status text at **1200×760 + and 900×560**, `tests/chrome_geometry.rs:58`), `docking_workflows.rs`, + `workspace_focus.rs`, `hidden_command_line.rs`, `pickdrag.rs`, + `gpu_viewport_stencil.rs`, `gpu_faults.rs`, `shared_controls.rs`, + `control_methods.rs`, `menubar_hover.rs`, `context_menu.rs`/`context_menus.rs`, + `style_dialogs.rs`, `dialog_dispatch.rs`, `text_double_click.rs`, `save_version.rs`, + `rapid_clicks.rs`, `drawing_recovery.rs`, `options_dialog.rs`, `options_followups.rs`, + and `src/i18n/tests.rs`. There are no golden images; the only PNG output is opt-in + (`CRAFT_UI_SUITE_SHOTS`, `CRAFT_UI_VISUAL_DIR`). +* There are ~68 direct `CadApp::logic` call sites, many in tests. After the split, a + direct call on a bare `Context` still reports pass index 0, so it keeps running + frame work; tests that need multi-pass semantics must use the `run_ui` helper shape + used by `hidden_command_line.rs:19-30`. + +## 4. Upstream 0.37 compile incompatibilities + +These remain **after** `craft-ui` is moved to the same egui 0.37 source; before that, +the craft-ui type mismatches dominate. Re-count on the migration tip. + +| # | Anchor | Symptom | Replacement and semantics | +|---|---|---|---| +| C1 | `lib.rs:688-706` (`issue_screenshots`), `:707-740` (`collect_screenshots`) | `egui::UserData` gone; `Event::Screenshot` gone | Use `ctx.request_screenshot(move |image: Arc| …)` (or `ViewportCommand::Screenshot(ScreenshotCallback::new(...))`). Keep the token, path, reply and deadline in CADCraft; the callback hands `(token, image)` to the request's completion slot. Call `ctx.request_repaint()` from the frame tick after completion, not from a callback holding a strong `Context` (§6.5). | +| C2 | `animation.rs:78` pattern; `animation.rs:145,197`, `canvas.rs:2806`, **and tests `animation_effects.rs:102,133`** initializers | `MouseWheel` gained `source: MouseWheelSource` | Add `source: _` to the pattern (or `..`); construct test events with `MouseWheelSource::Unknown` (the platform value on X11/Wayland/Windows/web) or `Wheel`/`Trackpad` where the test is about macOS momentum. Prefer a local test helper so a future field does not break every constructor. | +| C3 | `i18n/tests.rs:407,423,442,557,607,803,912,1017,1125,1168` | `FontsView.fonts` is private | Replace `view.fonts.font(&family).characters()` with the public `view.characters(&family)` or `view.has_glyph(&FontId, c)`. Keep the test intent: the family's actual character map, not a fallback glyph. | +| C4 | `icons.rs:965-989` (`describe_control`), all `WidgetType::Button` | `WidgetType` is a deprecated alias of `accesskit::Role` | Use `egui::Role::Button`; `craft-ui` uses `Role::CheckBox`. Assert the role in a semantic test. | +| C5 | 92 `egui::Id::new(...)` calls (90 src, 2 tests: `docking_workflows.rs:303`, `options_dialog.rs:237`), e.g. `palettes.rs:615,891,957`, `exchange.rs:75-144`, `docking.rs:339,375` | Deprecated; `clippy -D warnings` fails | `egui::Id::unique(...)` (same hash; `Id::new` now calls `Id::unique`), or `ui.make_persistent_id(salt)`/`IdSalt::new` where a scope-local id is wanted. `docking.rs` must keep the exact same id string for `DockArea` and `cancel_drag`. | +| C6 | 51 `ui.id()` calls (e.g. `chrome.rs:83`, `palettes.rs:154`, `cmdline.rs:446`) | Deprecated | `ui.scope_id()`. No value change. | +| C7 | `managers.rs:77` `num_field(ui, id: impl egui::AsId, …)` | `AsId` is a **deprecated blanket trait**, not removed (`egui/src/id.rs:7-12`, exported at `lib.rs:550`); clippy `-D warnings` rejects its use | Take `impl egui::AsIdSalt` and use `ui.make_persistent_id(id)`; the temporary field id becomes scope-local. | +| C8 | `egui::Panel::top/left/right/bottom("…")` (`chrome.rs:69,180,300`, `palettes.rs:330,346`, `menus.rs:573`) | 0.37 panel constructors take `impl AsIdSalt`; `From<&str> for Id` is gone; the panel id becomes `parent_ui.scope_id().with_salt(...)` (`panel.rs:974-976`) instead of 0.36's `Id::new("…")` | Keep the literal names `cc_title_toolbar`, `cc_menubar`, `cc_status`, `cc_file_tabs`, `cc_toolsets_strip`, `cc_palettes`; do not hard-code ids in tests. The first compatibility PR includes the additive `PanelState` key migration in §7. | +| C9 | `UiBuilder::id` inside `craft-ui` | Deprecated | `scope_id`; handled in craft-libs S1, not here. | + +No other 0.37 removals appear in CADCraft source: no `AsId` removal, no +`WidgetType::TextEdit`, no `global_theme_preference_switch`, no `show_inside`, +no `ImeEvent` matching, no `ScrollArea::id_source`. + +## 5. Runtime and semantic changes CADCraft must decide + +| Area | 0.37 change (primary source) | CADCraft decision | +|---|---|---| +| Layout/frame | `Context::run_ui` multi-pass; **native and web call `App::logic` per pass**; hidden root runs `run_logic` only; completed `run_ui` reports pass index 0 | Use `current_pass_index() == 0` for frame-once work; move input to `App::ui`; never use `visible()` as a phase (§6). | +| Screenshot | callback-based (#8489) | Bounded, cancelable per-request completion slot; weak callback; capture-time binding; deadline; one file/reply (§6.5). | +| Clipboard | `eframe` gains optional `clipboard` (#8392) | Add `"clipboard"` to the desktop `eframe` features. On web, use the browser path; verify paste/copy in a real browser. | +| Fonts | `default_fonts` no longer default (#8583); eframe `system_fonts` default (#8512) | Enable `default_fonts` and `monochrome_emoji_fonts` in **S2**; keep `system_fonts` off and set `NativeOptions::system_font_fallback = false`. | +| Glyphs | kittest panics on a missing glyph by default (#8601); U+22EE absent from both 0.36.2 and 0.37 bundled fonts | Enable the monochrome emoji face; run a strict glyph sweep in **S2**; replace uncovered UI symbols with code-drawn art/text; never set a global `MissingGlyphPolicy::Tofu` for the suite. | +| TextEdit | clicking another widget collapses the selection before the button runs (#8763; unmerged #8764) | Assert the **exact selected range** is formatted by the MTEXT Bold/Italic/Underline/Overline toolbar and any dialog action; pointer and keyboard paths. CADCraft's MTEXT toolbar is drawn before the `TextEdit` and uses its own `st.sel`, which may make it immune; prove it, do not assume it. If a regression is real, preserve the range in CadCraft or adopt the fork fix; do not patch by disabling selection. | +| Menus | arrow-key navigation within open menus (#8548), popup sizing passes | Re-run `menubar_hover`/`context_menu(s)`; add keyboard traversal. | +| Accessibility | `WidgetType`→`Role`, unnamed inputs fail kittest (#8555/#8589/#8611), `label_inputs_by`/`name_inputs`/`accessible_language` | Name every input reached by tests in S2; add the systematic unnamed-widget audit; put the UI language on the AccessKit root via fork E17 in S4. | +| Clipboard/PRIMARY | 0.37 supports X11/Wayland PRIMARY (#8428); Wayland clipboard #8657/E4; **W15P is new** | Require winit W15P + egui E4/P2b in the final pins; test the native matrix; do not default to a PRIMARY-only fallback (§9). | +| Web | `DroppedFile::web_file` opt-in (#8669) | eframe enables it on wasm; assert the feature is in the **wasm** graph and add `cargo check -p cadcraft-web`. | +| Scroll/animations | kinetic scrolling (#8662/#8663), repaint-burst fix (#8595), style sizing (#8617/#8445) | Re-run animation on/off tests; apply wheel/zoom once and reuse the resulting view for later passes (§6); measure idle frames; keep explicit spacing overrides. | +| Hidden window | pinned eframe runs `PassMode::LogicOnly` for an overdue root; **#8748 is closed and unmerged**; no deferred viewports in CADCraft | Keep control/LISP/autosave/screenshot deadlines alive on logic ticks; handle input only in a known UI pass. Do not claim #8748 behavior. A fork that later adopts an overdue full pass must pin that exact revision and re-run the hidden tests. | + +## 6. Root `Ui`, multi-pass and logic-only semantics + +### 6.1 Rules + +1. `CadApp::ui(&mut self, ui: &mut egui::Ui)` remains the only root-`Ui` entry. + Panels are added in the existing order; dialogs/windows/areas continue to use + `ui.ctx()`. No code calls `Context::root_ui` inside `ui` (it would panic, #8535). +2. `frame_tick(ctx) := ctx.current_pass_index() == 0`. It is true on pass 1 and on + every logic-only tick, and false on later passes. It is the only frame-once guard. + Do **not** use `cumulative_frame_nr` for this: `run_logic` does not increment it, so + a hidden tick and the next full frame share a number and would duplicate work. +3. Input-dependent work (shortcuts, clipboard mirror, dropped files, canvas zoom + from wheel events) runs in `App::ui` on the first pass, never in `App::logic`. + `run_logic` leaves events/time stale; reading `dropped_files` or `Event::Paste` + there replays a user action on every hidden tick. +4. Consume-based background work runs on **every** `App::logic` call: the control + mpsc drain, opening-job receiver, screenshot completion receiver, GPU-fault + `take`, and the Plot/Hatch resume flags. These cannot duplicate because each item + is consumed once. They must not be hidden behind the pass-index guard, or a fresh + control reply, opening result or GPU fault arriving during a later pass would + starve until the next frame. +5. Deadline/time-based work (LISP slice, autosave, queued-screenshot issue, workspace + persistence, spacemouse poll, native-menu poll) runs on `frame_tick`. Autosave and + the screenshot queue are additionally idempotent by their own deadlines; the guard + keeps pacing deterministic rather than relying on that. +6. UI is never shown from `logic`. The opening spinner currently drawn at + `lib.rs:580` moves into `draw()` as a foreground `Area`; `opening::show` keeps its + rendering code but is called from `ui`. +7. Per-pass handlers that do run twice must be idempotent by a saved value, a + `shown_*` field or a consumed queue. `control::begin_frame` still clears the + observed-widget list at the start of each UI pass. +8. Input events are removed only after the app handled them: `raw.dropped_files` + always; `Copy`/`Cut`/`Paste`/`MiddleClickPaste` only when + `!ctx.egui_wants_keyboard_input()` (the guard `menus::shortcuts` uses). A focused + `TextEdit` still receives them. Do not clear `Text`/`Key` before `CadApp::ui` + because the command line reads them there. + +### 6.2 Split (design, not code) + +```rust +fn frame_tick(ctx: &egui::Context) -> bool { + ctx.current_pass_index() == 0 // pass 1 or a logic-only tick; 0 after completion +} + +pub fn logic(&mut self, ctx: &egui::Context) { + // Consume-based: every call, never behind the guard. + self.drain_control(ctx); + self.finish_opening(); + self.collect_screenshots(ctx); + self.gpu_fault_tick(ctx); + managers::plot_window_resume(self, ctx); + hatchdlg::resume(self); + + if frame_tick(ctx) { + // Once per layout frame or hidden tick. + self.session.lisp_tick(); + if self.session.lisp_busy() { ctx.request_repaint(); } + self.close_request_frame(ctx); + self.apply_theme_fonts_zoom(ctx); + self.issue_screenshots(ctx); + self.autosave_tick(ctx); + if !self.synthetic.is_empty() { ctx.request_repaint(); } + } +} + +pub fn ui(&mut self, ui: &mut egui::Ui) { + let ctx = ui.ctx(); + if frame_tick(ctx) { + self.ui_input(ctx); // shortcuts, clipboard mirror, dropped files + self.frame_wheel(ctx); // apply fresh wheel/zoom exactly once + self.frame_cap_wait(); // pacing belongs to a painted pass + } // later passes draw the already-updated state; never re-inject consumed wheel input + i18n::with_language(self.language(), || self.draw(ui)); +} +``` + +`ui_input` clears `raw.dropped_files` after opening them, and calls +`menus::shortcuts` (which consumes shortcuts and clipboard events). `frame_wheel` +runs `animation::unsmooth_scroll` when animations are off and applies the frame's +wheel scroll/zoom once. Later passes render the resulting view; they neither re-inject +events nor reapply a cached delta in `canvas.rs`. `frame_cap_wait` is moved out of `raw_input_hook` because the hook also +runs on logic-only ticks where no paint will happen. + +`raw_input_hook` keeps only work that is safe without a pass: the desktop theme +override, `cmdline::capture_tab`, and synthetic-event injection. Injection drains +`self.synthetic` once into `RawInput`; eframe preserves that input in +`pending_raw_input` across logic-only ticks, so repeated ticks do not duplicate the +same event. Add a test that a queued synthetic event is applied once after a hidden +tick followed by a shown frame. + +### 6.3 Side-effect placement + +| Work | Anchor | Class | Why / test | +|---|---|---|---| +| LISP slice and repaint while busy | `lib.rs:538-541` | frame tick | One slice per layout frame; a hidden window keeps it alive. Test a LISP routine and a two-pass frame plus a minimized tick. | +| Close-request / CancelClose | `lib.rs:542-545` | frame tick | `run_logic` updates viewport close/focus; cancel must work while hidden. | +| Font install, theme/animation/zoom | `lib.rs:546-577` | frame tick (guarded `shown_*`) | Applied before pass 1; repeated calls are no-ops. Tests call `logic` repeatedly and expect a theme change. | +| Control queue | `lib.rs:664-687` | every call (consume) | Requests/replies must not duplicate; each mpsc item is handled once. Test one command in a two-pass frame mutates once, and a request queued between passes is answered without waiting for the next frame. | +| Opening jobs + spinner | `lib.rs:579-580`, `opening.rs:77-97` | receiver every call; spinner in `ui` | Worker results are consumed once; drawing is UI. Test the spinner shape appears in a `run_ui` output and no `Area` is created from `logic`. | +| Synthetic repaint | `lib.rs:581-583` | frame tick | Synthetic events are drained in the hook; request a repaint while they remain. | +| Screenshot collection/issue | `lib.rs:584-585`, `:688-740` | collection every call; issue frame tick | Correlation/deadline survive multi-pass; one request → one callback → one reply. | +| GPU faults | `lib.rs:586`, `autosave.rs:94-103` | every call (`take`) | Device errors arrive between frames; `save_now` runs once per fault. | +| Plot/Hatch resumes | `lib.rs:587-588` | every call (flags/take) | State-machine continuation; a second pass must not re-open a dialog twice. | +| Autosave | `lib.rs:589`, `autosave.rs:85-90` | frame tick | `Autosaver::tick` is time-based; after scheduling a write it records `written`, so it is idempotent, but one tick per frame keeps pacing deterministic. (SAVETIME 0 disables autosave; it does not write twice.) | +| Shortcuts + clipboard mirror | `lib.rs:590`, `menus.rs:629-700`, `clipboard.rs:29-60` | `ui` first pass | Input-derived; a hidden `run_logic` must not replay stale `Event::Paste` or copy twice. | +| Dropped files | `lib.rs:591-597` | `ui` first pass, clear after | **Current hazard**: stale `dropped_files` would re-open on every hidden tick and twice in a two-pass frame. Test one drop, then two hidden ticks. | +| Wheel/scroll/zoom input | `lib.rs:568-570`, `animation.rs:104-130`, `canvas.rs:1334,1396` | `ui` first pass only | Apply the input once; later passes draw the updated view/ScrollArea state without re-injection. Assert exactly one notch equals one view/scroll step under forced two-pass layout, animations on/off. | +| Spacemouse poll (host) | `main.rs:80`, `spacemouse.rs:61-80` | frame tick | `poll` copies `shared.motion` and re-applies it per call; one poll per frame. Test with a counter. | +| Native menu poll (macOS) | `main.rs:74-76` | frame tick | Rebuilds enablement a few times per second; not per pass. | +| Workspace persistence (host) | `main.rs:81,93` | frame tick (logic only) | `save_if_changed` is idempotent but must not run per pass; `save_on_exit` stays in `on_exit`. Remove the duplicate call from `App::ui`. | +| Web inbox drain / dirty bridge | `apps/cadcraft-web/src/main.rs:33-44` | drain every call; dropped files in `ui`; dirty report frame tick | DOM reporting is idempotent; dropped files are input and must be read once. | +| `raw_input_hook` frame cap/synthetics | `lib.rs:601-625` | per logic callback | Runs in `update` and `update_logic_only`; move the frame-cap wait to `ui` pass 1; synthetic injection is drain-based and preserved in pending input. | + +### 6.3.1 Host and web frame token + +The host `eframe::App::logic` implementations are outside `CadApp`, so they use the +same public token rather than a new scheduler: + +* native `apps/cadcraft/src/main.rs:68-84`: call `self.cad.logic(ctx)` on every + `App::logic` invocation; wrap `native_menu.poll`, `spacemouse.poll`, + `persist_workspace(false)` and the `quit_requested` check in + `if ctx.current_pass_index() == 0`. Remove `persist_workspace` from `App::ui` + (`main.rs:91-94`), which currently runs it a second time. `spacemouse.poll` copies + `shared.motion` rather than taking it (`spacemouse.rs:61-80`), so without the guard a + second pass applies the same motion twice. +* web `apps/cadcraft-web/src/main.rs:31-44`: `Inbox::drain` (background results) runs + on every logic call; `Bridge::report_dirty` runs on `frame_tick`; `take_dropped` + moves to `Shell::ui` and clears `raw.dropped_files` after reading them. Remove the + duplicate `report_dirty` from `Shell::ui`. `Inbox::take_dropped` currently clones + `raw.dropped_files` without consuming them (`upload.rs:71-77`), so it would read the + same files twice in a two-pass frame and on every hidden tab tick. +* `raw_input_hook` remains per logic callback on both hosts (see §3.3); only the frame + cap moves to the first painted pass. + +Add host/web counters in S3 tests: one spacemouse poll per frame, one native-menu +poll per frame, one dirty report per frame, and one dropped-file read per event. + +### 6.4 Screenshot callback correlation and bounds + +State per admitted request (owned by `CadApp`, with paths/replies/deadlines kept in +the app): + +``` +ShotRequest { + token: u64, path: Option, reply: Sender, deadline: f64, + viewport: ViewportId, session_generation: u64, + expected_px: (u32, u32), expected_ppp: f32, + slot: Arc, // one completion slot per request +} +ShotSlot { state: Mutex, wake: egui::Context } +ShotState { canceled: bool, result: Option, String>>, bytes: usize } +``` + +`issue_screenshots` waits for `canvas.building()` and at least three accepted UI frames (an intentional settling unit, never three discarded passes), then +calls `ctx.request_screenshot` with a closure holding **`Weak` only**: + +```rust +let slot = Arc::downgrade(&request.slot); +ctx.request_screenshot(move |image: Arc| { + if let Some(slot) = slot.upgrade() { + let wake = slot.complete(image); // stores image/result, releases the lock + if let Some(ctx) = wake { ctx.request_repaint(); } // Context lives in the slot + } +}); +``` + +The callback must not capture a `Context` strongly: the callback is itself stored in +that context's viewport commands, so a strong `Context` would form a cycle. It must +not encode PNGs, write files, run commands, or hold a lock while waking. + +`collect_screenshots` drains the slot results on every logic call, checks the token +and request/session binding, and replies with `save_screenshot(&image, path)`. Binding: + +* CADCraft has ROOT only: bind to an explicit CadApp session/document generation. + The callback supplies pixels only; viewport identity is request-owned, not callback + metadata. Reject generation changes/close and never reuse an old result for a new document; +* At issuance refresh expected geometry from the target UI content rect and ppp, + after the settling wait. If no fresh UI geometry exists, request a paint before issue. + Revalidate a monotonic resize/DPI/zoom/surface epoch before saving; derive scale from + returned pixels and unchanged logical bounds, including E2 fitting. A mismatch is: + reply `{"ok":false,"error":"window geometry changed during capture"}` and release + the image rather than silently pairing it with a newer request; +* a late callback whose slot is canceled or dropped releases the image without a + file or reply; a timed-out request leaves no file and cannot later write one; +* admission limits: at most 4 pending/queued requests and at most 64 MiB aggregate + reserved in-flight plus completed image bytes (64 MiB per-image maximum as well). + Reserve checked expected width×height×4 bytes at admission, adjust before issue after + a geometry change only if capacity permits, otherwise reject. Requests over the + limit are rejected immediately with an error reply. `pending_bytes` is decremented + on drain, timeout, cancellation and dropped slots; +* cancel all slots on close/viewport loss and in `on_exit`; cancellation is a flag, + not a process kill. `now_ms` is 0 on wasm, so the deadline uses + `web_time::Instant` on web, never stale InputState::time; do not leave an infinite + web deadline. + +CADCraft adopts the shared craft-ui CL-2 completion-slot API (the eleven existing craft-ui creative consumers' +approach; DeckCraft, FigCraft and ArtCraft Native keep local adapters). +Request records, paths, PNG serialization, timeouts and the reply schema stay in +CADCraft. If the provisional CL-2 API is unavailable, S3 is blocked rather than +growing a second divergent helper. + +Regression assertions: + +* two requests in one frame, distinct paths, both files have the harness's pixel size + and each reply names its own path; +* a request while a popup forces a second pass produces exactly one file; +* a timed-out request leaves no file and cannot later write one; +* a minimized/hidden window reports the documented timeout, or succeeds through a + forced hidden paint (`RequestPaintWhileHidden`/screenshot), never hangs; +* admission overflow returns an error and does not grow memory; +* a completion after resize/DPI change returns the geometry-mismatch error; +* a late callback after close writes nothing. + +kittest fulfils `ViewportCommand::Screenshot` in `Harness::handle_screenshots` +(0.37 `egui_kittest/src/lib.rs:909-947`), so routing/bounds are testable without a +real window. + +### 6.5 Multi-pass and logic-only tests + +* `two_pass_frame_runs_frame_logic_once`: force a discard pass (open a ComboBox or + menu); assert one non-idempotent counter/LISP slice and one control execution. + Mutation: move the LISP slice into an unguarded call; the test must fail. +* `logic_tick_with_unknown_visibility`: call `run_logic` with + `minimized=None, occluded=None`; queue a control `ping`, a screenshot deadline and + one LISP slice; assert all progress and no UI pass. +* `logic_tick_with_visible_true`: same with `minimized=Some(false), occluded=None`; + this is the case the old `visible()` predicate got wrong. +* `hidden_painted_two_pass_frame`: with `minimized=Some(true)`, run a full `run_ui` + with a forced second pass; assert frame work once, input once, and the final pass + renders the once-updated view without applying wheel movement again. +* `hidden_logic_ticks_do_not_reopen_dropped_files`: one shown frame with a dropped + file, then two `run_logic` ticks; the file opens once. Mutation: move drop handling + into `logic`; the test must fail. +* `hidden_logic_ticks_do_not_paste_twice`: one `Event::Paste`, a shown frame, then + two `run_logic` ticks; the paste command runs once. +* `fresh_control_between_passes`: request a discard in pass 1, enqueue a control + request before pass 2; it is answered in pass 2, not starved. +* `control_queue_answers_while_minimized`: `ping`/`ui.inspect` replies arrive on + `run_logic` ticks and no UI pass occurs. +* `opening_spinner_is_drawn_in_ui`: the spinner `Shape` appears in a `run_ui` output; + no `Area` is created from `logic`. +* `spacemouse_one_poll_per_frame` and `web_drop_once`: counters across a multi-pass + frame and a hidden tick. +* `hidden_child_only_if_adopted`: CADCraft has no deferred viewports, so assert that + (no `show_viewport_deferred` call) and record #8748 as not applicable. If a later + fork pin adopts an overdue full pass, add a hidden-parent/deferred-child + open/update/close test with overdue redraw and unknown visibility before claiming it. +* `undo_and_save_reopen_still_single_step`: command → undo → redo → save → reopen via + control methods; assert entity counts and DXF bytes, not just `ok`. +* `direct_logic_tests_do_not_silently_skip_frame_work`: audit the ~68 direct + `CadApp::logic` call sites; tests whose intent is a frame use the `run_ui` helper. + +## 7. Panels, docking, IDs and persistence + +* Keep the panel order from §3.3. The root `Ui` is full-window; panels consume space + before `CentralPanel`, so a panel id or separator/clip change moves the canvas rect. + Assert `canvasRect` equals the window minus the top/bottom/left chrome at + **1600×1000** (new test) and keep the existing status-bar geometry at + **1200×760 and 900×560** (`chrome_geometry.rs:58`). +* Keep the panel id strings `cc_title_toolbar`, `cc_menubar`, `cc_status`, + `cc_file_tabs`, `cc_toolsets_strip`, `cc_palettes`. Do not rename them during the + `AsIdSalt` move; tests and `ui.elements` refer to behaviour around them. +* **Panel `PanelState` migration (first compatibility PR).** 0.36 keyed a panel's + `PanelState` by `Id::new("cc_x")`; 0.37 keys it by + `parent_ui.scope_id().with_salt(IdSalt::new("cc_x"))` (`panel.rs:974-976`). CADCraft + currently sets `persist_egui_memory=false` and its top-level panels are + `exact_size(...)`/non-resizable, so no `PanelState` is currently file-persisted. The + first compatibility PR nevertheless owns the saved-key migration and performs it + additively before the first UI pass: if the new key has no `egui::PanelState` and + the old `Id::unique("cc_x")` key does, + copy the old value to the new key; keep the old key for rollback. Seed a + `Context` with an old-key `PanelState` in a test and assert the new key sees it. + This prevents a one-time width reset if eframe memory persistence is enabled or a + panel becomes resizable, and covers saved test fixtures/other consumers. +* `DockArea::new(egui::Id::new("cadcraft-docking"))` and + `cancel_drag::(ctx, egui::Id::new("cadcraft-docking"))` must migrate together + to `Id::unique` with the same string. A mismatch leaves a drag active after a + workspace replacement; `docking_workflows.rs` already tests host cancel. +* `DockArea::direction` is an **explicit opt-in visual policy** from craft-libs + §7.7/CL-3. CADCraft passes `DockDirection::LeftToRight` by default and + `RightToLeft` only in S5b if chrome mirroring is implemented. Never reverse saved + tab order to simulate RTL. Preserve serialized split first/second, axes, ratios, + floating rectangles and logical tab order; painting, hit tests, drag insertion, + accessibility order and trailing resize grips must use the same computed + rectangles; physical splitter direction is unchanged. Test LTR/RTL click, drag, + reorder, resize, keyboard focus, protected floating groups and an unchanged + serialized workspace; deliberately mirror paint without hit coordinates to prove + detection. +* Docking layout persistence is `Workspace` JSON (`docking.rs:74-130`), not egui ids. + Assert `ui.dock`, `ui.set`, save/reload with floating groups, stacks and hidden + panels after the craft-ui update; changing direction must not rewrite the JSON. +* Narrow-window behavior remains: toolbar overflow (`chrome.rs:96-120`) keeps every + command reachable; `chrome_geometry.rs` must keep passing at 1×/2×. +* There are no secondary native viewports (`show_viewport_deferred`/immediate viewport + use), so fork E1 (`ViewportHooks`) is unused. “Viewport” in CADCraft code + (`canvas.rs`, `vpscale.rs`, `render/lib.rs`) means a paper-space layout viewport. + +## 8. Fonts, glyphs, language, RTL and accessibility + +### 8.1 Fonts and glyph coverage + +* S2 enables `default_fonts` and `monochrome_emoji_fonts` on the workspace `egui` + dependency. Without the latter, `⌘` (U+2318) has no bundled face (it was in + `emoji-icon-font`, previously unconditional). This is a visible glyph change and + therefore part of the compatibility PR, not a later “no behaviour change” stage. +* `⇧` (U+21E7) is only in Hack (Monospace), while `Button::shortcut_text` uses the + Button text style (Proportional body in CADCraft). Decide in S2: draw the macOS + modifier symbols in code (the WCS chevron already is, `canvas.rs:2558`), keep `⇧` + only in monospaced contexts, or use text on macOS. Do not silently enable tofu. +* Split the coverage test by source, because bundled fonts cannot cover every catalog: + * bundled-font sweep: every non-ASCII character in `i18n/*.tsv` that is not in a + `can_show`-gated script, plus `shortcut_label` outputs, must have a glyph in the + Proportional or Monospace family under `default_fonts` + `monochrome_emoji_fonts`, + without the system-font prepend (deterministic on CI/web); + * pinned-fixture sweep: Arabic/Thai/CJK/Japanese/Hebrew/color-emoji fixtures from + §8.4 loaded into a test `Context` cover the gated languages’ catalog characters. + * `theme::can_show` (`theme.rs:640-647`) remains the gate for `ar`/`zh*`/`ko`; + record which languages are gated and which fixtures were installed. +* Keep `theme::install_fonts`' caps (`MAX_UI_FONT_BYTES`) and file reads unchanged. + Do not adopt the 0.37 `GlyphRasterizer`/`FontProvider` APIs; set + `NativeOptions::system_font_fallback = false` explicitly and do not enable eframe's + `system_fonts` feature. This keeps CADCraft's capped app-controlled chain + authoritative and avoids the #8512 system-font discovery cost. +* Keep `FontsView` access test-only through `view.characters(...)`/`view.has_glyph`. + +### 8.2 Language and RTL + +* In `CadApp` (frame tick or first UI pass, before the AccessKit tree is built), call + `ctx.set_language(Some(language.to_owned()))` when the resolved language changes + (fork E17). This places the BCP 47 tag on the AccessKit root; the web runner's + speech/hidden-input language uses it through E18. +* Do not adopt `craft-i18n`; CADCraft owns its TSV catalog and `with_language`. +* Arabic: keep Arabic **text** rendering (bidi + shaping, E5/E12/E13) and verify + `ar.tsv` screens. `Style::right_to_left` (E9) mirrors egui menus, scroll bars, + checkboxes, combo boxes and popups, but not CADCraft's custom chrome or docking. + S4 keeps `right_to_left = false` (Arabic UI stays LTR with correctly shaped text, + documented in `docs/localization-parity.md`). S5b may enable it only with app-owned + chrome mirroring and craft-ui `DockArea::direction`; until then do not claim an RTL UI. +* Logical text is authoritative. `crates/fonts/src/bidi.rs`/`mtext.rs` compute visual + runs for rendering from logical text (`visual_runs`, `char_levels`); clipboard, + DXF/DWG save, accessibility and search continue to use logical strings. Never feed + display-bidi (visual-order) text into shaping, the clipboard or `IMEOutput`. + Test an Arabic MTEXT: copy, save/reopen and compare the logical string; no doubled + bidi and no reordered clipboard. +* AccessKit language test: after switching to `pt-BR`/`ar`, assert the root node's + language and `Options::language`; text-run direction assertions belong to fork E17. + +### 8.3 Accessible names and roles (S2, not S4) + +* kittest 0.37 defaults `check_accessibility = true` and + `missing_glyph_policy = MissingGlyphPolicy::Panic` + (`egui_kittest/src/builder.rs:50-51`); `check_accessibility()` runs after every + `run`/`run_steps` (`lib.rs:519-520`). Existing `Harness` tests therefore cannot be + green if an input is unnamed or a glyph is missing. +* Known failure: `alias_dialog.rs:35-37` renders the Alias dialog; its filter is + `ui.text_edit_singleline(&mut st.filter)` (`aliasdlg.rs:164-165`) after a separate + `ui.label("Filter")` with no `labelled_by`/placeholder. Name it in S2. +* Use `Response::labelled_by(label_response.id)` or + `ui.label_inputs_by(label.id, |ui| …)` for rows whose label CADCraft draws; + `hint_text` is acceptable for placeholder-labelled search fields, not numeric entry. +* Keep `icons::describe_control` labels/tab roles; migrate `WidgetType` to + `Role::Button` and assert `get_by_role`/`get_by_label` for a toolbar button, a toolset + tab and a dialog input. +* Add one test per dialog group that opens the dialog and asserts + `harness.unnamed_widgets()` is empty. Do not disable `check_accessibility` globally. + If a third-party widget cannot be named, name it at the call site and record why; a + per-test `with_accessibility_check(false)` requires an explicit enumerated exception, + a linked issue and a removal condition, not a suite-wide switch. +* S2 runs the full `cargo xtask ci` plus `cargo test -p cadcraft-ui-egui --all-targets` + and keeps `MissingGlyphPolicy::Panic`; S4 adds broader language/AccessKit-language + coverage, not the first green gate. + +### 8.4 Pinned licensed font fixtures + +Use the fixtures pinned by the egui plan §10.6; fetch them into the task evidence +directory, never commit them into CADCraft, and retain adjacent licences. CADCraft’s +own policy remains: optional central `craft-fonts` via `CRAFT_FONTS_DIR`, no font +binaries in this repository, no new `ATTRIBUTION.md` row for test-only external files. + +| Fixture | Source/path | SHA256 | License | +|---|---|---|---| +| Arabic | craft-fonts `2481a8a324b986a45954e90045ed1f8f2efaaf3c` `fonts/noto-sans-arabic/NotoSansArabic.ttf` | `63111b5b2e074dd48cc67692e0a2726d86ee94c1c37fe8598257b7b4e87e869e` | OFL-1.1 | +| Thai | craft-fonts `fonts/noto-sans-thai/NotoSansThai.ttf` | `5a1c559bb539583c8a1fd99d1c5b9491e5e14478c9cd2bd0970d5c3096cc9ef8` | OFL-1.1 | +| CJK SC/Latin | craft-fonts `fonts/noto-sans-cjk-sc/NotoSansCJKsc-Regular.otf` | `2c76254f6fc379fddfce0a7e84fb5385bb135d3e399294f6eeb6680d0365b74b` | OFL-1.1 | +| Japanese | craft-fonts `fonts/biz-ud-pgothic/BIZUDPGothic-Regular.ttf` | `258d7156c165f2ff774b6efee637c22c3b950de0d8a10e501137061bc8085d01` | OFL-1.1 | +| Hebrew | google/fonts `8b0a1d0f5983c89bc2b93f1b5fb55f9e252744b5` `ofl/notosanshebrew/NotoSansHebrew[wdth,wght].ttf` | `7ef36a2c3593758cdb622e1bdef4f84523e92fbc3ccc667438dd80ff54c2de88` | OFL-1.1 | +| Color emoji | googlefonts/noto-emoji `22e564626297b4df0a40570ad81d6c05cc7c38bd` `fonts/NotoColorEmoji.ttf` | `39ee3c587e10e89669b9ff32703261d10d5f9c4dd5ad147b6b5a1c5200591817` | OFL-1.1 | + +Bundled Latin/emoji comparisons use the exact `epaint_default_fonts` bytes from the +pinned baseline/candidate: `Ubuntu-Light.ttf` +`80307b8da7649aa4ee4d484b232140e3ce1ec0ca093073d3c53c8f5a5ced7a70` and +`NotoEmoji-Regular.ttf` +`415dc6290378574135b64c808dc640c1df7531973290c4970c51fdeb849cb0c5` (record licence +files). Fix the Hebrew variable axes to weight 400/width 100. All fixture hashes and +licences enter the evidence manifest; no script may silently skip for a missing fixture. + +## 9. Clipboard, PRIMARY, IME and keyboard + +* Add `"clipboard"` to the desktop `eframe` features and assert + `cargo tree -i arboard` reaches `egui-winit`. Regression: Copy in CADCraft, paste + into another app; copy text elsewhere, paste into CADCraft (the DXF-text mirror + remains in `clipboard.rs`). The feature-graph assertion is the detector; a kittest + copy/paste assertion cannot prove the OS clipboard because kittest does not run + eframe's OS clipboard. +* **Required PRIMARY design.** The final winit pin must contain W15P (PRIMARY on the + existing connection/event queue, per-seat lifecycle, SCTK 0.19.2 + `PrimarySelectionManagerState`/device/source/offer, no second regular + `wl_data_device`), and the final egui pin must contain E4/P2b routing + `get_primary_text`/`set_primary_text` through it. The current candidate winit + `8b35388b` has no PRIMARY path, and the current candidate egui `a4f9a17f` lacks the + later P2b integration; neither is acceptable as the final pin. +* PRIMARY acceptance (native, on wlroots/Hyprland, KWin, Mutter, plus X11): + pointer- and keyboard-triggered ownership (including keyboard-only focus), serial freshness, owner/seat changes, empty/absent offers, + self-paste (marker MIME, no deadlock), canceled/slow transfers, unchanged selection + not reclaimed, password selection never offered. CADCraft defaults to XWayland when + `DISPLAY` is set (`main.rs:301-306`); `CADCRAFT_WAYLAND=1` is the native Wayland + exposure. A PRIMARY-only fallback is allowed only as a **measured** fallback with + explicit owner acceptance, not as the default limitation. +* Middle-click: `Event::MiddleClickPaste { pos, text }` exists in 0.37. CADCraft's + command line is custom-painted, not a `TextEdit`, so decide in S5a: handle it only + when the pointer is in the command-line bar rect (stored state), insert at the buffer + end, and never start a command or duplicate a `Paste`; otherwise document the gap. + `Event::PasteImage` is ignored (no image paste). +* **E21b/P7b is a new prerequisite, not existing IME behavior.** Winit W27 exposes + `set_ime_surrounding_text`, but candidate egui-winit never calls it and + `IMEOutput` has no logical text. The final egui pin must contain E21b/P7b: an + optional transient bounded payload (≤4000 UTF-8 bytes, UTF-8-boundary safe, + preedit excluded, byte cursor/anchor remapped), a built-in `TextEdit` producer, and + a Wayland adapter sending it on text/selection/IME-owner change; clear on password, + focus loss, no IME, unavailable context and reset. Keep the final IME descriptor and + payload together across passes. It is transient and excluded from serialized debug/ + persistence output. CADCraft has no custom `IMEOutput` producer (all editors use + built-in `TextEdit`; the MTEXT editor and rename fields inherit it), so no app + model adapter is required unless one is added later. The suite inventory's custom + producers (DesignCraft/FigCraft/WordCraft/VectorCraft/EffectCraft/FilmCraft/ + DeckCraft/PhotoCraft) do not apply here: CADCraft's custom-painted command line + receives IME commits as `Event::Text` and produces no `IMEOutput`. If a custom + producer is added, + it must supply logical model text and convert its own cursor coordinates to UTF-8 + bytes; `None` placeholders are not a port. +* IME tests: `Event::Ime(ImeEvent::Commit)` into a layer-rename field and the MTEXT + editor; outgoing surrounding text updates immediately after delete+commit and + deletion-only batches; scalar/grapheme vs byte indices; supplementary emoji, + combining marks and RTL logical order; preedit exclusion and caret movement; a + 4000-byte/long-selection bound; same-window field switch and two windows; focus + loss/reset/late serial; password/no-context clear; multi-pass final-output selection. + Native Wayland Vietnamese/predictive IME verifies the protocol trace and editor + content/undo. macOS NSRange/W26 remains a separate unqualified row. +* Keep `cmdline::capture_tab` (`cmdline.rs:56-74`) and synthetic injection. Tab capture + only when `egui_wants_keyboard_input()` is false; Escape/Enter still reach the + command line (`hidden_command_line.rs`). +* Non-Latin layouts: fork E7/E19 and winit W25 report Latin letters for shortcuts; test + Cmd/Ctrl shortcuts with a Russian/Arabic layout sequence and the winit unit tests. +* Clipboard `ctx.copy_text` is an output side effect: keep the serial-guarded mirror in + the first UI pass so a two-pass frame cannot copy twice. + +## 10. GPU, wgpu, stencil and Windows + +* CADCraft uses `egui-wgpu` paint callbacks, not a custom renderer; the callback trait + is unchanged. Keep `gpu::install` error/device-lost handlers and the stencil check. +* The canvas draws at `info.screen_size_px` and relies on egui's scissor for clipping + (`gpu.rs:731-780`). Fork E2+E3 fits an oversized surface to + `max_texture_dimension_2d` and returns configure/render errors instead of panicking; + CADCraft consumes it through eframe. Add a fork `surface_fit` unit test; a real + oversized window cannot be produced in the available environment, so record it as + an unresolved hardware gate. +* Windows: `graphics::configure` forces DX12 + FXC and excludes GL. Upstream #8736 + reports FXC compile cost for 0.37's predictable-filter shader (~57 ms vs 14 ms, + about +40 ms startup). Measure CADCraft start-to-first-`ping` on the Windows VM + (fresh processes) and compare 0.36/stock 0.37/fork; report renderer construction + separately from first frame. Do not hide a regression by changing CADCraft's FXC + choice, and do not report compile duration as UI performance. +* Keep `NativeOptions.stencil_buffer = 8` (`main.rs:188,277`). The fork's surface fit + must preserve the matching stencil texture size. `gpu_viewport_stencil.rs` stays. +* Keep `WgpuConfiguration::default().with_surface_config(surface_config(...))` + (`gpu.rs:53-65`) and the `max_buffer_size` adapter workaround (`main.rs:289-297`). +* Web: `?cpu` bypasses the GPU canvas; `?webgl` forces GL; the web runner passes + `None` for stencil (`apps/cadcraft-web/src/main.rs:133-137`). Do not change that + assumption without a WebGL2/browser test. +* Environment wording: this server has **no inherited display session and no running + compositor**. The parent can access an i915 PCI `8086:3E96` render node + (`/dev/dri/renderD128` readable/writable, `graphics-device-inspection.json`), but a + worker sandbox may hide `/dev/dri`, and access alone is not a qualified Vulkan/ + OpenGL/real-GPU result. Windows remains the Microsoft Basic Render Driver + (software D3D12). Report software and real-GPU tables separately. + +## 11. Dependencies, fork patches and vendors + +### 11.1 Target pins (final revisions unresolved) + +Before pinning, the fork and winit revisions must be reachable from the organisation +repositories; Cargo fetches a repository by `rev`, so a commit that exists only in +`aiongg/*` is not publishable as `storytold/*`. Publication is a separate human +approval. The final revisions are placeholders until that approval: + +```toml +craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "", features = ["serde"] } +craft-docpath= { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "" } +egui = { version = "0.37.0", features = ["default_fonts", "monochrome_emoji_fonts"] } +egui_kittest = "0.37.0" +eframe = { version = "0.37.0", default-features = false } +egui-wgpu = "0.37.0" +``` + +and one root redirect for exactly the crates in the graph: + +```toml +[patch.crates-io] +egui = { git = "https://github.com/storytold/egui", rev = "" } +epaint = { git = "https://github.com/storytold/egui", rev = "" } +emath = { git = "https://github.com/storytold/egui", rev = "" } +ecolor = { git = "https://github.com/storytold/egui", rev = "" } +epaint_default_fonts = { git = "https://github.com/storytold/egui", rev = "" } +egui-wgpu = { git = "https://github.com/storytold/egui", rev = "" } +egui-winit = { git = "https://github.com/storytold/egui", rev = "" } +eframe = { git = "https://github.com/storytold/egui", rev = "" } +egui_kittest = { git = "https://github.com/storytold/egui", rev = "" } +winit = { git = "https://github.com/storytold/winit", rev = "" } +``` + +Trim any crate not in the graph; an unused `[patch]` entry is a Cargo **warning**, not +a build error, but it is still removed. `egui_extras` is only a dev-dependency of +`craft-ui` and is not in CADCraft's graph. No `branch` and no `path` may appear for a +published pin; the audit's local path patches are evidence only. The final fork/tag +must also replace the fork's own root `[patch] winit = { branch = ... }` with the +reviewed winit rev, or document an immutable equivalent. + +`rust-version`: keep the workspace at `1.90` for engine crates and add +`rust-version = "1.95"` to `apps/cadcraft` and `apps/cadcraft-web`, matching +`crates/ui-egui/Cargo.toml:7` and `README.md:126-127`. + +### 11.2 craft-libs PR #199: port hunks, never the tree + +`git diff --numstat 49190884 b68de8c -- crates/ui` is **78 insertions, 222 deletions** +(14 files). The stale trees `b68de8c`/`ec8840a` are missing current `craft-platform` +and revert docking/printing/security fixes (`c67b1e1` floating-group permission, +`4f85efb` bounded `shown_names`, and related settings/printer hardening). Never merge +or check them out as the target. + +S1 creates a fresh branch from current `main` `49190884` and applies only the egui-API +hunks from `8614e4a` (plus the relevant README change from `1764bca`): + +* `WidgetType`→`Role`; +* `UiBuilder::id`/`Ui::id`→`scope_id`; +* `Id::new`→`Id::unique`; +* dev-deps on the final egui 0.37 revision (also bump workspace egui and UI egui_extras/egui_kittest dev-dependencies together); +* the docking fixture's U+22EE→`…` workaround; +* the CL-2 capture-slot prototype and CL-3 opt-in `DockArea::direction` API. + +Preserve the current docking-permission and print-name hardening. Run +`cargo test -p craft-ui --all-features`, the workspace tests, strict clippy and the +all-feature wasm check; merge; publish an immutable commit; then pin CADCraft to it. +`craft-docpath` is unchanged between `82a24b0` and `49190884`, so both CADCraft +craft-libs dependencies can use the same new revision. + +### 11.3 Language work #198 + +Consume #198 through the final fork/winit pins (E5/E12-E21, W17-W27, plus E21b/W27); +do not add a `craft-i18n` dependency or adopt its UI model. CADCraft keeps its TSV +catalog and `with_language`, and uses only `Context::set_language` and +`Style::right_to_left` from the fork. Any app-side #198 adoption is separate. + +### 11.4 Fork patch disposition for CADCraft + +Provenance: `egui/PATCHES.md` on the candidate and commits `0.37.0..a4f9a17f`. The +final pin must carry the whole reviewed series; the table records what CADCraft +exercises and what would catch removal. + +| Patch | What it does | CADCraft use | Keep / remove condition | Test | +|---|---|---|---|---| +| E1 | eframe `ViewportHooks` | Not used (no secondary windows) | Keep for craft-tablet; remove if upstream adds it | fork unit test | +| E2+E3 | fit oversized wgpu surface; setup errors | via eframe wgpu | Keep while emilk/egui#8361 open | fork `surface_fit`; CADCraft GPU gate unresolved | +| E4 | Wayland clipboard via winit | `CADCRAFT_WAYLAND=1` | Keep while #8657 draft; **requires W15 and W15P PRIMARY** | regular + PRIMARY native matrix | +| E5/E12/E13 | bidi shaping/order/marks/caret, graphemes | Arabic UI/text | Keep while #8577 open | fork text tests; Arabic UI label/MTEXT | +| E6 | IME focus not interrupted | Text fields | Keep | kittest IME commit | +| E7 | Windows VK letter keys | Non-Latin shortcuts | Keep; needs W25 | Windows VM | +| E8 | TextEdit glyph overshoot | Thai/Vietnamese/Arabic input | Keep | tall-glyph TextEdit | +| E9 | `Style::right_to_left` chrome | §8.2 decision | Keep; activation staged | fork RTL chrome; CADCraft opt-in only | +| E10 | web key/lang | Browser keys | Keep | browser/manual | +| E11 | vello_cpu single-thread raster | Not used | Keep | fork | +| E14/E15/E16 | fallback baseline, line breaker, cursor affinity | CJK/Indic editing | Keep | fork; CJK screens | +| E17 | `Context::set_language`, AccessKit language, bidi runs | Used in S4 | Keep until upstream | root-language test | +| E18 | eframe `Frame::set_ui_language`/web fallback | Web speech/lang | Keep | web/manual + language test | +| E19 | Latin layout chars for shortcuts | Non-Latin layouts | Keep; needs W25 | Windows/Linux manual | +| E20 | widget text in Ui language | All translated widgets | Keep | language render test | +| E21 | IME delete-surrounding bytes | macOS/Wayland IME | Keep; needs W26/W27 | fork + native IME | +| E21b | bounded outgoing IME surrounding text + TextEdit producer + Wayland adapter | All built-in `TextEdit` editors | **Required new prerequisite**; keep with W27 state/serial | outgoing-state/focus/password/UTF-8/4000-byte tests | +| P2b | E4 PRIMARY integration | Wayland PRIMARY | Required with W15P | native PRIMARY matrix | + +Each “remove when” is a review condition: remove a patch only after the replacement is +verified in the same consumer graph and the table is updated. + +### 11.5 winit + +CADCraft needs the winit fork because E4/E19/E21/E21b call APIs in W15/W15P/W25/W26/ +W27 not in crates.io 0.30.13. Patch winit to the **final reviewed rev**, not a branch; +keep `winit` 0.30.13-compatible `dpi`. `apps/cadcraft/Cargo.toml:29` keeps its direct +winit dependency for `EventLoopBuilderExtX11`; the patch makes it and eframe's winit one +package. + +Do not delete W1-W15 or W17-W27 tests (`tests/ime_patches.rs`, +`tests/pointer_patches.rs`). W15P needs pure seat/offer/serial/owned-marker tests, a +`WAYLAND_DEBUG=1`/test-compositor protocol trace, and the native Hyprland/KWin/Mutter +matrix. W16 (pinch/rotation) remains outside this cut. + +### 11.6 Vendors and lockfile checks (native and wasm) + +* CADCraft has no vendor directory and no external path patch. Nothing is deleted. +* craft-libs' `vendor/krilla`, `vendor/hayro*` are PDF-only and unrelated; leave them. +* pdfcraft's vendored `egui-wgpu`/winit are another repository's responsibility; do not + remove them here or copy their patches into CADCraft. +* After regeneration, assert on both target feature sets: + * native: `cargo tree -d`, `cargo tree -i egui`, `cargo tree -i winit`, + `cargo tree -i arboard`, `cargo metadata --format-version 1`; + * wasm: `cargo tree --target wasm32-unknown-unknown -d`, + `cargo tree -i egui --target wasm32-unknown-unknown`, + `cargo tree -e features --target wasm32-unknown-unknown -p cadcraft-web`, + and `cargo metadata` filtered/interpreted for `apps/cadcraft-web`; + * no duplicate egui/emath/ecolor/epaint/accesskit 0.36/0.37 pairs; + * every egui-family source is `git+https://github.com/storytold/egui?rev=`; + winit is the storytold fork; craft-libs crates share one final rev; + * `harfrust` may legitimately appear at 0.12 (egui) and 0.14 (`cadcraft-fonts`); + * `arboard` is present through `egui-winit` because `clipboard` is on; + * the wasm graph has `egui/web_file`, eframe wgpu/accesskit and no native-only + `arboard`, X11 or Wayland packages; assert these, and test a negative fixture with + a missing family patch or a moved branch (it must fail the graph gate); + * no manifest contains `path = "/home/…"` or a `branch =` for a published pin; + * `cargo check --locked` succeeds on the final native and wasm locks. +* Keep the old coherent lock (`a08fd7c`) in evidence for rollback; do not delete the old + dependency set from review notes. + +Shared handoff requirement: consume complete egui P8 including P7c's selected #8763/#8748 +fixes and P7 key-release pairing. MTEXT selection-to-format assertions and ordinary held-key +layout/focus changes are S5/S6 gates. Built-in TextEdit's E21b producer inherits P7b's owner +identity even when unavailable, Available-empty distinction, committed W27 clear, latest-pass +None, stale-epoch invalidation and current-epoch serial-mismatch behavior. Add a native test +contrasting stale epoch with current-epoch mismatch; do not implement fork internals locally. + +## 12. Staged implementation PRs + +Publication order assumes each repository has its own approval. No stage is pushed, +tagged, merged or posted by this plan. + +``` +storytold/winit final rev (bounded W15/W15P, W17-W27 lifecycle) + -> storytold/egui final P8 (P2b/P7/P7b/P7c included) + -> craft-libs S1: current main + PR199 API hunks + CL-2F/CL-3 + -> published craft-libs final rev + -> cadcraft S2 (atomic compatibility; strict names/glyphs; clipboard/fonts; + PanelState migration; native+wasm graph) + -> S3 lifecycle/screenshot/host + -> S4 language/AccessKit/RTL text + -> S5a PRIMARY/clipboard matrix (required for parity) + -> S5b RTL activation (optional, only with chrome mirroring) + -> S6 docs + acceptance evidence +``` + +* **S1 craft-libs** — fresh branch from `49190884`; apply only the `8614e4a` API + hunks; preserve docking-permission/printing hardening; add CL-2 capture slot and + CL-3 opt-in direction API; tests/clippy/wasm; merge; publish a final rev. +* **S2 CADCraft compatibility** — final pins; `clipboard`, `default_fonts`, + `monochrome_emoji_fonts`; app `rust-version`; C1-C8 mechanical migrations; panel + `PanelState` migration; accessible names and glyph sweep; lockfile regeneration; + shared CL-2F callback migration including basic drain/timeout/reply handling and + native **and wasm** graph assertions. It is atomic and must compile and pass + `cargo xtask ci` (and `cargo test -p cadcraft-ui-egui --all-targets`). It is **not** + a “no behaviour change” stage: the emoji font, accessible names and panel keys change. +* **S3 CADCraft lifecycle** — `frame_tick` split, opening spinner move, host/web frame + token, once-per-frame wheel application, shared capture lifecycle stress, dropped-file fix, + bounded counters, tests and mutation probes. +* **S4 CADCraft text/accessibility/language** — broader glyph coverage via pinned + fixtures, `Context::set_language`, AccessKit language, Arabic shaping checks, docs + row for RTL status. +* **S5a CADCraft clipboard/PRIMARY (required for parity)** — middle-click decision and + the native Wayland/X11 PRIMARY matrix against W15P/E4/P2b. A measured fallback is + recorded only if native evidence proves the design cannot meet a required platform. +* **S5b CADCraft RTL activation (optional)** — `DockArea::direction`, + `Style::right_to_left` and app-owned chrome mirroring; otherwise document the gap. +* **S6 evidence and docs** — performance report, screenshots, before/after matrices, + docs revision history, remaining gates. + +Rollback: keep the `a08fd7c` source/lock pair as the last coherent 0.36.2 state. S2 is +the compatibility boundary; after it, revert the stack (source + lock) rather than the +lock alone. S3, S4, S5a and S5b are independent commits and can be reverted without touching the pins. +No partial rollback to 0.36 is promised after S2 until the stack is reverted as a unit. + +## 13. Verification plan + +### 13.1 Compilation and repository gates (through `task.py run/status/finish`) + +* `cargo xtask ci` (fmt, clippy -D warnings, workspace tests, assets, layers, wasm). +* `cargo check --workspace --all-targets --locked`. +* `cargo test -p cadcraft-ui-egui --all-targets` **alone** (UI crate dev/test font + features self-sufficient). +* `cargo check -p cadcraft-web --target wasm32-unknown-unknown` and + `trunk build --release` from `apps/cadcraft-web`; the wasm32 target is installed, + but `trunk`/`wasm-bindgen` and a browser are not, so the bundle/browser run remain + blocking unknowns. +* Native **and** wasm graph checks from §11.6. +* Optional features: `cargo test -p cadcraft-ui-egui --features …` where the crate has + features (currently none); craft-libs all-features/craft-ui all-features in S1. +* Windows: a task-specific craft-windows clone, separate target/evidence, native + `cargo test --workspace --locked`, `cargo build --release -p cadcraft`, then the GUI + workflow in §13.5. Do not reuse the baseline executable as new-source evidence. + +### 13.2 Behavioural regressions + +| Behaviour | Assertion | Anchor | +|---|---|---| +| Multi-pass frame side effects | one non-idempotent counter/LISP slice/control execution per frame with a popup open | §6.5 | +| Hidden logic | control replies, autosave deadlines and screenshot timeouts while minimized/unknown visibility; dropped file opens once | §6.5 | +| Fresh queues | a control request queued between passes is answered without waiting for the next frame | §6.5 | +| Hidden child (conditional) | if a later fork pin adopts an overdue full pass, parent occluded/overdue plus child open/update/close; CADCraft currently has no child viewports, so record N/A rather than claim #8748 | §6.5, §14.5 | +| Screenshot correlation | two tokens/two paths; timeout/cancel/admission/geometry mismatch | §6.4 | +| Command/undo | create → undo → redo → entity count | engine + UI control test | +| Save/reopen | `examples/apartment.dxf` opened, edited, saved to temp, reopened; entity count and file bytes | `apps/cadcraft`/CLI | +| Theme/language | light/dark/System, `en`/`de`/`zh`/`ar`, no text loss; AccessKit language | `lib.rs`/`i18n` tests | +| Panels/docking | canvas rect, tab order, floating group move, workspace reload, unchanged direction JSON | `docking_workflows`, `chrome_geometry` | +| Keyboard/focus | Tab/Shift-Tab, Enter/Space activation, Escape/Enter command line, IME commit | `workspace_focus`, `hidden_command_line`, new IME | +| Clipboard | Copy/Cut/Paste across apps, DXF mirror, X11/Wayland PRIMARY matrix | `clipboard.rs`, native | +| Selection | MTEXT toolbar formats the exact selected range; pointer and keyboard paths | `#8763`/`#8764` test | +| GPU/stencil | clipped viewport pixels, surface/stencil sizes, device-loss notice | `gpu_viewport_stencil`, `gpu_faults` | +| Fonts/glyphs | bundled sweep + pinned fixtures; no tofu/clipped combining marks | §8.1/§8.4 | +| Web | `?cpu`, `?webgl`, open/download/iframe host | browser matrix | + +### 13.3 Screenshots, geometry and semantic accessibility + +* Capture before/after PNGs for 1600×1000 and 900×560, 1.0/1.25/2.0 scale, dark/light, + Start tab and a drawing with Layers/Properties/Tool Sets open, an in-window menu, the + command line with keywords, MTEXT editor, Layer Properties Manager and Options. +* Compare geometry semantically first (panel rects, canvas rect, text bounding boxes, + no overlap), then pixels with stated tolerances: exact for flat fills/sampled + backgrounds; changed-pixel ratio ≤0.5% per window and no text box moving more than + 2 px unless the change is the intended 0.37 style adjustment. Record both images and + diff counts; never update a golden without a stated intent. +* Accessibility: `unnamed_widgets()` empty for every opened dialog, expected + role/label/value for representative inputs, tab traversal order, focus visibility and + AccessKit root language after a switch. +* kittest `with_context_setup` is the supported way to install deterministic fonts/ + options before the first pass (#8686); do not use `Context::run_logic` to initialise + fonts or read input twice. + +### 13.4 Mutation probes + +Break the implementation once per feature and record the failure: +1. move only the deliberately non-idempotent LISP slice/counter into a second-pass + call; the two-pass test must fail (the other queue work is consume-based and would + still run once, so it is not a valid probe); +2. map two screenshot tokens to one path; the correlation test must fail; +3. remove the `clipboard` feature; the **`cargo tree -i arboard`** assertion must fail, + and a manual OS-clipboard check must fail (a kittest copy/paste assertion is not a + detector); +4. put dropped-file handling back in `logic`; the hidden-tick/reopen test must fail; +5. remove an accessible name; `unnamed_widgets` must fail; +6. change `Role::Button` to `Role::Unknown`; the role test must fail; +7. remove `monochrome_emoji_fonts`; the `⌘` glyph test must fail; +8. leave `DockArea` and `cancel_drag` ids different; the drag-cancel test must fail; +9. skip the panel `PanelState` migration; the seeded old-key test must fail; +10. reapply the wheel delta in pass two; the two-pass view-position assertion must fail; +11. send a screenshot completion after resize/DPI change; the mismatch test must fail; +12. remove the 4000-byte IME bound/preedit exclusion; the IME tests must fail. + +A probe is not a deliverable; record the failure output in evidence. + +### 13.5 Platform matrix + +| Platform | What can be qualified here | Limits / required preparation | +|---|---|---| +| Linux host, headless | compile, clippy, tests, kittest, `cadcraft-cli perf`, wasm compile | no inherited `DISPLAY`/`WAYLAND_DISPLAY`, no compositor/Xvfb; not native focus/IME/picker/GPU evidence | +| Linux X11 | functional GUI and focus/clipboard after provisioning Xvfb + a window manager; record software renderer/versions | Xvfb/manager absent at inspection; must be provisioned as an isolated managed job; results labelled virtual-display/software | +| Linux Wayland | W15/W15P/PRIMARY, W27/E21b IME after provisioning Weston/Sway/wlroots/KWin/Mutter | compositors absent; XWayland default path is separate; native capability evidence required | +| Linux GPU | parent-visible i915 `8086:3E96` renderD128; qualify Vulkan/OpenGL/wgpu via a managed probe | worker sandbox may hide `/dev/dri`; access alone is not GPU acceptance; software and real-GPU tables separate | +| Windows craft-windows VM | native compile/tests, GUI launch, screenshots, control channel, Basic Render Driver | software D3D12; IME/keyboard layouts only if the guest has them; no real-GPU claim | +| Browser | `cargo check`/`trunk build`; interactive run needs a pinned browser and local server | no browser/trunk installed; WebGPU/WebGL backend must be recorded | +| macOS | release CI only, no local hardware | native focus/IME/picker/traffic-light layout unresolved | +| FreeBSD | CI workflow/VM only | no local evidence | + +Native focus/IME/file-picker behavior and real-GPU performance are not established by +headless rendering or the software renderer. macOS/browser rows remain unresolved unless +real evidence is produced. + +## 14. Performance plan + +### 14.1 Environment and identities + +Record for every run: exact CADCraft SHA, `Cargo.lock` SHA256, feature set, +`CRAFT_FONTS_DIR` (or absent), fixture (`examples/apartment.dxf`, synthetic counts), +OS/kernel, Rust version, GPU or software renderer/driver, display scale, viewport size, +build profile (`release`), and whether the canvas is GPU or `?cpu`. On Linux record +`lscpu`, `uname -a`, DRM device identities and `vulkaninfo --summary` when available, +plus the Xvfb/compositor/window-manager versions. On Windows record the guest adapter +and `dxdiag` Basic Render Driver. Never mix software and real-GPU tables. + +### 14.2 Baselines and attribution + +* **B0**: `a08fd7c` with the coherent 0.36.2 egui family and craft-ui `5c874d51`. +* **B0u**: B0 in a measurement-only worktree with `frame_nr`/pass/CPU counters and + idle-frame instrumentation backported (never committed). +* **B0e**: `1977a493` engine/format dependency state measured with `cadcraft-cli perf` + (engine-only, no UI) to attribute tiny-skia/skrifa/harfrust/quick-xml changes. +* **C0**: the S2 compatibility commit. It includes the clipboard/font/accessibility/ + panel-key changes, so it is not a pure dependency baseline. +* **C0u**: C0 with the same measurement-only counters. +* **Cs**: C0's source compiled against **stock egui 0.37 `841a328b`** (and stock + craft-ui API port) where it compiles; if a fork-only API is already used, use the + same operation in a pinned minimal adapter/harness and explain the semantic + difference. Record B0/Cs/C0 source and lock identities. +* **C1..Cn**: each later stage, compared with C0 for that stage. + +Do not compare B0 directly with S4 as if only the UI changed. Use B0 vs B0e for the +non-UI delta, C0 vs Cs for fork-vs-stock, and B0 vs C0 for the total compatibility +delta. `ui.inspect` does not expose `cumulative_frame_nr`; C0u/B0u add the counter in +the measurement worktree, and S3 may add bounded production fields. `cpu_usage` is +available only from the host `eframe::Frame`, so the host records it; `CadApp::logic` +does not receive a `Frame`. + +### 14.3 Matched operations and sample counts + +Use the suite minimums: **≥10 warmups**, **≥30 steady samples** for discrete +operations, **≥20 fresh startup processes**, and **≥1,000 frames** before reporting a +frame p99. Interleave baseline/candidate (ABAB) on the same machine and lock; report raw +samples, median, p95, p99 (only with ≥1,000 frames), peak RSS, allocations/texture +uploads and idle CPU/repaint count. Exclude the first run after a cold shader/font cache; +record cold vs warm separately. + +1. **Engine control**: `cadcraft-cli perf 200000` and + `cadcraft-cli perf examples/apartment.dxf`; repeat the single-shot command in a + driver script (the CLI has no repeat flag), ≥10 warmups + ≥30 samples, parse the + timing table. +2. **Idle**: open the sample, no input, 60 s; frames from the measurement counter, + process CPU (`/proc//stat`, `Get-Process` CPU), peak working set; animations on + and off; ≥1,000 frames. +3. **Pan/zoom**: 200 view steps across five zoom bands via `cmdline.input` ZOOM/PAN or + `ui.drag` (there is no `view.set`), a redraw each; `frameMs`, `buildMs`, `drawMs`, + `meshMs`, `prims`; ≥1,000 frames. +4. **Selection/properties**: 10,000 synthetic entities, open Properties, pan, re-select; + `drawMs`/`frameMs`; include the `#8763`/`#8764` selection action. +5. **Command line/menus**: 200 commands and 50 menu opens; frame time and idle repaint. +6. **Dialogs**: open/close Layers, Options, Plot, MTEXT 20 times; frame time and + allocation/upload counters; ≥1,000 frames for the p99 claim. +7. **Save/reopen**: open `examples/apartment.dxf`, edit, save to temp, reopen; wall + time and `drawMs`. +8. **#8731 text layout/tessellation**: 120 ASCII labels + 120 rounded-rect controls + adapted to CADCraft's chrome; measure tessellation CPU/instruction cost per frame on + B0/Cs/C0; test mixed/color rows and keep color-emoji/wrap semantics. +9. **#8736 Windows startup**: fresh process, poll the control port until `ping` + succeeds; renderer construction separate from first frame; ≥20 fresh processes on + B0/Cs/C0. Record the absolute +ms for FXC. +10. **#8512 fonts**: measure CADCraft startup with `system_font_fallback=false` and + record that no system-font discovery is performed; compare a stock-eframe default + only as a separate row. Do not enable `system_fonts` to “fix” a number. + +### 14.4 Thresholds and noise policy + +Predefine and freeze before candidate measurement: + +* investigate a repeatable **>5% median** or **>10% p95** latency regression; +* investigate **>5% peak-memory** increase and any new idle busy-loop/audio underrun; +* for sub-millisecond measurements require the relative increase to exceed **0.05 ms** + or corroborate with stable instruction/allocation counts; +* app-specific gates may be stricter, never looser. CADCraft engine operations + ≤ +5% median and UI `frameMs`/`cpuMs` p95 ≤ +10% are the floor. Startup uses the same + median/p95 rule; **no “+100 ms” allowance** hides the ~+40 ms FXC cost. If +40 ms is + accepted, it is a documented tradeoff with owner review, not a threshold change. +* Same Rust/toolchain, `release`, features/fonts/fixture, GPU/driver/DPI/window size; + no concurrent builds/noisy workload; rerun noisy comparisons; never expand a + tolerance after seeing a failure; never report compile duration as UI performance. +* Screenshots use the §13.3 pixel policy; a 0.37 style change (#8617/#8445) is documented + with a before/after crop before acceptance. + +### 14.5 Upstream-reported issues (reported, not measured here) + +* #8731: plain-text tessellation ≈+60% in 0.37, color-glyph branch in + `Tessellator::tessellate_text`; measure operation 8 on B0/Cs/C0; a row-level fast path + is a fork decision, not a CADCraft workaround. +* #8736: `egui-wgpu` 0.37 `wrap_texel_coord` makes the DX12/FXC pipeline ~4× slower to + compile, ~+40 ms startup; CADCraft defaults to FXC. Measure operation 9; do not + change the FXC choice to hide it. Clamp-only is not a valid Repeat/MirroredRepeat fix. +* #8512: system-font startup cost; CADCraft disables the provider, so record absence of + that cost rather than adding it. +* #8595: repaint-burst fix may reduce idle CPU; measure operation 2. +* #8528: cross-viewport galley cache; CADCraft has one viewport, not applicable. +* #8763/unmerged #8764: selection loss before a clicked widget. Test the MTEXT toolbar + and dialog actions with pointer and keyboard; the fork owns any selected fix, and an + app workaround must preserve the range and undo semantics with a removal condition. +* #8748: **closed and unmerged**; not behavior in the pin. Test root hidden/logic-only + behavior; a deferred-child overdue full pass exists only if a later fork pin adopts + it, with that exact revision and native evidence. Do not infer phase from `visible()`. + +## 15. Documentation updates + +* `docs/ui-parity.md`: Accessibility row — AccessKit roles/names/language tested in + kittest; native screen-reader coverage still not established. +* `docs/localization-parity.md`: Arabic row — shaping/RTL status, whether the UI is + mirrored, glyph coverage; add a revision-history line. +* `docs/control-protocol.md`: new `ui.inspect.perf` fields (frame/pass counters) and + unchanged screenshot client semantics; note screenshot admission/timeout errors. +* `README.md`: UI builds require Rust 1.95; keep the core 1.90 minimum. +* No `ATTRIBUTION.md`, contributor or asset changes are part of this migration. Keep + `@skylartaylor` username-only credit. + +## 16. Risks, blocking unknowns and acceptance + +Blocking before calling the migration complete: + +1. **Final pins/publication reachability**: only the approved final `storytold/egui`, + `storytold/winit` and `storytold/craft-libs` revisions are production pins. The + aiongg candidates are comparison inputs. The final fork/tag must replace the fork's + own moving winit branch patch. +2. **W15P/E4 PRIMARY**: the final winit rev must include W15P and the final egui rev + E4/P2b, with native wlroots/KWin/Mutter/X11 evidence. A PRIMARY-only fallback is a + measured, explicitly accepted exception. +3. **E21b/P7b IME**: the final egui rev must include the bounded outgoing surrounding + text producer/adapter; otherwise IME is not complete and the limitation is explicit. +4. **craft-libs S1**: port only `8614e4a` hunks onto `49190884`; never merge + `b68de8c`/`ec8840a`; preserve `c67b1e1`/`4f85efb`/`craft-platform` and README. +5. **Toolchain**: egui 0.37 requires Rust 1.95; apps must state it. The server's + default `rustc` is 1.99.0 and 1.95.0/1.96.1/1.97.1 are installed; `trunk`, a browser + and a compositor/Xvfb are absent. +6. **Display/renderer**: no inherited display; parent-visible i915 renderD128 is + unqualified and may be hidden from a worker; Windows is software D3D12. Native focus, + IME, picker and real-GPU performance remain unresolved until managed evidence. +7. **Web**: `cargo check -p cadcraft-web` and a wasm graph gate are required; `trunk` + build and a real browser run are blocking unknowns. +8. **Windows/macOS native**: Basic Render Driver only; focus/IME/picker/traffic-light + layout and real-GPU behavior unqualified; macOS hardware unavailable. +9. **Fonts**: monochrome emoji changes `⌘`; the `⇧` decision may change macOS labels; + pinned fixtures are external and must keep their licences. `can_show` gating remains. +10. **RTL**: `Style::right_to_left` alone does not mirror CADCraft chrome; use craft-ui + `DockArea::direction` only with app-owned mirroring; do not claim an RTL UI earlier. +11. **Screenshot on web**: `now_ms` is 0 on wasm; the new slot deadline must use a web + clock, and a browser screenshot remains a separate gate. +12. **Oversized surface**: no environment can produce a window larger than + `max_texture_dimension_2d`; E2+E3 is covered by the fork unit test only. +13. **Review/evidence**: a fresh independent adversarial review of this exact document + SHA256 is required before implementation; each implementation stage needs a fresh + adversarial review at its exact candidate revision, with regressions and measured + before/after performance. The baseline is captured before production edits. + +Acceptance criteria (all evidenced, not inferred): S2 compiles with one egui family and +one winit; `cargo xtask ci` passes with strict kittest names/glyphs; native and wasm +graph assertions pass; S3 lifecycle/screenshot/bounds tests and mutation probes pass; +S4 language/AccessKit/Arabic tests pass; S5a PRIMARY is evidenced or recorded +as an explicit fallback; before/after screenshots and performance samples meet §14.4 or +have a reviewed explanation; remaining platform gates are listed with the exact +unverified behaviors. + +## References (primary sources inspected) + +* egui 0.37.0 changelog (`CHANGELOG.md` at `841a328b`) and crate sources: + `Context::run_ui`/`run_logic`/`end_pass`/`current_pass_index`, `PanelState`/`Panel::id`, + `ScreenshotCallback`, `MouseWheelSource`, `Role`, `Id`/`AsId`/`AsIdSalt`, + `FontDefinitions`, `MissingGlyphPolicy`, kittest `builder.rs`/`accessibility.rs`. +* Native/web eframe integrations: `native/epi_integration.rs`, `native/run.rs`, + `native/wgpu_integration.rs`, `web/app_runner.rs` at `a4f9a17f` and `767b1cad`. +* Candidate fork `PATCHES.md` and commits `0.37.0..a4f9a17f`; winit plan W15P and + `craft/0.30.13-lang`; craft-libs `49190884`, `8614e4a`, `b68de8c`, `ec8840a`. +* `/home/codex/work/ssd2/task-evidence/egui037-migration-20261022/upstream-discussions.json` + (#8731, #8736, #8763, #8748, #8535, #8489, #8555, #8583, #8512, #8428, #8657, + #8577, #8641), `lifecycle-source-audit.md`, `ime-surrounding-gap.md`, + `graphics-device-inspection.json`, and `craft-libs/docs/egui-037-suite-rollout.md`. + These are reported findings/history, not fresh measurements. + +## Appendix A — source anchors + +Manifests: `Cargo.toml:6-11,52-59`; `crates/ui-egui/Cargo.toml:7,20-32`; +`apps/cadcraft/Cargo.toml:19,29`; `apps/cadcraft-web/Cargo.toml:20,26-32`. + +App shell: `apps/cadcraft/src/main.rs:67-110` (App impl), `:185,188` (app id/stencil), +`:228-307` (options/run), `:343-348` (set_wgpu), `:301-306` (XWayland). +`apps/cadcraft-web/src/main.rs:31-58` (Shell), `:108-130` (web renderer/stencil), +`apps/cadcraft-web/src/upload.rs:70-77` (`take_dropped`). +`apps/cadcraft/src/workspace.rs:149-170` (save_if_changed/save_on_exit). + +UI lifecycle: `crates/ui-egui/src/lib.rs:195-267` (CadApp fields), `:444-455` +(finish_opening), `:536-598` (logic), `:601-625` (raw_input_hook), `:627-662` +(ui/draw), `:664-687` (drain_control), `:688-740` (screenshots), `:745-755` (now_ms). +`crates/ui-egui/src/opening.rs:77-97` (spinner Area). + +UI structure: `chrome.rs:69,180,300` (panels), `menus.rs:569-589` (menu bar), +`docking.rs:74-130,318-399` (Workspace/DockArea), `palettes.rs:330,346` (panels), +`theme.rs:314-387` (fonts), `:640-647` (can_show), `:697-713` (theme apply), +`animation.rs:75-130` (wheel/unsmooth), `icons.rs:948-989` (buttons/roles), +`cmdline.rs:56-74` (Tab), `:251-330` (keyboard), `clipboard.rs:29-60` (mirror/paste), +`mtext_editor.rs:146-164,217-224,420-455` (formatting toolbar/selection). + +GPU/control: `gpu.rs:53-65` (surface config), `:681-780` (install/callback), +`control.rs:195-215` (methods), `:255-272` (inspect), `:547-559` (save_screenshot), +`tests/gpu_viewport_stencil.rs`, `tests/gpu_faults.rs`. + +Tests: 43 files under `crates/ui-egui/tests`; see §3.7; `tests/chrome_geometry.rs:58` +uses 1200×760/900×560; `tests/hidden_command_line.rs:19-30` is the `run_ui` helper shape. + +## Appendix B — commands proposed for the parent to run (not run here) + +All build/test commands must go through `maintenance/task.py run/status/finish` with one +unique task and evidence path, e.g.: + +```sh +python3 /home/codex/work/maintenance/task.py run \ + --job-id egui037-cadcraft-ci-01 --kind build --task egui037-cadcraft \ + --source /home/codex/work/CraftApps/egui037-migration-20261022/cadcraft \ + --evidence /home/codex/work/ssd2/task-evidence/egui037-migration-20261022/cadcraft \ + -- bash -c 'cargo xtask ci' +``` + +and equivalents for: + +```sh +cargo check --workspace --all-targets --locked +cargo test -p cadcraft-ui-egui --all-targets +cargo check -p cadcraft-web --target wasm32-unknown-unknown +(cd apps/cadcraft-web && trunk build --release) +cargo tree -d +cargo tree -i egui +cargo tree --target wasm32-unknown-unknown -d +cargo tree -i egui --target wasm32-unknown-unknown +cargo tree -e features --target wasm32-unknown-unknown -p cadcraft-web +cargo metadata --format-version 1 +cargo run -p cadcraft-cli -- perf 200000 +cargo run -p cadcraft-cli -- perf examples/apartment.dxf +``` + +Use the craft-windows clone helper for the Windows stage, a fresh target/evidence +directory, `guest-exec.py`/build equivalents appropriate to CADCraft, and the existing +`task.py`/`vm.py` sequence. Do not reuse the baseline's built binary as new-source +evidence, and do not run these commands in this planning task. + + +## Local implementation update — 2026-10-23 + +The user clarified that reviewed plans remain local and implementation proceeds before publication. The earlier requirement to publish/land this plan first is superseded; pushes and PR publication still require explicit approval. + +Canonical `storytold/cadcraft` `main` now resolves to `f043540d13dac03ce895a96c78a8349f3e40bd5b`. The owned checkout was fast-forwarded to this revision. Earlier baseline SHAs and line anchors above are retained as historical review evidence; use this new revision and its exact lockfile for current-main integration and paired measurements. The original refs remain reachable. + +Preserve browser page zoom; test canvas pinch routing because this page does not add DesignCraft touch-action:none. + +The complete static delta review and exact old/new object identities are retained in the task evidence: `canonical-diffs-refresh-20261023.json` and `overnight-consumer-review-20261023.md`. This amendment claims no new build, native GUI or performance acceptance. + +## Local implementation update — 2026-10-24 (upstream review v9 resolutions) + +The v9 source review found no high-severity migration regression in the native UI +library/integration gate and listed four conservative-behaviour notes plus three +strict-glyph/accessibility gaps. This update records how the concrete findings are +resolved without widening the migration contract: + +* **Conservative capture identity stays.** `SurfaceGeometry` equality keeps + `window_focused`, `minimized` and `occluded` alongside viewport, pixels-per-point + and texture limits. Any observed focus/visibility transition can change the + presented frame, so an admitted capture is rejected rather than re-used; the + struct's documentation now states this intent instead of narrowing the epoch. +* **No-op `document.switch` stays strict.** `run_switch` bumps the document epoch + even when the requested index is already active, because the command cancels the + active command and an external client cannot prove the switch was unobservable. + The source records that the conservative bump is deliberate. +* **Invalid pass geometry invalidates the retained surface.** `note_pass_geometry` + now mirrors `note_raw_geometry`: a pass that cannot report a finite positive + pixels-per-point clears `capture_geometry` and bumps both epochs. Previously the + previous surface survived and a later admission could bind to stale geometry. +* **MTEXT Diameter is painted, not shaped.** U+2300 is absent from egui's bundled + Proportional/Monospace/emoji faces and from the web font set. The toolbar button + now paints CADCraft's own circle-and-slash glyph and carries the semantic name + "Diameter"; the document text still uses U+2300, which the drafting font covers. +* **Affected controls are named.** The MTEXT height `DragValue` gets the AccessKit + name "Text height" (its tooltip alone is not a name), and QuickCalc's conversion + `Value` field is associated with its visible label and a meaningful placeholder + instead of the unexplained "1". +* **Strict bundled-font regressions.** Focused kittest regressions render the + Diameter button, MTEXT height field and QuickCalc value row with bundled + `FontDefinitions` only (no `theme::install_fonts` system prepend), the default + `MissingGlyphPolicy::Panic` and accessibility checks. No global tofu policy or + accessibility opt-out is introduced. + +The first macOS native-menu poll now refreshes enablement immediately by +initializing that timer to `f64::NEG_INFINITY`; `now_ms` starts near zero, so the +former `0.0` sentinel suppressed the first ~300 ms of enablement. + +This amendment is source-only: it does not claim a cargo, fmt, clippy, test, wasm, +Windows/macOS or GUI result. Publication still requires fresh explicit approval +after the parent's own validation. + +### Full MTEXT toolbar font regression + +The bundled-font check also covers the entire formatting toolbar: alignment, list and tab +marks are drawn with original geometry, and the color button relies on egui’s menu affordance +instead of an uncovered arrow character. The font and color controls publish semantic names. +The whole-toolbar harness retains strict missing-glyph and accessible-name checks, so a system +font cannot hide an unsupported toolbar character. Native visual review remains required. + + +## Native MTEXT insertion follow-up (2026-10-24) + +Native Wayland job29 exercised real keyboard circle creation and MTEXT entry, the +Diameter button, callback screenshot, save, normal quit, restart and reopen. The +new entities and exact `Migration 0.37 ⌀` text survive DXF reopening. The editor +showed tofu for U+2300 even though its toolbar icon was already painted correctly. +A bounded original circle/slash `GlyphRasterizer` now supplies only U+2300 when no +installed font covers it; it keeps the stored Unicode character and cursor indexes +unchanged and works without optional font files on native/web. Font setup installs +it idempotently. A real toolbar-click/TextEdit test uses the strict bundled-font +harness and checks inserted contents and caret. Job30 validates this follow-up; +fresh native visual confirmation and review remain pending. diff --git a/docs/gaps.md b/docs/gaps.md index 44ed6f22e..a4a12cff8 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where CADCraft falls short of AutoCAD -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (Command Aliases dialog for ALIASEDIT: U12 and E5 narrowed) · **Target:** Autodesk AutoCAD 2027 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (DIMTFILL limits in the `*D` block, EXPLODE and DXF: F7 updated) · **Target:** Autodesk AutoCAD 2027 Every known shortfall, one row each, ranked within each section by user impact. This is the work list: agents pick from here (top of a section first), and remove or update a row in the same PR @@ -29,7 +29,7 @@ do them first. | F3 | **[alpha blocker]** (dynamic-block authoring is beta) No dynamic-block authoring: parameters and actions can't be added in the Block Editor. Dynamic blocks from other files are read and their references edited (visibility, distances, stretch and move, lookups, grips). BEDIT (with the Edit Block Definition dialog, which also starts new blocks), BSAVE, BSAVEAS and BCLOSE edit a static block definition, and every reference updates; REFEDIT edits a reference in place; no authoring palettes yet | issue #185 offers help | High: standard libraries are dynamic | 40–65 | | F4 | Autosave leftovers: no autosave in the web build (browser storage); autosave files are DXF, not DWG; no separate RECOVER mode in the file readers (SAVETIME, Drawing Recovery, AUDIT and RECOVER landed with #406; AUDITCTL `.adt` log landed) | `engine/src/autosave.rs`, `engine/src/audit.rs` | Low-medium (web users) | 2–4 | | F5 | Associative arrays (ASsociative, Levels, polar ROWs, ARRAYEDIT, ARRAYCLOSE) landed (#448); left: grip editing of arrays (count/spacing grips), ARRAYPATH is not associative, ARRAYEDIT has no item deletion (CTRL+click) and no Key point base points, levels only shift Z (no 3D view) | `cmd/array.rs`, `cmd/array/edit.rs` | Medium | 4–6 | -| F7 | Dimensions: no DIMSTYLE manager dialog; no smart DIM; DIMTFILL/DIMTFILLCLR, DIMALTTD/DIMALTTZ, DIMUPT and DIMTXTDIRECTION not modelled; jog height and break size not style settings; DIMBREAK breaks don't follow moved objects (style variables, DIMJOGGED, DIMJOGLINE, DIMBREAK, DIMEDIT, DIMINSPECT and QDIM modes done, #507) | `doc/src/tables.rs`, `cmd/annotate_dimedit.rs`, `cmd/annotate_qdim.rs` | Medium | 10–15 | +| F7 | Dimensions: no DIMSTYLE manager dialog; no smart DIM; DIMALTTD/DIMALTTZ, DIMUPT and DIMTXTDIRECTION not modelled; jog height and break size not style settings; DIMBREAK breaks don't follow moved objects; with DIMTFILL 1 the written `*D` block and EXPLODE keep no background mask (a WIPEOUT over the text box would), and a true-color DIMTFILLCLR is written to DXF as the nearest ACI (style variables, DIMJOGGED, DIMJOGLINE, DIMBREAK, DIMEDIT, DIMINSPECT and QDIM modes done, #507; the DIMTFILL/DIMTFILLCLR text background done) | `doc/src/tables.rs`, `cmd/annotate_dimedit.rs`, `cmd/annotate_qdim.rs` | Medium | 10–15 | | F8 | TOLERANCE leftovers (feature control frames landed, #420: model, our own symbol drawing, DXF, command-line prompts, explode, grips): no Geometric Tolerance dialog, no LEADER/QLEADER tolerance annotation, no in-place editing of a frame's contents | `cmd/tolerance.rs` | Low-medium (mechanical) | 2–4 | | F10 | MLEADEREDIT (add/remove leader), MLEADERALIGN and MLEADERCOLLECT landed, but a multileader has one landing and one block (a collection keeps the other blocks as block references beside it; MLEADERALIGN has no Options-menu preview); MLEADERSTYLE has 5 fields (block content and spline/no-leader types are per command, not in the style; block content has no attributes) | `annotate.rs`, `cmd/mleader_content.rs`, `tables.rs:417` | Medium | 8–12 | | F9 | MTEXT is typed in an on-canvas editor with a formatting toolbar (font, height, bold, italic, underline, overline, colour, alignment, lists, tab and special characters) and a ruler for indents and tab stops (#532); left: the box does not lay the text out by the indents, tabs, columns or font weights while typing (bold shows wider, overline is not drawn), no justification (attachment) or columns controls in the editor, existing MTEXT cannot be edited (no DDEDIT/MTEDIT), stacked fractions and other codes show as placeholders; no fields (FIELD, UPDATEFIELD) and no spell check (SPELL): open PR #551. Paragraph indents, tabs, bullets, numbering and columns landed (#547) | `ui-egui/mtext_editor.rs`, `engine/mtext_edit.rs` | Medium | 10–18 | @@ -43,7 +43,7 @@ do them first. | F19 | MATCHPROP Settings is a keyword prompt (no dialog) and has no material, shadow or centre-object settings; TEXTTOFRONT and HATCHTOBACK act on the current space only; OVERKILL doesn't compare polyline segments with other objects (picker and Settings, DRAWORDER Above/Under, TEXTTOFRONT, HATCHTOBACK, OVERKILL tolerance/ignore/overlap/end-to-end options landed, #439) | `props/matchprop.rs`, `draworder.rs`, `overkill.rs` | Low | 2–4 | | F20 | OPTIONS dialog done (#23): Display, Open and Save, Drafting, Selection, User Preferences and Profiles tabs, and the user profile (object snap modes, drafting toggles, Dynamic Input, PICKBOX, APERTURE, GRIPSIZE, CURSORSIZE, PICKFIRST, PICKADD, QPMODE, model and paper background, snap marker size, system cursor) survives restarts. Left: right-click customization, the autosave interval and default save version on Open and Save (the features themselves landed, #406, #421), AutoSnap magnet and tooltip toggles, colours of the other drawing-area elements, named profiles with import and export | `ui-egui/src/options/` | Low | 2–4 | | F21 | MLINE, REGION and HELIX are objects since #510, with gaps: MLEDIT tees trim square; UNION/SUBTRACT/INTERSECT only combine regions whose boundaries don't meet; regions are saved as closed polylines (no ACIS data); PURGE skips multiline styles | `draw2/mline.rs`, `inquiry/regions.rs`, `dxf_region.rs` | Low | 6–10 | -| F22 | Inquiry leftovers (COUNT across spaces and nested blocks, the QuickCalc palette and COMPARE landed, #563; MASSPROP on regions, #510): COMPARE compares the tab shown, not every layout at once, and has no COMPAREEXPORT report; QuickCalc keeps its history and variables for the session only | `file/compare.rs`, `palettes/quickcalc.rs` | Low | 3–5 | +| F22 | Inquiry leftovers (COUNT across spaces and nested blocks, the QuickCalc palette and COMPARE landed, #563; MASSPROP on regions, #510): COMPARE compares the tab shown, not every layout at once (COMPAREEXPORT, the comparison as a new drawing, landed); COMPAREEXPORT puts the two sets of objects in model-space groups, where AutoCAD puts them in blocks; QuickCalc keeps its history and variables for the session only | `file/compare.rs`, `palettes/quickcalc.rs` | Low | 3–5 | | F23 | No Sheet Set Manager, eTransmit, Share, Traces, Markup Import, Activity Insights | menu items uncovered | Low-medium (team workflows) | 40–70 | | F24 | No 3D: UCS, 3D views, orbit, solids, surfaces, meshes, sections, visual styles, materials, lights, rendering (150 menu items) | [parity-checklist](parity-checklist.md) | Medium (3D users) | 300–450 | @@ -150,6 +150,9 @@ Detail: [localization-parity.md](localization-parity.md). | Date | Change | Summary | |---|---|---| +| 2026-10-25 | minor | DIMTFILL limits: with DIMTFILL 1 (the drawing's background color) the written `*D` block and EXPLODE keep no background mask (a WIPEOUT over the text box would), and a true-color DIMTFILLCLR is written to DXF as the nearest ACI: F7 updated | +| 2026-10-24 | minor | COMPAREEXPORT (typed, or Export on the comparison bar) opens the comparison as a new untitled drawing, `compare_ vs .dwg`: this drawing's objects in one group and the other drawing's differing objects in a second group, in the comparison colors (green, red, gray for objects in both), block contents and style colors included: F22 narrowed | +| 2026-10-24 | minor | DIMTFILL and DIMTFILLCLR: a dimension's text background (none, the drawing's background color, or a color) hides what is drawn under the text; SETVAR, DIMOVERRIDE and style fields, DIMSTYLE groups 69 and 70 in DXF from R2007 (CADCraft xdata before), and a SOLID in the `*D` block and from EXPLODE for DIMTFILL 2: F7 narrowed | | 2026-10-22 | minor | APPLOAD typed or from Tools ▸ Load Application opens a `.lsp` file browser starting in the last-used folder (kept in the preferences); a picked file loads interactively like a typed name (routines that ask for input wait), the web says to drop the file (no file system), and the JSON form `{path}` reads the path as given and returns the commands the file defined, never opening a dialog (#1820): E1 narrowed | | 2026-10-16 | minor | ALIASEDIT typed or from Tools ▸ Customize opens the Command Aliases dialog (user aliases next to the built-in ones; Add, Edit, Remove; OK, Cancel, Apply), #1381: U12 and E5 narrowed | | 2026-10-16 | minor | MOCORO (Tools ▸ Express Tools ▸ Move/Copy/Rotate): select objects and a base point once, then Move, Copy, Rotate, Scale, Base and Undo until eXit, one undo step; JSON form `{base, ops}`: E3 narrowed | diff --git a/docs/localization-parity.md b/docs/localization-parity.md index de033b966..121c4d5ec 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** minor (Brazilian Portuguese row updated and terminology reviewed; previously: Swedish catalog completed with the 95 strings added since; previously: Swedish catalog added: complete catalog, menu headings, native-speaker terminology review; previously: complete current Korean catalog with Hangul font coverage) · **Target:** Autodesk AutoCAD 2027 for Mac (26.0) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (the command line enables the OS IME and handles composition/commit, so single-line TEXT edits accept CJK input; previously: Brazilian Portuguese row updated and terminology reviewed; previously: Swedish catalog completed with the 95 strings added since; previously: Swedish catalog added: complete catalog, menu headings, native-speaker terminology review; previously: complete current Korean catalog with Hangul font coverage) · **Target:** Autodesk AutoCAD 2027 for Mac (26.0) How far CADCraft's interface and drawing text are from AutoCAD's languages. Summarised in [ROADMAP.md](../ROADMAP.md#languages); work items in [gaps.md](gaps.md#localization). @@ -37,9 +37,12 @@ shaping and right-to-left text through TrueType, vertical SHX text styles. lam-alef, ZWNJ, marks; Hebrew), in TEXT and MTEXT including wrapping (#287). Left-to-right lines are still placed one glyph per code point: **no shaping** for Devanagari and other Indic scripts, and **no vertical text**. SHX fonts are not read; our stroke font stands in. -- **Text input:** egui/winit IME. Several upstream IME fixes are queued as PRs (#110–#119: - macOS Korean, emoji picker, Windows caret placement, X11 and Wayland), so CJK input is not yet - reliable on every platform. +- **Text input:** egui/winit IME. The command line — which collects single-line TEXT edits and + other prompt text — now enables the operating system IME while a prompt asks for text and shows + composition (pre-edit) text until it is committed, so Chinese, Japanese and Korean input works + there (#1761); egui's own text fields handle IME themselves. Several upstream IME fixes are + queued as PRs (#110–#119: macOS Korean, emoji picker, Windows caret placement, X11 and Wayland), + so CJK input is not yet reliable on every platform. ## Languages @@ -86,6 +89,7 @@ each language, which needs humans. Translations parallelise fully once the catal | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | Command line IME: enabled while a prompt collects text, composition shown but not saved, commit inserted once; single-line TEXT edits accept CJK input (#1761) | | 2026-10-19 | minor | Brazilian Portuguese: row brought up to date (it still read 0%); native-speaker terminology pass on 69 rows (Bind as Integrar, prompt keywords and SPDX kept in English, Thaw Layer, Snap type, Revolve fixed; snap, viewport, grip, offset, layer on/off and multiline wording made consistent with Brazilian usage) | | 2026-10-15 | minor | Greek (`el`) catalog: 1,330 rows, context/plural/locale/font tests; review pending | | 2026-10-14 | minor | Swedish completed: 95 rows for strings added since (Hatch and Gradient dialog, Block Editor, REFEDIT, multileader editing, Options selection settings, MTEXT editor, recent files); 1,330 rows, every key of the Ukrainian reference catalog | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 5e1c7cf7f..9c2186c90 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,20 +1,19 @@ # Parity checklist -> **Generated:** 2026-10-15 by `cargo xtask parity`. Do not edit by hand. -> **Generated:** 2026-10-16 by `cargo xtask parity`. Do not edit by hand. +> **Generated:** 2026-10-25 by `cargo xtask parity`. Do not edit by hand. Menu-breadth parity: a reference menu item counts as covered when a CADCraft command is registered under the same menu label. It measures breadth, not depth; see [target-app-parity.md](target-app-parity.md) for the weighted assessment. -**Menu breadth: 283 / 491 items (58%)** · 427 registered commands. +**Menu breadth: 296 / 491 items (60%)** · 441 registered commands. | Menu | Items | Covered | % | |---|---|---|---| | File | 22 | 13 | 59% | -| Edit | 13 | 11 | 85% | -| View | 75 | 31 | 41% | +| Edit | 13 | 12 | 92% | +| View | 75 | 36 | 48% | | Insert | 11 | 5 | 45% | -| Format | 27 | 25 | 93% | -| Tools | 85 | 60 | 71% | +| Format | 27 | 26 | 96% | +| Tools | 85 | 66 | 78% | | Draw | 87 | 55 | 63% | | Dimension | 28 | 28 | 100% | | Modify | 113 | 52 | 46% | @@ -33,7 +32,6 @@ Menu-breadth parity: a reference menu item counts as covered when a CADCraft com - File > Markup Import... - File > Share... - Edit > Undo Group of commands -- Edit > Cut with Base Point - View > Pan > Point - View > Orbit > Constrained Orbit - View > Orbit > Free Orbit @@ -68,14 +66,9 @@ Menu-breadth parity: a reference menu item counts as covered when a CADCraft com - View > Render > Light > New Distant Light - View > Render > Light > Light Glyphs - View > Render > Materials Browser -- View > UCS Icon > On - View > UCS Icon > Origin - View > UCS Icon > Properties... -- View > ViewCube > On - View > ViewCube > Settings -- View > Attribute Display > Normal -- View > Attribute Display > On -- View > Attribute Display > Off - View > Lights - View > Enter Full Screen - Insert > PDF Underlay... @@ -85,9 +78,7 @@ Menu-breadth parity: a reference menu item counts as covered when a CADCraft com - Insert > ACIS File... - Insert > Drawing Exchange Binary... - Format > Transparency -- Format > Thickness - Tools > Spelling -- Tools > Inquiry > Volume - Tools > Update Fields - Tools > Edit Xref in Place - Tools > New UCS > World @@ -101,14 +92,8 @@ Menu-breadth parity: a reference menu item counts as covered when a CADCraft com - Tools > New UCS > Z - Tools > Named UCS... - Tools > Customize > Interface (CUI)... -- Tools > Customize > Edit Command Aliases (PGP)... - Tools > Import Settings - Tools > Export Settings -- Tools > Express Tools > Convert Text to Mtext -- Tools > Express Tools > Break-line Symbol -- Tools > Express Tools > Move/Copy/Rotate -- Tools > Express Tools > Explode Attributes to Text -- Tools > Express Tools > Replace Block... - Tools > Express Tools > Super Hatch > Image... - Tools > Express Tools > Super Hatch > Xref... - Tools > Express Tools > Super Hatch > Select Existing Object diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 1e529cda9..6b5407d60 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Target-app parity: CADCraft vs AutoCAD 2027 -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (RECTANG centre construction mode, #1127; numbers not re-measured) · **Target:** Autodesk AutoCAD 2027 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (COMPAREEXPORT; numbers not re-measured) · **Target:** Autodesk AutoCAD 2027 The authoritative assessment of how close CADCraft is to AutoCAD, and how much Opus 5.5 work remains. [ROADMAP.md](../ROADMAP.md) summarises it, [gaps.md](gaps.md) lists every shortfall, @@ -156,7 +156,7 @@ presence: an option that prints "not available yet" doesn't count. | Hatch and gradients | 5% | 35% | 15–25 | pick points with islands; 23 patterns of ~85; gradients render flat; origin, gap tolerance, separate hatches, island styles and HATCHEDIT options (#451); Hatch and Gradient / Hatch Edit dialogs (no Preview or Inherit Properties) | [gaps](gaps.md#features) | | Blocks, attributes, xrefs, groups | 9% | 25% | 80–120 | static blocks and attributes; ATTSYNC, BATTMAN editing, DATAEXTRACTION/EATTEXT (#433); xrefs (#525); groups (#417); Block Editor for static blocks (BEDIT, BSAVE, BSAVEAS, BCLOSE); REFEDIT; dynamic block references read and edited; no dynamic-block authoring, XCLIP, DesignCenter | [gaps](gaps.md#features) | | Layouts, viewports, plotting | 9% | 35% | 35–50 | layouts, per-VP layers, page setups, plot styles (CTB/STB), Plot dialog (#160), Plot Preview and PUBLISH (#186), plotter devices (#564), PDF with text, layers and sheets (#567, #571), polygonal/object/clipped viewports (#453), annotative scales (#468); no MVIEW Restore, no system printers | [gaps](gaps.md#features) | -| Inquiry and utilities | 4% | 30% | 30–45 | DIST, AREA, ID, LIST, MEASUREGEOM, CAL, FIND, COUNT across layouts, QuickCalc, COMPARE (#563), MASSPROP on regions (#510), autosave/recovery, AUDIT, RECOVER (#406), OPTIONS (#23); no SPELL (open PR #551), ETRANSMIT | [gaps](gaps.md#features) | +| Inquiry and utilities | 4% | 30% | 30–45 | DIST, AREA, ID, LIST, MEASUREGEOM, CAL, FIND, COUNT across layouts, QuickCalc, COMPARE (#563) and COMPAREEXPORT, MASSPROP on regions (#510), autosave/recovery, AUDIT, RECOVER (#406), OPTIONS (#23); no SPELL (open PR #551), ETRANSMIT | [gaps](gaps.md#features) | | Parametric constraints | 2% | 50% | 20–30 | all 12 geometric and the dimensional constraints, own solver; DOF report, G2 Smooth, ellipse/spline/block geometry and CONSTRAINTINFER landed (#546) | [geometry-parity](geometry-parity.md#constraint-solver--50) | | 3D modelling, visualisation, rendering | 5% | 2% | 300–450 | no UCS, 3D views, orbit, solids, surfaces, meshes, sections, visual styles, materials, lights or rendering; 150 of the 491 menu items | [gaps](gaps.md#features) | | Collaboration and cloud (Sheet Set Manager, Share, Traces, Markup Import, Activity Insights, DWG Compare) | 2% | 5% | 40–70 | none of these exist | [gaps](gaps.md#features) | @@ -223,6 +223,7 @@ DWG/DXF corpus (files from people who own them); black-box checks in AutoCAD of | Date | Change | Summary | |---|---|---| +| 2026-10-24 | minor | Inquiry and utilities row: COMPAREEXPORT exports a comparison to a new drawing; parity percentages and hours not re-measured | | 2026-10-22 | minor | Draw row: RECTANG gained a centre construction mode (`Center` keyword, `center: true`) beside its corner mode (#1127); parity percentages and hours not re-measured | | 2026-10-12 | minor | Hatch row evidence: Hatch and Gradient / Hatch Edit dialogs; percentages and hours not re-measured | | 2026-10-12 | minor | Blocks row evidence: Block Editor for static blocks (BEDIT, BSAVE, BSAVEAS, BCLOSE); percentages and hours not re-measured | diff --git a/packaging/env.sh b/packaging/env.sh index ab01ce4c5..07d69a068 100644 --- a/packaging/env.sh +++ b/packaging/env.sh @@ -49,7 +49,7 @@ warn() { # Copy licence and readme files that exist into a package directory. copy_docs() { local dest="$1" f - for f in README.md LICENSE LICENSE-MIT LICENSE-APACHE COPYRIGHT; do + for f in README.md LICENSE LICENSE-MIT LICENSE-APACHE COPYRIGHT NOTICE ATTRIBUTION.md; do if [[ -f "$ROOT/$f" ]]; then cp "$ROOT/$f" "$dest/"; fi done copy_font_licences "$dest" diff --git a/packaging/linux/flatpak/ai.storyteller.cadcraft.yml b/packaging/linux/flatpak/ai.storyteller.cadcraft.yml index e70d80009..75a6ff690 100644 --- a/packaging/linux/flatpak/ai.storyteller.cadcraft.yml +++ b/packaging/linux/flatpak/ai.storyteller.cadcraft.yml @@ -50,6 +50,8 @@ modules: - install -Dm644 packaging/linux/ai.storyteller.cadcraft.desktop /app/share/applications/ai.storyteller.cadcraft.desktop - install -Dm644 packaging/linux/ai.storyteller.cadcraft.mime.xml /app/share/mime/packages/ai.storyteller.cadcraft.xml - mkdir -p /app/share/icons && cp -R assets/app-icon/hicolor /app/share/icons/ + - install -Dm644 NOTICE /app/share/doc/cadcraft/NOTICE + - install -Dm644 ATTRIBUTION.md /app/share/doc/cadcraft/ATTRIBUTION.md - >- version=$(awk '/^\[/{p=($0=="[workspace.package]");next} p&&$1=="version"{gsub(/[" ]/,"",$3);print $3;exit}' Cargo.toml); install -d /app/share/metainfo; diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index 36b69cecb..7ad0c4016 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -103,7 +103,6 @@ fi if has appimage; then APPDIR="$WORK/CADCraft.AppDir" cp -R "$STAGE" "$APPDIR" - mv "$APPDIR/usr/share/doc" "$WORK/doc-unused" ln -s usr/bin/cadcraft "$APPDIR/AppRun" cp "$HERE/$APP_ID.desktop" "$APPDIR/$APP_ID.desktop" cp "$ROOT/assets/app-icon/hicolor/256x256/apps/$APP_ID.png" "$APPDIR/$APP_ID.png" diff --git a/packaging/macos/package.sh b/packaging/macos/package.sh index fd626fbf4..014f91bd9 100755 --- a/packaging/macos/package.sh +++ b/packaging/macos/package.sh @@ -103,6 +103,8 @@ mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" # Executable and icon carry the display name (CFBundleExecutable / CFBundleIconFile). cp "$WORK/bin/cadcraft" "$APP/Contents/MacOS/CADCraft" cp "$ROOT/assets/app-icon/cadcraft.icns" "$APP/Contents/Resources/CADCraft.icns" +# Bundled dependency font notices are installed before the app is signed. +cp "$ROOT/NOTICE" "$ROOT/ATTRIBUTION.md" "$APP/Contents/Resources/" # Licences of the embedded craft-fonts fonts (only when built with CRAFT_FONTS_DIR). copy_font_licences "$APP/Contents/Resources" sed -e "s/@VERSION@/$VERSION/g" -e "s/@SHORT_VERSION@/$SHORT_VERSION/g" \ diff --git a/packaging/windows/cadcraft.wxs b/packaging/windows/cadcraft.wxs index 50f82f6ff..d59cc41b3 100644 --- a/packaging/windows/cadcraft.wxs +++ b/packaging/windows/cadcraft.wxs @@ -14,6 +14,9 @@ unless DESKTOPSHORTCUT=1 or DESKTOPSHORTCUT=0 is given. --> + + + + + + + + + + + diff --git a/packaging/windows/package.ps1 b/packaging/windows/package.ps1 index a6b8cf23e..92b48e134 100644 --- a/packaging/windows/package.ps1 +++ b/packaging/windows/package.ps1 @@ -92,11 +92,27 @@ Copy-Item (Join-Path $Bin 'cadcraft.exe'), (Join-Path $Bin 'cadcraft-cli.exe') $ & (Join-Path $PSScriptRoot 'sign.ps1') (Join-Path $Stage 'cadcraft.exe') (Join-Path $Stage 'cadcraft-cli.exe') +# ---- font notices for the MSI ------------------------------------------------------------------ +# The portable ZIP already carries these notices; stage the complete files before MSI creation too. +$Docs = Join-Path $Stage 'font-licences' +New-Item -ItemType Directory -Force -Path $Docs | Out-Null +# Bundled dependency font notices are required even without optional craft-fonts. +Copy-Item -LiteralPath (Join-Path $Root 'NOTICE'), (Join-Path $Root 'ATTRIBUTION.md') -Destination $Docs +$HasCraftFontLicences = 1 +if ($env:CRAFT_FONTS_DIR) { + foreach ($ofl in Get-ChildItem -Path (Join-Path $env:CRAFT_FONTS_DIR 'fonts\*\OFL.txt') -File -ErrorAction SilentlyContinue) { + Copy-Item -LiteralPath $ofl.FullName -Destination (Join-Path $Docs "OFL-$($ofl.Directory.Name).txt") + $HasCraftFontLicences = 1 + } +} +# ---- end font notices for the MSI -------------------------------------------------------------- + # ---- MSI --------------------------------------------------------------------------------------- $Msi = Join-Path $Dist "cadcraft-$Version-windows-$Arch.msi" Invoke-Native 'wix build' { wix build (Join-Path $PSScriptRoot 'cadcraft.wxs') -arch $Arch -ext WixToolset.UI.wixext ` -d "Version=$MsiVersion" -d "BinDir=$Stage" -d "IconPath=$(Join-Path $Root 'assets\app-icon\cadcraft.ico')" ` + -d "DocsDir=$Docs" -d "HasCraftFontLicences=$HasCraftFontLicences" ` -o $Msi } # wix writes its debug symbols (.wixpdb) next to the MSI; keep them out of the release assets. @@ -108,7 +124,7 @@ $Portable = Join-Path $TargetDir "windows-package\cadcraft-$Version-windows-$Arc Remove-Item -Recurse -Force $Portable -ErrorAction SilentlyContinue New-Item -ItemType Directory -Force -Path $Portable | Out-Null Copy-Item (Join-Path $Stage '*.exe') $Portable -foreach ($f in 'README.md', 'LICENSE', 'LICENSE-MIT', 'LICENSE-APACHE') { +foreach ($f in 'README.md', 'LICENSE', 'LICENSE-MIT', 'LICENSE-APACHE', 'NOTICE', 'ATTRIBUTION.md') { $p = Join-Path $Root $f if (Test-Path $p) { Copy-Item $p $Portable } }