diff --git a/.config/nextest.toml b/.config/nextest.toml new file mode 100644 index 000000000..2a4cd2cb9 --- /dev/null +++ b/.config/nextest.toml @@ -0,0 +1,3 @@ +# Minimum cargo-nextest for this repository: GHSA-vc25-qg58-w57c is fixed in 0.9.150 and CI +# installs exactly that version, so an older nextest refuses to run (exit 92 REQUIRED_VERSION_NOT_MET). +nextest-version = "0.9.150" diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 54439c12e..e124d102a 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -24,10 +24,10 @@ Generated-in-code assets are original and have no file to list: | Asset | Author | Source | Licence | Notes | |---|---|---|---|---| | `epaint_default_fonts::EGUI_ICONS`, `epaint_default_fonts::EMOJI_ICON` (dependency font inputs) | John Slegers | [egui 173cc737](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [emoji-icon-font](https://github.com/jslegers/emoji-icon-font) | MIT (Copyright (c) 2014 John Slegers; full upstream text in `NOTICE`) | egui-icons.ttf is the five-glyph subset; emoji-icon-font.ttf is the full monochrome fallback. Registered by the production default font definitions; dependency assets unmodified here, no font file copied into this repository. | -| `krilla 0.8.2 / icc/sGrey-v2-magic.icc` (dependency-embedded) | Clinton Ingram | [craft-libs a091680 vendor/krilla/icc/sGrey-v2-magic.icc](https://github.com/storytold/craft-libs/blob/a0916808f7d78bed8c7b90cdf937176015454a7f/vendor/krilla/icc/sGrey-v2-magic.icc) (Krilla 0.8.2 upstream 3ffdf0588cf98050aad6edba51ca70162e1fb5b5) | CC0-1.0 | Embedded by the pinned PDF writer (`craft-pdf` 0.1.0, Krilla 0.8.2); unmodified dependency asset, no file copied into this repository. SHA-256 `4110169287e5f1b5149ca9f1da1814d32943905bba8030e849d1a3b729a19481`. | -| `krilla 0.8.2 / icc/sGrey-v4.icc` (dependency-embedded) | Clinton Ingram | [craft-libs a091680 vendor/krilla/icc/sGrey-v4.icc](https://github.com/storytold/craft-libs/blob/a0916808f7d78bed8c7b90cdf937176015454a7f/vendor/krilla/icc/sGrey-v4.icc) (Krilla 0.8.2 upstream 3ffdf0588cf98050aad6edba51ca70162e1fb5b5) | CC0-1.0 | Embedded by the pinned PDF writer (`craft-pdf` 0.1.0, Krilla 0.8.2); unmodified dependency asset, no file copied into this repository. SHA-256 `00c0f94e09127520a17dc0e1d9264b5702081d96dbdb1549ee88e3631ce42a9d`. | -| `krilla 0.8.2 / icc/sRGB-v2-magic.icc` (dependency-embedded) | Clinton Ingram | [craft-libs a091680 vendor/krilla/icc/sRGB-v2-magic.icc](https://github.com/storytold/craft-libs/blob/a0916808f7d78bed8c7b90cdf937176015454a7f/vendor/krilla/icc/sRGB-v2-magic.icc) (Krilla 0.8.2 upstream 3ffdf0588cf98050aad6edba51ca70162e1fb5b5) | CC0-1.0 | Embedded by the pinned PDF writer (`craft-pdf` 0.1.0, Krilla 0.8.2); unmodified dependency asset, no file copied into this repository. SHA-256 `af4efe28f6d311799865f325ba39184a2e978b113ca74124d60af7de22b105f4`. | -| `krilla 0.8.2 / icc/sRGB-v4.icc` (dependency-embedded) | Clinton Ingram | [craft-libs a091680 vendor/krilla/icc/sRGB-v4.icc](https://github.com/storytold/craft-libs/blob/a0916808f7d78bed8c7b90cdf937176015454a7f/vendor/krilla/icc/sRGB-v4.icc) (Krilla 0.8.2 upstream 3ffdf0588cf98050aad6edba51ca70162e1fb5b5) | CC0-1.0 | Embedded by the pinned PDF writer (`craft-pdf` 0.1.0, Krilla 0.8.2); unmodified dependency asset, no file copied into this repository. SHA-256 `c56e1685d888f5edb92fe07f2750f387f8fe8e91b32ff8fb0b56bfbbb9458353`. | +| `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 455fa3219..0633f38f3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,9 +4,9 @@ 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", @@ -28,9 +28,9 @@ dependencies = [ [[package]] name = "accesskit" -version = "0.25.1" +version = "0.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad442f58ee04714aaa0ba0a2768c1ea1935b29507bb22ddece8cbbc76db02932" +checksum = "4e883d1d28c5fd5568c79ed2d2bd48d9f2fd3945c8974ed0f53690ceddb051be" dependencies = [ "enumn", "serde", @@ -39,9 +39,9 @@ dependencies = [ [[package]] name = "accesskit_atspi_common" -version = "0.21.0" +version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52c182f9c282ac9c5638d876d551d15e5f7d397ec263349a0c6a2b61595dd5e4" +checksum = "24f5baf62e34a4a6c30e3d697e4782f821a7337049c44c3b40576a27dfbb580d" dependencies = [ "accesskit", "accesskit_consumer", @@ -53,9 +53,9 @@ dependencies = [ [[package]] name = "accesskit_consumer" -version = "0.39.1" +version = "0.39.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a45e2bb300bea6d5f33a82c3e051c3ddc6f41c209750f68a0acbe991a284344" +checksum = "f3da8f27636015191b2264ee1df681503170a632cafd832fdc1edfdd4e3a2fd3" dependencies = [ "accesskit", "hashbrown 0.17.1", @@ -63,9 +63,9 @@ dependencies = [ [[package]] name = "accesskit_ios" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28e539d64b27fd4c14de8f78ada8f7c944c092f52f7e49fb63316507a57f6937" +checksum = "a5280724673bbfa8b30a838c013500b719392989be3b97c5a9964062c252c232" dependencies = [ "accesskit", "accesskit_consumer", @@ -77,9 +77,9 @@ dependencies = [ [[package]] name = "accesskit_macos" -version = "0.27.1" +version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5701624bf6a11efb6d4c8e922bf44126ed96250e5faa9a7a0468c979635839a4" +checksum = "123828ceeec755b05255e3976cb0852993202de98a0626c97dd8d34246991f84" dependencies = [ "accesskit", "accesskit_consumer", @@ -91,9 +91,9 @@ dependencies = [ [[package]] name = "accesskit_unix" -version = "0.24.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "202f24df034a7476d07b7f74284de84f6d62aabd858dbe7ee9cad3b7ad6f8f9d" +checksum = "30d5192871228c54f1b0fb4a85cef000e11a3804f25d2165f14afa0daa67f3f6" dependencies = [ "accesskit", "accesskit_atspi_common", @@ -109,9 +109,9 @@ dependencies = [ [[package]] name = "accesskit_windows" -version = "0.35.1" +version = "0.35.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce63f35d6bdcf59f26b76b3379063f738e6412cef46999cc772d46aa3de35adb" +checksum = "498668b90da9995302a0055aaecad4756932333da2ddef6baee1c00844c430fc" dependencies = [ "accesskit", "accesskit_consumer", @@ -123,9 +123,9 @@ dependencies = [ [[package]] name = "accesskit_winit" -version = "0.34.1" +version = "0.34.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee96dd150375c05912d8a473f98c2fb155fb3234fac88292d396548a2460fa77" +checksum = "cd4ab4437b520ff03088fbe48676a11e7d44a929535e64b31432c1073e48dbcc" dependencies = [ "accesskit", "accesskit_ios", @@ -343,7 +343,7 @@ checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -378,7 +378,7 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -444,9 +444,9 @@ checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" [[package]] name = "basic-udev" -version = "0.1.2" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a45f9771ced8a774de5e5ebffbe520f52e3943bf5a9a6baa3a5d14a5de1afe6" +checksum = "868a8681ed3a19fae3e302c705b1cabf5be53278cc8720a9b82e9805c9ab171d" [[package]] name = "bit-set" @@ -541,7 +541,7 @@ checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -571,7 +571,9 @@ dependencies = [ "cadcraft-mcp", "cadcraft-ui-egui", "chrono", + "craft-ui", "craft-update", + "craft-window", "eframe", "egui", "hidapi", @@ -740,6 +742,7 @@ dependencies = [ "chrono", "craft-ui", "craft-update", + "craft-window", "egui", "egui-wgpu", "egui_kittest", @@ -820,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", @@ -862,13 +865,22 @@ 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", ] +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.21", +] + [[package]] name = "codespan-reporting" version = "0.13.1" @@ -907,9 +919,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", @@ -992,12 +1004,40 @@ dependencies = [ [[package]] name = "craft-docpath" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=a0916808f7d78bed8c7b90cdf937176015454a7f#a0916808f7d78bed8c7b90cdf937176015454a7f" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" + +[[package]] +name = "craft-fonts-build" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" + +[[package]] +name = "craft-i18n" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +dependencies = [ + "fixed_decimal", + "icu_decimal", + "icu_locale_core", + "icu_locale_fallback", + "icu_normalizer", + "icu_plurals", + "icu_properties", + "icu_provider", + "icu_provider_adapters", + "icu_provider_blob", + "log", + "objc2-foundation 0.3.2", + "sys-locale", + "unicode-bidi", + "windows-registry", + "writeable", +] [[package]] name = "craft-pdf" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=a0916808f7d78bed8c7b90cdf937176015454a7f#a0916808f7d78bed8c7b90cdf937176015454a7f" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "hayro-syntax", "krilla", @@ -1007,20 +1047,28 @@ dependencies = [ [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=a0916808f7d78bed8c7b90cdf937176015454a7f#a0916808f7d78bed8c7b90cdf937176015454a7f" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ + "craft-fonts-build", + "craft-i18n", "egui", + "epaint", + "js-sys", "log", "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "serde", + "skrifa 0.44.0", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", ] [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "chrono", "dunce", @@ -1042,11 +1090,22 @@ dependencies = [ "zip", ] +[[package]] +name = "craft-window" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +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", ] @@ -1162,7 +1221,7 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -1449,9 +1508,9 @@ source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f9 [[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" @@ -1537,9 +1596,20 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.14" +version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" + +[[package]] +name = "fixed_decimal" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2214fb00f669ee96bee5bb29dfb1ddabe058b2bc426817efc79afa03c9bc55ae" +dependencies = [ + "displaydoc", + "smallvec", + "writeable", +] [[package]] name = "fixedbitset" @@ -1622,7 +1692,7 @@ checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -1673,7 +1743,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -1981,7 +2051,7 @@ checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a" [[package]] name = "hayro-jbig2" version = "0.3.0" -source = "git+https://github.com/storytold/craft-libs?rev=a0916808f7d78bed8c7b90cdf937176015454a7f#a0916808f7d78bed8c7b90cdf937176015454a7f" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "hayro-ccitt", ] @@ -1995,7 +2065,7 @@ checksum = "c75ab947623ef4ccaa7acf0579edf7cbb5a73838e3839a7be73335e522f433a1" [[package]] name = "hayro-syntax" version = "0.7.2" -source = "git+https://github.com/storytold/craft-libs?rev=a0916808f7d78bed8c7b90cdf937176015454a7f#a0916808f7d78bed8c7b90cdf937176015454a7f" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "hayro-jbig2", "hayro-jpeg2000", @@ -2072,6 +2142,23 @@ dependencies = [ "zerovec", ] +[[package]] +name = "icu_decimal" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eb8f655bba2d0c0459e43a5b0e6cd3cd388109898fb3d85d048165e8b0abd08" +dependencies = [ + "displaydoc", + "fixed_decimal", + "icu_locale_core", + "icu_pattern", + "icu_plurals", + "icu_provider", + "serde", + "writeable", + "zerovec", +] + [[package]] name = "icu_locale_core" version = "2.3.0" @@ -2080,11 +2167,32 @@ checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", + "serde", "tinystr", "writeable", "zerovec", ] +[[package]] +name = "icu_locale_fallback" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "251af8e57c9400e3eb58242fe5b8b1152b2a64fdf4cf632f923c38ccee6f2fa9" +dependencies = [ + "icu_locale_core", + "icu_locale_fallback_data", + "icu_provider", + "potential_utf", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locale_fallback_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "decf2a22ec8fa68f1a0c1129a3f8583f8f8bc24e8b9ccbe98ead99f62a4dc3a8" + [[package]] name = "icu_normalizer" version = "2.3.0" @@ -2096,6 +2204,9 @@ dependencies = [ "icu_properties", "icu_provider", "smallvec", + "utf16_iter", + "utf8_iter", + "write16", "zerovec", ] @@ -2105,6 +2216,32 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" +[[package]] +name = "icu_pattern" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc43ce0e76ee8cb0482dc6e2dbb9b76278e8038f45daabf2a2877577e2ceb18f" +dependencies = [ + "displaydoc", + "either", + "serde", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_plurals" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e475e6766ef87b1d3c1f97be6363995b0bfaeddbc789671615df598a97ff0593" +dependencies = [ + "displaydoc", + "fixed_decimal", + "icu_provider", + "serde", + "zerovec", +] + [[package]] name = "icu_properties" version = "2.3.0" @@ -2134,6 +2271,9 @@ checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", + "postcard", + "serde", + "stable_deref_trait", "writeable", "yoke", "zerofrom", @@ -2141,6 +2281,30 @@ dependencies = [ "zerovec", ] +[[package]] +name = "icu_provider_adapters" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df887bc4f55dbc773c7c45a0c7dfa75be886f7b7812830a71ab7271cc1801820" +dependencies = [ + "icu_locale_fallback", + "icu_provider", +] + +[[package]] +name = "icu_provider_blob" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5916b44fac319d3f58e4d8d805a627df19e27937b582cd99fcde3bb6b7ce1b0e" +dependencies = [ + "icu_provider", + "postcard", + "serde", + "writeable", + "zerotrie", + "zerovec", +] + [[package]] name = "idna" version = "1.1.0" @@ -2357,7 +2521,7 @@ dependencies = [ [[package]] name = "krilla" version = "0.8.2" -source = "git+https://github.com/storytold/craft-libs?rev=a0916808f7d78bed8c7b90cdf937176015454a7f#a0916808f7d78bed8c7b90cdf937176015454a7f" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "base64 0.22.1", "bumpalo", @@ -2429,7 +2593,7 @@ dependencies = [ "bitflags 2.13.2", "libc", "plain", - "redox_syscall 0.9.4", + "redox_syscall 0.9.5", ] [[package]] @@ -2455,12 +2619,15 @@ name = "litemap" version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" +dependencies = [ + "serde_core", +] [[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" @@ -2479,9 +2646,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", @@ -2517,9 +2684,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", ] @@ -3076,9 +3243,9 @@ 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", @@ -3102,9 +3269,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", ] @@ -3386,12 +3553,24 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "serde", +] + [[package]] name = "potential_utf" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ + "serde_core", + "writeable", "zerovec", ] @@ -3580,9 +3759,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", ] @@ -3894,7 +4073,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -3918,14 +4097,14 @@ 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", ] @@ -4032,9 +4211,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", ] @@ -4180,9 +4359,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", @@ -4197,7 +4376,7 @@ checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -4206,7 +4385,10 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" dependencies = [ + "js-sys", "libc", + "wasm-bindgen", + "web-sys", ] [[package]] @@ -4278,7 +4460,7 @@ checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -4353,11 +4535,12 @@ 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", + "serde_core", "zerovec", ] @@ -4369,18 +4552,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", @@ -4393,18 +4576,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", @@ -4414,18 +4597,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" @@ -4522,9 +4705,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" @@ -4600,6 +4783,12 @@ dependencies = [ "serde", ] +[[package]] +name = "utf16_iter" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" + [[package]] name = "utf8-zero" version = "0.8.1" @@ -4614,9 +4803,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", @@ -4734,7 +4923,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "wasm-bindgen-shared", ] @@ -4904,9 +5093,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", @@ -5208,6 +5397,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + [[package]] name = "windows-result" version = "0.4.1" @@ -5448,11 +5648,20 @@ dependencies = [ "read-fonts 0.39.2", ] +[[package]] +name = "write16" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" + [[package]] name = "writeable" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" +dependencies = [ + "either", +] [[package]] name = "x11-dl" @@ -5533,9 +5742,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", @@ -5550,7 +5759,7 @@ checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "synstructure", ] @@ -5622,7 +5831,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "zbus_names", "zvariant", "zvariant_utils", @@ -5630,9 +5839,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", @@ -5662,18 +5871,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", @@ -5697,7 +5906,7 @@ checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "synstructure", ] @@ -5714,16 +5923,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", + "litemap", + "serde_core", "yoke", "zerofrom", + "zerovec", ] [[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 = [ + "serde", "yoke", "zerofrom", "zerovec-derive", @@ -5731,20 +5944,20 @@ 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.0" +version = "9.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2886b00003a72c4ccee3f42cfc34908d33b2508a74d827dc2a4244706475c704" +checksum = "518b847fd864440c379748ad684c449f293af68978a485a4ff16f7c901c1f7c4" dependencies = [ "crc32fast", "flate2", @@ -5798,9 +6011,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", @@ -5813,14 +6026,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", ] @@ -5833,7 +6046,7 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn 3.0.6", + "syn 3.0.9", "winnow", ] diff --git a/Cargo.toml b/Cargo.toml index 9ceb02e4d..8c4230e22 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 = "a0916808f7d78bed8c7b90cdf937176015454a7f" } +craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } # 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 = "a0916808f7d78bed8c7b90cdf937176015454a7f", default-features = false } +craft-pdf = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", default-features = false } # foundation thiserror = "2" @@ -53,14 +53,15 @@ encoding_rs = "0.8.35" quick-xml = "0.42" # ui -craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f", features = ["serde"] } +craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["serde"] } +craft-window = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", 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 = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", features = ["egui"] } +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] } rfd = "0.17" arboard = { version = "3.6", default-features = false } @@ -90,9 +91,9 @@ debug = 0 # PDF backends for craft-pdf: pinned to the same reviewed craft-libs revision as craft-pdf itself # (Cargo ignores dependency-local patches). [patch.crates-io] -krilla = { version = "=0.8.2", git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f" } -hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f" } -hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f" } +krilla = { version = "=0.8.2", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } +hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } +hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } # Keep the complete egui family and native event bridge on reviewed fork commits. ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } diff --git a/NOTICE b/NOTICE index 3e6cc03a3..2eb24b221 100644 --- a/NOTICE +++ b/NOTICE @@ -28,9 +28,9 @@ The PDF writer in the pinned craft-pdf 0.1.0 / Krilla 0.8.2 dependency embeds fo Profiles by Clinton Ingram, under CC0-1.0. They are unmodified dependency assets; no profile file is copied into this repository. Per-file sources and SHA-256 hashes are in ATTRIBUTION.md. Source: https://github.com/LaurenzV/krilla/tree/3ffdf0588cf98050aad6edba51ca70162e1fb5b5/crates/krilla/icc -(pinned through https://github.com/storytold/craft-libs/tree/a0916808f7d78bed8c7b90cdf937176015454a7f/vendor/krilla/icc). +(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/a0916808f7d78bed8c7b90cdf937176015454a7f/vendor/krilla/icc/LICENSE +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 diff --git a/apps/cadcraft/Cargo.toml b/apps/cadcraft/Cargo.toml index 4c88f2ad6..48c2c2d15 100644 --- a/apps/cadcraft/Cargo.toml +++ b/apps/cadcraft/Cargo.toml @@ -34,11 +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 45852da2f..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}"); @@ -86,7 +97,7 @@ impl eframe::App for App { } self.spacemouse.poll(&mut self.cad, ctx); // After `cad.logic`, so a control command's workspace change is saved in the same frame. - self.persist_workspace(false); + self.persist_workspace(Some(ctx)); } if std::mem::take(&mut self.cad.quit_requested) { ctx.send_viewport_cmd(egui::ViewportCommand::Close); @@ -100,11 +111,13 @@ impl eframe::App for App { } fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { self.cad.ui(ui); + #[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(); } @@ -405,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 1e987870d..8649ed8e4 100644 --- a/apps/cadcraft/src/native_menu.rs +++ b/apps/cadcraft/src/native_menu.rs @@ -118,7 +118,7 @@ impl NativeMenu { 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(); } @@ -142,8 +142,9 @@ impl NativeMenu { 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); } } diff --git a/apps/cadcraft/src/workspace.rs b/apps/cadcraft/src/workspace.rs index f4e34d4fb..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 { @@ -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,10 +86,16 @@ 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) && cadcraft_ui_egui::docking::validate(&docking).is_ok() { @@ -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,37 +250,42 @@ 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, permissions}; + 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, 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, None, permissions).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 version_one_docking_file_still_loads() { - use cadcraft_ui_egui::docking::{Panel, reveal_panel}; + 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":{ @@ -271,28 +297,18 @@ mod tests { "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,"toolsetTab":"Modeling","historyLines":4}}"#; + "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 mut ui = restored.unwrap(); + let ui = restored.unwrap(); assert_eq!(ui.toolset_tab, "Modeling"); - let docking = &ui.docking; - assert_eq!(docking.layout.floating.len(), 1); - let window = &docking.layout.floating[0]; - assert_eq!(window.rect, [120.0, 90.0, 340.0, 410.0]); - assert_eq!(serde_json::to_value(&window.root).unwrap(), json!({"Tabs":{"panels":["layers","externalReferences"],"active":1}})); - assert_eq!( - serde_json::to_value(&docking.layout.root).unwrap()["Split"]["second"], - json!({"Stack":{"entries":[{"panel":"properties","open":false,"height":null}]}}) - ); - assert!(!docking.layout.contains(&Panel::ToolSets)); - reveal_panel(&mut ui.docking, Panel::ToolSets).unwrap(); - let tools = ui.docking.layout.location(&Panel::ToolSets).unwrap(); - assert_eq!(tools.anchor, Some(Panel::Canvas)); - assert_eq!(serde_json::to_value((&tools.placement, &tools.size)).unwrap(), json!([{"Split":"Left"}, {"FixedFirst":220.0}])); + 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(); } @@ -311,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(); @@ -326,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())); @@ -342,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(); } @@ -364,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/clippy.toml b/clippy.toml index 4b6c79f55..287e14edc 100644 --- a/clippy.toml +++ b/clippy.toml @@ -2,3 +2,6 @@ allow-unwrap-in-tests = true allow-expect-in-tests = true allow-panic-in-tests = true allow-indexing-slicing-in-tests = true +disallowed-types = [ + { path = "egui::ComboBox", reason = "use craft_ui::ComboBox: egui's combo box menu can't grow back while open (egui#5225)" }, +] diff --git a/crates/constraints/src/lib.rs b/crates/constraints/src/lib.rs index 72a05b6a2..89c6276cd 100644 --- a/crates/constraints/src/lib.rs +++ b/crates/constraints/src/lib.rs @@ -358,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; @@ -396,46 +446,7 @@ pub(crate) fn build_budgeted( 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, held, tol }); diff --git a/crates/constraints/src/model.rs b/crates/constraints/src/model.rs index 165afac8d..d18646c32 100644 --- a/crates/constraints/src/model.rs +++ b/crates/constraints/src/model.rs @@ -191,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(); @@ -200,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; diff --git a/crates/doc/src/entity.rs b/crates/doc/src/entity.rs index 0d1f5eb5f..089904109 100644 --- a/crates/doc/src/entity.rs +++ b/crates/doc/src/entity.rs @@ -9,130 +9,6 @@ use serde::{Deserialize, Serialize}; mod mline; pub use mline::*; -/// A database handle (DXF group 5), unique per drawing. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] -pub struct Handle(pub u64); - -impl Handle { - pub fn hex(self) -> String { - format!("{:X}", self.0) - } - pub fn parse_hex(s: &str) -> Option { - let s = s.trim(); - if !s.bytes().all(|b| b.is_ascii_hexdigit()) { - return None; - } - u64::from_str_radix(s, 16).ok().map(Handle) - } -} - -/// Lineweight in hundredths of a millimetre, or a logical value. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum Lineweight { - #[default] - ByLayer, - ByBlock, - Default, - /// 0..=211 (hundredths of mm). - Mm100(u16), -} - -impl Lineweight { - /// The standard lineweight values (mm × 100). - pub const STANDARD: [u16; 24] = [0, 5, 9, 13, 15, 18, 20, 25, 30, 35, 40, 50, 53, 60, 70, 80, 90, 100, 106, 120, 140, 158, 200, 211]; - pub fn to_dxf(self) -> i16 { - match self { - Lineweight::ByLayer => -1, - Lineweight::ByBlock => -2, - Lineweight::Default => -3, - Lineweight::Mm100(v) => v.min(211) as i16, - } - } - pub fn from_dxf(v: i16) -> Lineweight { - match v { - -2 => Lineweight::ByBlock, - -3 => Lineweight::Default, - 0..=211 => Lineweight::Mm100(v as u16), - _ => Lineweight::ByLayer, - } - } - pub fn name(self) -> String { - match self { - Lineweight::ByLayer => "ByLayer".into(), - Lineweight::ByBlock => "ByBlock".into(), - Lineweight::Default => "Default".into(), - Lineweight::Mm100(v) => format!("{:.2} mm", f64::from(v) / 100.0), - } - } -} - -/// Transparency: ByLayer, ByBlock, or 0..=90 percent. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum Transparency { - #[default] - ByLayer, - ByBlock, - Percent(u8), -} - -impl Transparency { - /// "ByLayer", "ByBlock" or the percentage ("50"). - pub fn name(&self) -> String { - match self { - Transparency::ByLayer => "ByLayer".into(), - Transparency::ByBlock => "ByBlock".into(), - Transparency::Percent(p) => p.to_string(), - } - } -} - -/// Properties every entity has. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(default, rename_all = "camelCase")] -pub struct Common { - pub layer: String, - pub color: Color, - pub linetype: String, - pub lineweight: Lineweight, - pub ltscale: f64, - pub transparency: Transparency, - pub visible: bool, - pub thickness: f64, - pub extrusion: Vec3, - /// Named plot style: `ByLayer`, `ByBlock` or a style of the layout's named (`.stb`) plot - /// style table. - pub plot_style: String, - /// The annotation scales an annotative object supports (empty: not annotative). - #[serde(skip_serializing_if = "Vec::is_empty")] - pub anno_scales: Vec, - /// Other applications' extended data (xdata) as read from the drawing file: whole - /// applications, each from its `1001` group on. It is part of the object, so a copy of the - /// object (COPY, MIRROR, ARRAY, paste) carries it, as in AutoCAD. - #[serde(skip_serializing_if = "Vec::is_empty")] - pub xdata: Vec, -} - -impl Default for Common { - fn default() -> Self { - Common { - layer: "0".into(), - color: Color::ByLayer, - linetype: "ByLayer".into(), - lineweight: Lineweight::ByLayer, - ltscale: 1.0, - transparency: Transparency::ByLayer, - visible: true, - thickness: 0.0, - extrusion: Vec3::Z, - plot_style: "ByLayer".into(), - anno_scales: Vec::new(), - xdata: Vec::new(), - } - } -} - #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Entity { pub handle: Handle, @@ -146,108 +22,6 @@ impl Entity { Entity { handle, common: Common::default(), kind } } } - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct Line { - pub a: Vec3, - pub b: Vec3, -} -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct Point { - pub p: Vec3, - #[serde(default)] - pub angle: f64, -} -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct Circle { - pub center: Vec3, - pub radius: f64, -} -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct Arc { - pub center: Vec3, - pub radius: f64, - /// Radians, CCW. - pub start: f64, - pub end: f64, -} -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct Ellipse { - pub center: Vec3, - pub major: Vec3, - pub ratio: f64, - pub start: f64, - pub end: f64, -} -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct LwPolyline { - pub vertices: Vec, - pub closed: bool, - #[serde(default)] - pub const_width: f64, - #[serde(default)] - pub elevation: f64, - /// Polyline linetype generation (PLINEGEN). - #[serde(default)] - pub plinegen: bool, -} -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct Polyline3d { - pub points: Vec, - pub closed: bool, -} -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum HAlign { - #[default] - Left, - Center, - Right, - Aligned, - Middle, - Fit, -} -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum VAlign { - #[default] - Baseline, - Bottom, - Middle, - Top, -} -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct Text { - pub insert: Vec3, - #[serde(default)] - pub align_pt: Option, - pub height: f64, - pub value: String, - #[serde(default)] - pub rotation: f64, - #[serde(default = "one")] - pub width_factor: f64, - #[serde(default)] - pub oblique: f64, - #[serde(default = "standard")] - pub style: String, - #[serde(default)] - pub halign: HAlign, - #[serde(default)] - pub valign: VAlign, -} -impl Text { - /// The point the text is placed by, as the Insertion snap and the grip show it: the alignment - /// point (DXF group 11) of justified text, the start point (group 10) of left-baseline, - /// aligned and fit text. - pub fn justify_point(&self) -> Vec2 { - match (self.halign, self.valign) { - (HAlign::Left, VAlign::Baseline) | (HAlign::Aligned | HAlign::Fit, _) => self.insert.xy(), - _ => self.align_pt.unwrap_or(self.insert).xy(), - } - } -} fn one() -> f64 { 1.0 } @@ -257,720 +31,27 @@ fn standard() -> String { mod helix; pub use helix::*; -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct MText { - pub insert: Vec3, - pub height: f64, - /// Reference rectangle width (0 = no wrapping). - #[serde(default)] - pub width: f64, - /// 1..=9: TL TC TR ML MC MR BL BC BR. - #[serde(default = "attach_tl")] - pub attach: u8, - #[serde(default)] - pub rotation: f64, - #[serde(default = "standard")] - pub style: String, - /// Raw MTEXT contents with inline codes (\P, {\fArial|b1;...}, \S1/2; …). - pub contents: String, - #[serde(default = "one")] - pub line_spacing: f64, - /// Line spacing style (DXF 73): Exactly (`true`) keeps `line_spacing` whatever the character - /// heights; At least (`false`, the default) lets taller characters push lines apart. - #[serde(default)] - pub line_spacing_exact: bool, - /// Columns the text flows through, each `width` wide (DXF 75/76/78/79/48/49/50); `None`: - /// one column. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub columns: Option, -} -fn attach_tl() -> u8 { - 1 -} - -/// Static columns: a fixed number of columns of one height. Dynamic columns: as many columns -/// as the text needs, of one height (auto height) or each its own (manual heights). -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum MTextColumnType { - #[default] - Static, - Dynamic, -} - -/// MTEXT columns (MTEXT Columns option, DXF Reference MTEXT groups 75–79, 48–50). -#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] -#[serde(default, rename_all = "camelCase")] -pub struct MTextColumns { - #[serde(rename = "type")] - pub kind: MTextColumnType, - /// Number of columns (static columns: the columns there are). - pub count: u16, - /// Space between two columns. - pub gutter: f64, - /// Height of every column (static, and dynamic with auto height). - pub height: f64, - /// Dynamic columns: one height for all columns (`height`) instead of `heights`. - pub auto_height: bool, - /// Dynamic columns with manual heights: the height of each column. - #[serde(skip_serializing_if = "Vec::is_empty")] - pub heights: Vec, - /// Columns run right to left. - pub flow_reversed: bool, -} - -impl MTextColumns { - /// The width of `count` columns of `column_width` and their gutters. - pub fn total_width(&self, column_width: f64) -> f64 { - let n = f64::from(self.count.max(1)); - column_width * n + self.gutter.max(0.0) * (n - 1.0) - } - - /// The tallest column. - pub fn max_height(&self) -> f64 { - self.heights.iter().copied().fold(self.height, f64::max).max(0.0) - } - - /// Scale every length (SCALE, block insertion). - pub fn scale(&mut self, s: f64) { - self.gutter *= s; - self.height *= s; - self.heights.iter_mut().for_each(|h| *h *= s); - } -} -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct Attrib { - pub tag: String, - pub text: Text, - #[serde(default)] - pub invisible: bool, - #[serde(default)] - pub constant: bool, - #[serde(default)] - pub prompt: String, - /// An attribute's own properties; each one left unset follows the attribute definition. - #[serde(default, skip_serializing_if = "AttribProps::is_empty")] - pub props: AttribProps, - /// A multiline attribute (DXF attribute type 2, or 4 for a definition): its text as - /// multiline text. `text.value` holds the plain text, which value edits change. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub multiline: Option, - /// Its other modes: Verify, Preset and Lock position. - #[serde(default, skip_serializing_if = "AttribModes::is_default")] - pub modes: AttribModes, -} - -/// An attribute's modes beyond invisible and constant. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct AttribModes { - /// Verify: the value is asked for twice when the block is inserted (DXF flag 4). - #[serde(default)] - pub verify: bool, - /// Preset: the default value is used without asking (DXF flag 8). - #[serde(default)] - pub preset: bool, - /// Lock position: the attribute can't be moved on its own within a reference (DXF group - /// 280 after the flags). A reference's attributes take it from their definitions. - #[serde(default)] - pub lock_position: bool, -} - -impl AttribModes { - pub fn is_default(&self) -> bool { - *self == Self::default() - } - /// The modes of a reference's attribute made from a definition with these: AutoCAD keeps - /// only Lock position there. - pub fn for_reference(self) -> Self { - AttribModes { lock_position: self.lock_position, ..Self::default() } - } -} - -/// The multiline text of a multiline attribute. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct MultilineAttrib { - pub mtext: MText, - /// The plain text `mtext`'s contents read as: an attribute whose value differs was edited - /// since, and its value replaces the contents. - pub read_as: String, -} - -impl MultilineAttrib { - /// This text placed by `m` (a block reference's transform, for its attribute made from a - /// definition). - pub fn transformed(&self, m: &Mat3) -> MultilineAttrib { - let mut k = EntityKind::MText(self.mtext.clone()); - k.transform(m); - let mtext = if let EntityKind::MText(t) = k { t } else { self.mtext.clone() }; - MultilineAttrib { mtext, read_as: self.read_as.clone() } - } -} - -impl Attrib { - /// Move a multiline attribute's text by `m` along with the attribute (nothing for others). - pub fn transform_multiline(&mut self, m: &Mat3) { - if let Some(ml) = &mut self.multiline { - *ml = ml.transformed(m); - } - } - - /// A multiline attribute's text as drawn and saved: its multiline text, with the contents - /// replaced by the value when the value was edited since it was read. - pub fn multiline_text(&self) -> Option { - let m = self.multiline.as_ref()?; - let mut t = m.mtext.clone(); - if self.text.value != m.read_as { - t.contents = mtext_literal(&self.text.value); - } - Some(t) - } -} - -/// `s` as MTEXT contents that read back as `s`: backslashes and braces escaped, line breaks as -/// paragraph breaks. -pub fn mtext_literal(s: &str) -> String { - let mut out = String::with_capacity(s.len()); - for c in s.chars() { - match c { - '\\' => out.push_str("\\\\"), - '{' => out.push_str("\\{"), - '}' => out.push_str("\\}"), - '\n' => out.push_str("\\P"), - '\r' => {} - c => out.push(c), - } - } - out -} - -/// Properties set on one attribute of a block reference (an ATTRIB's layer, colour, linetype and -/// lineweight), overriding those of its definition. -#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] -#[serde(default, rename_all = "camelCase")] -pub struct AttribProps { - #[serde(skip_serializing_if = "Option::is_none")] - pub layer: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub color: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub linetype: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub lineweight: Option, -} - -impl AttribProps { - /// Nothing set: the attribute is drawn with its definition's properties. - pub fn is_empty(&self) -> bool { - *self == AttribProps::default() - } - /// `base` (the definition's properties) with the ones set here in place. - pub fn over(&self, base: &Common) -> Common { - Common { - layer: self.layer.clone().unwrap_or_else(|| base.layer.clone()), - color: self.color.unwrap_or(base.color), - linetype: self.linetype.clone().unwrap_or_else(|| base.linetype.clone()), - lineweight: self.lineweight.unwrap_or(base.lineweight), - ..base.clone() - } - } -} - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct Insert { - pub block: String, - pub insert: Vec3, - #[serde(default = "unit_scale")] - pub scale: Vec3, - #[serde(default)] - pub rotation: f64, - #[serde(default)] - pub attribs: Vec, - #[serde(default = "one_u")] - pub cols: u32, - #[serde(default = "one_u")] - pub rows: u32, - #[serde(default)] - pub col_spacing: f64, - #[serde(default)] - pub row_spacing: f64, -} -fn unit_scale() -> Vec3 { - Vec3::new(1.0, 1.0, 1.0) -} -fn one_u() -> u32 { - 1 -} -impl Insert { - /// Block-to-world transform (2D part). - pub fn transform(&self, base: Vec2) -> Mat3 { - Mat3::translate(self.insert.xy()) - .then_before(Mat3::rotate(self.rotation)) - .then_before(Mat3::scale(self.scale.x, self.scale.y)) - .then_before(Mat3::translate(-base)) - } -} -#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum DimKind { - /// Rotated linear dimension (horizontal = 0, vertical = 90°). - Linear { - rotation: f64, - }, - Aligned, - Angular, - Angular3P, - Diameter, - Radius, - Ordinate { - x_type: bool, - }, - ArcLength, -} -/// A dimension. Definition points follow DXF: `defpt` (10: dimension line point), -/// `p13`/`p14` (extension origins), `p15`/`p16` (arc/angle points), `text_mid` (11). -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct Dimension { - pub kind: DimKind, - pub defpt: Vec3, - pub text_mid: Vec3, - #[serde(default)] - pub p13: Vec3, - #[serde(default)] - pub p14: Vec3, - #[serde(default)] - pub p15: Vec3, - #[serde(default)] - pub p16: Vec3, - /// "" = measured value, "<>" inside = measurement placeholder, " " = suppressed. - #[serde(default)] - pub text: String, - #[serde(default = "standard")] - pub style: String, - #[serde(default)] - pub measurement: f64, - #[serde(default)] - pub text_rotation: f64, - /// User moved the text (text_mid is authoritative). - #[serde(default)] - pub user_text_pos: bool, - /// Anonymous block with the rendered geometry (from files); regenerated when edited. - #[serde(default)] - pub block: Option, - /// Per-dimension style overrides (DIMOVERRIDE): DimStyle field names (camelCase) → values, - /// merged over the named style. - #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")] - pub overrides: serde_json::Map, - /// Associativity: which definition points follow which objects. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub assoc: Vec, - /// Oblique extension lines, jogs, breaks and inspection frames. - #[serde(default, skip_serializing_if = "DimMarks::is_empty")] - pub marks: DimMarks, -} - -/// What dimension edits add to a dimension's drawing: DIMEDIT Oblique, DIMJOGLINE, DIMJOGGED, -/// DIMBREAK and DIMINSPECT. -#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] -#[serde(default, rename_all = "camelCase")] -pub struct DimMarks { - /// Angle (radians) of the extension lines of a linear or aligned dimension; None = - /// perpendicular to the dimension line. - #[serde(skip_serializing_if = "Option::is_none")] - pub oblique: Option, - /// A jog on the dimension line of a linear or aligned dimension, at this point (DIMJOGLINE). - #[serde(skip_serializing_if = "Option::is_none")] - pub jog_line: Option, - /// A jogged radius dimension (DIMJOGGED). - #[serde(skip_serializing_if = "Option::is_none")] - pub jogged: Option, - /// Gaps in the dimension and extension lines (DIMBREAK). - #[serde(skip_serializing_if = "Vec::is_empty")] - pub breaks: Vec, - /// Inspection frame, label and rate (DIMINSPECT). - #[serde(skip_serializing_if = "Option::is_none")] - pub inspect: Option, -} - -impl DimMarks { - pub fn is_empty(&self) -> bool { - *self == DimMarks::default() - } - - /// Transform the points and angles (part of [`EntityKind::transform`]). - fn transform(&mut self, t3: &dyn Fn(&mut Vec3), rot: f64, s: f64, mirror: bool) { - if let Some(a) = &mut self.oblique { - *a = cadcraft_geom::norm_angle(if mirror { rot - (*a - rot) } else { *a + rot }); - } - if let Some(j) = &mut self.jog_line { - t3(j); - } - if let Some(j) = &mut self.jogged { - t3(&mut j.center); - t3(&mut j.jog); - } - for b in &mut self.breaks { - t3(&mut b.at); - b.size *= s; - } - } -} - -/// The definition of a jogged radius dimension: the dimension line starts at an overriding -/// centre and jogs on its way to the arc. -#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] -#[serde(default, rename_all = "camelCase")] -pub struct JoggedRadius { - /// Where the dimension line starts instead of the true centre. - pub center: Vec3, - /// The middle of the jog. - pub jog: Vec3, -} - -/// A gap in a dimension's lines: everything within `size / 2` of `at` is left out. -#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] -#[serde(default, rename_all = "camelCase")] -pub struct DimBreak { - pub at: Vec3, - pub size: f64, - /// Made by DIMBREAK Auto (recomputed by the next Auto) rather than picked by hand. - pub auto: bool, -} - -/// An inspection dimension (DIMINSPECT). -#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] -#[serde(default, rename_all = "camelCase")] -pub struct Inspection { - /// Frame shape: 0 round ends, 1 angular ends, 2 none. - pub shape: u8, - /// Text left of the value ("" = none). - pub label: String, - /// Inspection rate right of the value ("" = none). - pub rate: String, -} - -/// A dimension definition point that follows an object (associative dimensions). -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct DimAssoc { - /// Definition point: "defpt", "p13", "p14", "p15" or "p16". - pub point: String, - /// The object it is attached to. - pub handle: Handle, - pub snap: AssocSnap, -} - -/// Where on the object an associative definition point sits. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum AssocSnap { - /// Start point of a line/arc (or polyline vertex 0). - Start, - /// End point of a line/arc. - End, - Mid, - /// Centre of a circle/arc. - Center, - /// On a circle/arc at a fixed angle (radians) from its centre. - OnCircle { - angle: f64, - }, - /// Intersection of this (line) object with another line. - Intersection { - other: Handle, - }, - /// Vertex `index` of a polyline. - Vertex { - index: usize, - }, -} -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct Leader { - pub vertices: Vec, - #[serde(default = "yes")] - pub arrow: bool, - #[serde(default)] - pub spline: bool, - #[serde(default = "standard")] - pub style: String, -} fn yes() -> bool { true } -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct MLeader { - pub leaders: Vec>, - pub landing: Vec3, - pub dogleg: f64, - pub text: Option, - #[serde(default = "standard")] - pub style: String, - #[serde(default = "one")] - pub arrow_size: f64, - /// Leader lines are smooth curves through their points (leader type Spline). An empty - /// `leaders` is a multileader without a leader line (leader type None). - #[serde(default)] - pub spline: bool, - /// Block content in place of the text: a block reference placed at the landing. - #[serde(default)] - pub block: Option, -} - -impl MLeader { - /// Each leader line as drawn: through its points to the landing, smoothed for spline leaders. - pub fn leader_paths(&self) -> Vec> { - self.leaders - .iter() - .map(|l| { - let pts: Vec = l.iter().map(|v| v.xy()).chain(std::iter::once(self.landing.xy())).collect(); - if self.spline && pts.len() > 2 { GSpline::from_fit_points(&pts).tessellate(1e-3) } else { pts } - }) - .collect() - } -} -/// A hatch boundary loop: closed polyline with bulges. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct HatchLoop { - pub vertices: Vec, - #[serde(default)] - pub outer: bool, - /// The objects the loop was made from (DXF group 97 count, 330 handles): an associative - /// hatch re-fills the loop from them when they change. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub sources: Vec, -} -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct Hatch { - pub pattern: String, - pub solid: bool, - pub loops: Vec, - #[serde(default = "one")] - pub scale: f64, - #[serde(default)] - pub angle: f64, - #[serde(default)] - pub associative: bool, - /// 0 normal (odd parity), 1 outer, 2 ignore. - #[serde(default)] - pub style: u8, - #[serde(default)] - pub elevation: f64, - /// Gradient fill: name + second colour. - #[serde(default)] - pub gradient: Option, - #[serde(default)] - pub origin: Vec2, - #[serde(default)] - pub background: Option, - /// The pattern's line families as a file defined them, for a pattern that isn't in the - /// standard library (unit scale and angle, like the library's; empty otherwise). - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub pattern_lines: Vec, -} -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct Gradient { - pub name: String, - pub color1: Color, - pub color2: Color, - #[serde(default)] - pub angle: f64, - #[serde(default)] - pub centered: bool, -} -/// SOLID / TRACE: up to four corners (DXF order 1,2,4,3 when drawn). -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct Solid { - pub corners: [Vec3; 4], -} -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct Face3d { - pub corners: [Vec3; 4], - #[serde(default)] - pub hidden_edges: u8, -} -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct RayLine { - pub base: Vec3, - pub dir: Vec3, -} -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct Viewport { - pub center: Vec3, - pub width: f64, - pub height: f64, - pub view_center: Vec2, - pub view_height: f64, - #[serde(default)] - pub id: u32, - #[serde(default)] - pub locked: bool, - #[serde(default)] - pub frozen_layers: Vec, - /// Per-viewport layer colour overrides (VP Color in the Layer Properties Manager). - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub layer_colors: Vec<(String, Color)>, - /// The paper-space entity whose outline clips the viewport (a closed polyline, circle, - /// ellipse or spline; DXF group 340). `None`: the rectangle `center`/`width`/`height`. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub clip: Option, - /// Display on (MVIEW ON/OFF). An off viewport shows its border but no model space. - #[serde(default = "yes")] - pub on: bool, -} -impl Default for Viewport { - fn default() -> Self { - Viewport { - center: Vec3::ZERO, - width: 1.0, - height: 1.0, - view_center: Vec2::ZERO, - view_height: 1.0, - id: 0, - locked: false, - frozen_layers: Vec::new(), - layer_colors: Vec::new(), - clip: None, - on: true, - } - } -} -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct Image { - pub insert: Vec3, - /// One pixel's width (U) and height (V) vectors in drawing units. - pub u: Vec3, - pub v: Vec3, - /// Image size in pixels. - pub size: Vec2, - /// The image file (its IMAGEDEF object's path). - pub path: String, - /// The image definition's name (its key in the drawing's image dictionary). - #[serde(default, skip_serializing_if = "String::is_empty")] - pub name: String, - /// Clip boundary in pixel coordinates (origin at the top-left corner of the image, y down): - /// two opposite corners of a rectangle, or a polygon's vertices. Empty: the whole image. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub clip: Vec, - /// The clip boundary is applied. - #[serde(default)] - pub clipping: bool, - /// Display flags: 1 show the image, 2 show it when not aligned with the screen, 4 use the - /// clip boundary, 8 transparency on. - #[serde(default = "image_display")] - pub display: u16, - /// Brightness, contrast and fade, 0..=100. - #[serde(default = "image_fifty")] - pub brightness: u8, - #[serde(default = "image_fifty")] - pub contrast: u8, - #[serde(default)] - pub fade: u8, - /// The definition's default size of one pixel and its resolution unit (0 none, 2 cm, 5 inch). - #[serde(default = "image_pixel")] - pub pixel_size: Vec2, - #[serde(default)] - pub resolution_units: u8, -} -fn image_display() -> u16 { - 7 -} -fn image_fifty() -> u8 { - 50 -} -fn image_pixel() -> Vec2 { - Vec2::new(1.0, 1.0) -} -impl Default for Image { - fn default() -> Self { - Image { - insert: Vec3::ZERO, - u: Vec3::new(1.0, 0.0, 0.0), - v: Vec3::new(0.0, 1.0, 0.0), - size: Vec2::new(1.0, 1.0), - path: String::new(), - name: String::new(), - clip: Vec::new(), - clipping: false, - display: image_display(), - brightness: image_fifty(), - contrast: image_fifty(), - fade: 0, - pixel_size: image_pixel(), - resolution_units: 0, - } - } -} -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct Wipeout { - pub boundary: Vec, -} -#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct TableCell { - pub text: String, - #[serde(default)] - pub merged: Option<(u32, u32)>, - /// The cell's own cell style; empty: its row's. - #[serde(default, skip_serializing_if = "String::is_empty")] - pub style: String, - /// A formula (`=Sum(A1:B3)`); `text` holds its value. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub formula: Option, - /// The cell's own text height (DXF 140 in the cell record), before the table's scale; - /// `None`: its cell style's. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub text_height: Option, -} -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct Table { - pub insert: Vec3, - pub col_widths: Vec, - pub row_heights: Vec, - pub cells: Vec>, - #[serde(default = "standard")] - pub style: String, - #[serde(default = "default_text_h")] - pub text_height: f64, - /// First row is a title (merged across, larger text). - #[serde(default)] - pub title: bool, - /// The row after the title holds column headers. - #[serde(default)] - pub header: bool, - /// The cell style of each row; missing or empty: by position (Title, Header, Data). - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub row_styles: Vec, - /// Table breaking (TABLEBREAK): the table drawn in several parts. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub breaks: Option, - /// The CSV file the cells come from (DATALINK). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub data_link: Option, -} -fn default_text_h() -> f64 { - 0.18 -} mod region; pub use region::*; -/// A raw DXF group: kept for round-tripping entities we do not model. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct RawTag { - pub code: i32, - pub value: String, -} +mod block; +mod common; +mod curves; +mod dim; +mod fill; +mod layout; +mod text; + +pub use block::{Attrib, AttribModes, AttribProps, Insert, MultilineAttrib, mtext_literal}; +pub use common::{Common, Handle, Lineweight, RawTag, Transparency}; +pub use curves::{Arc, Circle, Ellipse, Face3d, Line, LwPolyline, Point, Polyline3d, RayLine, Solid}; +pub use dim::{AssocSnap, DimAssoc, DimBreak, DimKind, DimMarks, Dimension, Inspection, JoggedRadius, Leader, MLeader}; +pub use fill::{Gradient, Hatch, HatchLoop, Wipeout}; +pub use layout::{Image, Table, TableCell, Viewport}; +pub use text::{HAlign, MText, MTextColumnType, MTextColumns, Text, VAlign}; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Unknown { pub dxf_type: String, diff --git a/crates/doc/src/entity/block.rs b/crates/doc/src/entity/block.rs new file mode 100644 index 000000000..166afee27 --- /dev/null +++ b/crates/doc/src/entity/block.rs @@ -0,0 +1,182 @@ +use super::common::{Common, Lineweight}; +use super::text::{MText, Text}; +use super::*; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Attrib { + pub tag: String, + pub text: Text, + #[serde(default)] + pub invisible: bool, + #[serde(default)] + pub constant: bool, + #[serde(default)] + pub prompt: String, + /// An attribute's own properties; each one left unset follows the attribute definition. + #[serde(default, skip_serializing_if = "AttribProps::is_empty")] + pub props: AttribProps, + /// A multiline attribute (DXF attribute type 2, or 4 for a definition): its text as + /// multiline text. `text.value` holds the plain text, which value edits change. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub multiline: Option, + /// Its other modes: Verify, Preset and Lock position. + #[serde(default, skip_serializing_if = "AttribModes::is_default")] + pub modes: AttribModes, +} + +/// An attribute's modes beyond invisible and constant. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AttribModes { + /// Verify: the value is asked for twice when the block is inserted (DXF flag 4). + #[serde(default)] + pub verify: bool, + /// Preset: the default value is used without asking (DXF flag 8). + #[serde(default)] + pub preset: bool, + /// Lock position: the attribute can't be moved on its own within a reference (DXF group + /// 280 after the flags). A reference's attributes take it from their definitions. + #[serde(default)] + pub lock_position: bool, +} + +impl AttribModes { + pub fn is_default(&self) -> bool { + *self == Self::default() + } + /// The modes of a reference's attribute made from a definition with these: AutoCAD keeps + /// only Lock position there. + pub fn for_reference(self) -> Self { + AttribModes { lock_position: self.lock_position, ..Self::default() } + } +} + +/// The multiline text of a multiline attribute. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MultilineAttrib { + pub mtext: MText, + /// The plain text `mtext`'s contents read as: an attribute whose value differs was edited + /// since, and its value replaces the contents. + pub read_as: String, +} + +impl MultilineAttrib { + /// This text placed by `m` (a block reference's transform, for its attribute made from a + /// definition). + pub fn transformed(&self, m: &Mat3) -> MultilineAttrib { + let mut k = EntityKind::MText(self.mtext.clone()); + k.transform(m); + let mtext = if let EntityKind::MText(t) = k { t } else { self.mtext.clone() }; + MultilineAttrib { mtext, read_as: self.read_as.clone() } + } +} + +impl Attrib { + /// Move a multiline attribute's text by `m` along with the attribute (nothing for others). + pub fn transform_multiline(&mut self, m: &Mat3) { + if let Some(ml) = &mut self.multiline { + *ml = ml.transformed(m); + } + } + + /// A multiline attribute's text as drawn and saved: its multiline text, with the contents + /// replaced by the value when the value was edited since it was read. + pub fn multiline_text(&self) -> Option { + let m = self.multiline.as_ref()?; + let mut t = m.mtext.clone(); + if self.text.value != m.read_as { + t.contents = mtext_literal(&self.text.value); + } + Some(t) + } +} + +/// `s` as MTEXT contents that read back as `s`: backslashes and braces escaped, line breaks as +/// paragraph breaks. +pub fn mtext_literal(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '\\' => out.push_str("\\\\"), + '{' => out.push_str("\\{"), + '}' => out.push_str("\\}"), + '\n' => out.push_str("\\P"), + '\r' => {} + c => out.push(c), + } + } + out +} + +/// Properties set on one attribute of a block reference (an ATTRIB's layer, colour, linetype and +/// lineweight), overriding those of its definition. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct AttribProps { + #[serde(skip_serializing_if = "Option::is_none")] + pub layer: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub color: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub linetype: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub lineweight: Option, +} + +impl AttribProps { + /// Nothing set: the attribute is drawn with its definition's properties. + pub fn is_empty(&self) -> bool { + *self == AttribProps::default() + } + /// `base` (the definition's properties) with the ones set here in place. + pub fn over(&self, base: &Common) -> Common { + Common { + layer: self.layer.clone().unwrap_or_else(|| base.layer.clone()), + color: self.color.unwrap_or(base.color), + linetype: self.linetype.clone().unwrap_or_else(|| base.linetype.clone()), + lineweight: self.lineweight.unwrap_or(base.lineweight), + ..base.clone() + } + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Insert { + pub block: String, + pub insert: Vec3, + #[serde(default = "unit_scale")] + pub scale: Vec3, + #[serde(default)] + pub rotation: f64, + #[serde(default)] + pub attribs: Vec, + #[serde(default = "one_u")] + pub cols: u32, + #[serde(default = "one_u")] + pub rows: u32, + #[serde(default)] + pub col_spacing: f64, + #[serde(default)] + pub row_spacing: f64, +} + +fn unit_scale() -> Vec3 { + Vec3::new(1.0, 1.0, 1.0) +} + +fn one_u() -> u32 { + 1 +} + +impl Insert { + /// Block-to-world transform (2D part). + pub fn transform(&self, base: Vec2) -> Mat3 { + Mat3::translate(self.insert.xy()) + .then_before(Mat3::rotate(self.rotation)) + .then_before(Mat3::scale(self.scale.x, self.scale.y)) + .then_before(Mat3::translate(-base)) + } +} diff --git a/crates/doc/src/entity/common.rs b/crates/doc/src/entity/common.rs new file mode 100644 index 000000000..6f7867ab3 --- /dev/null +++ b/crates/doc/src/entity/common.rs @@ -0,0 +1,132 @@ +use super::*; + +/// A database handle (DXF group 5), unique per drawing. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +pub struct Handle(pub u64); + +impl Handle { + pub fn hex(self) -> String { + format!("{:X}", self.0) + } + pub fn parse_hex(s: &str) -> Option { + let s = s.trim(); + if !s.bytes().all(|b| b.is_ascii_hexdigit()) { + return None; + } + u64::from_str_radix(s, 16).ok().map(Handle) + } +} + +/// Lineweight in hundredths of a millimetre, or a logical value. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Lineweight { + #[default] + ByLayer, + ByBlock, + Default, + /// 0..=211 (hundredths of mm). + Mm100(u16), +} + +impl Lineweight { + /// The standard lineweight values (mm × 100). + pub const STANDARD: [u16; 24] = [0, 5, 9, 13, 15, 18, 20, 25, 30, 35, 40, 50, 53, 60, 70, 80, 90, 100, 106, 120, 140, 158, 200, 211]; + pub fn to_dxf(self) -> i16 { + match self { + Lineweight::ByLayer => -1, + Lineweight::ByBlock => -2, + Lineweight::Default => -3, + Lineweight::Mm100(v) => v.min(211) as i16, + } + } + pub fn from_dxf(v: i16) -> Lineweight { + match v { + -2 => Lineweight::ByBlock, + -3 => Lineweight::Default, + 0..=211 => Lineweight::Mm100(v as u16), + _ => Lineweight::ByLayer, + } + } + pub fn name(self) -> String { + match self { + Lineweight::ByLayer => "ByLayer".into(), + Lineweight::ByBlock => "ByBlock".into(), + Lineweight::Default => "Default".into(), + Lineweight::Mm100(v) => format!("{:.2} mm", f64::from(v) / 100.0), + } + } +} + +/// Transparency: ByLayer, ByBlock, or 0..=90 percent. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Transparency { + #[default] + ByLayer, + ByBlock, + Percent(u8), +} + +impl Transparency { + /// "ByLayer", "ByBlock" or the percentage ("50"). + pub fn name(&self) -> String { + match self { + Transparency::ByLayer => "ByLayer".into(), + Transparency::ByBlock => "ByBlock".into(), + Transparency::Percent(p) => p.to_string(), + } + } +} + +/// Properties every entity has. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Common { + pub layer: String, + pub color: Color, + pub linetype: String, + pub lineweight: Lineweight, + pub ltscale: f64, + pub transparency: Transparency, + pub visible: bool, + pub thickness: f64, + pub extrusion: Vec3, + /// Named plot style: `ByLayer`, `ByBlock` or a style of the layout's named (`.stb`) plot + /// style table. + pub plot_style: String, + /// The annotation scales an annotative object supports (empty: not annotative). + #[serde(skip_serializing_if = "Vec::is_empty")] + pub anno_scales: Vec, + /// Other applications' extended data (xdata) as read from the drawing file: whole + /// applications, each from its `1001` group on. It is part of the object, so a copy of the + /// object (COPY, MIRROR, ARRAY, paste) carries it, as in AutoCAD. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub xdata: Vec, +} + +impl Default for Common { + fn default() -> Self { + Common { + layer: "0".into(), + color: Color::ByLayer, + linetype: "ByLayer".into(), + lineweight: Lineweight::ByLayer, + ltscale: 1.0, + transparency: Transparency::ByLayer, + visible: true, + thickness: 0.0, + extrusion: Vec3::Z, + plot_style: "ByLayer".into(), + anno_scales: Vec::new(), + xdata: Vec::new(), + } + } +} + +/// A raw DXF group: kept for round-tripping entities we do not model. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RawTag { + pub code: i32, + pub value: String, +} diff --git a/crates/doc/src/entity/curves.rs b/crates/doc/src/entity/curves.rs new file mode 100644 index 000000000..770b22559 --- /dev/null +++ b/crates/doc/src/entity/curves.rs @@ -0,0 +1,76 @@ +use super::*; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Line { + pub a: Vec3, + pub b: Vec3, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Point { + pub p: Vec3, + #[serde(default)] + pub angle: f64, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Circle { + pub center: Vec3, + pub radius: f64, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Arc { + pub center: Vec3, + pub radius: f64, + /// Radians, CCW. + pub start: f64, + pub end: f64, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Ellipse { + pub center: Vec3, + pub major: Vec3, + pub ratio: f64, + pub start: f64, + pub end: f64, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct LwPolyline { + pub vertices: Vec, + pub closed: bool, + #[serde(default)] + pub const_width: f64, + #[serde(default)] + pub elevation: f64, + /// Polyline linetype generation (PLINEGEN). + #[serde(default)] + pub plinegen: bool, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Polyline3d { + pub points: Vec, + pub closed: bool, +} + +/// SOLID / TRACE: up to four corners (DXF order 1,2,4,3 when drawn). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Solid { + pub corners: [Vec3; 4], +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Face3d { + pub corners: [Vec3; 4], + #[serde(default)] + pub hidden_edges: u8, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RayLine { + pub base: Vec3, + pub dir: Vec3, +} diff --git a/crates/doc/src/entity/dim.rs b/crates/doc/src/entity/dim.rs new file mode 100644 index 000000000..396e74c6f --- /dev/null +++ b/crates/doc/src/entity/dim.rs @@ -0,0 +1,226 @@ +use super::block::Insert; +use super::common::Handle; +use super::text::MText; +use super::*; + +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum DimKind { + /// Rotated linear dimension (horizontal = 0, vertical = 90°). + Linear { + rotation: f64, + }, + Aligned, + Angular, + Angular3P, + Diameter, + Radius, + Ordinate { + x_type: bool, + }, + ArcLength, +} + +/// A dimension. Definition points follow DXF: `defpt` (10: dimension line point), +/// `p13`/`p14` (extension origins), `p15`/`p16` (arc/angle points), `text_mid` (11). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Dimension { + pub kind: DimKind, + pub defpt: Vec3, + pub text_mid: Vec3, + #[serde(default)] + pub p13: Vec3, + #[serde(default)] + pub p14: Vec3, + #[serde(default)] + pub p15: Vec3, + #[serde(default)] + pub p16: Vec3, + /// "" = measured value, "<>" inside = measurement placeholder, " " = suppressed. + #[serde(default)] + pub text: String, + #[serde(default = "standard")] + pub style: String, + #[serde(default)] + pub measurement: f64, + #[serde(default)] + pub text_rotation: f64, + /// User moved the text (text_mid is authoritative). + #[serde(default)] + pub user_text_pos: bool, + /// Anonymous block with the rendered geometry (from files); regenerated when edited. + #[serde(default)] + pub block: Option, + /// Per-dimension style overrides (DIMOVERRIDE): DimStyle field names (camelCase) → values, + /// merged over the named style. + #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")] + pub overrides: serde_json::Map, + /// Associativity: which definition points follow which objects. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub assoc: Vec, + /// Oblique extension lines, jogs, breaks and inspection frames. + #[serde(default, skip_serializing_if = "DimMarks::is_empty")] + pub marks: DimMarks, +} + +/// What dimension edits add to a dimension's drawing: DIMEDIT Oblique, DIMJOGLINE, DIMJOGGED, +/// DIMBREAK and DIMINSPECT. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct DimMarks { + /// Angle (radians) of the extension lines of a linear or aligned dimension; None = + /// perpendicular to the dimension line. + #[serde(skip_serializing_if = "Option::is_none")] + pub oblique: Option, + /// A jog on the dimension line of a linear or aligned dimension, at this point (DIMJOGLINE). + #[serde(skip_serializing_if = "Option::is_none")] + pub jog_line: Option, + /// A jogged radius dimension (DIMJOGGED). + #[serde(skip_serializing_if = "Option::is_none")] + pub jogged: Option, + /// Gaps in the dimension and extension lines (DIMBREAK). + #[serde(skip_serializing_if = "Vec::is_empty")] + pub breaks: Vec, + /// Inspection frame, label and rate (DIMINSPECT). + #[serde(skip_serializing_if = "Option::is_none")] + pub inspect: Option, +} + +impl DimMarks { + pub fn is_empty(&self) -> bool { + *self == DimMarks::default() + } + + /// Transform the points and angles (part of [`EntityKind::transform`]). + pub(super) fn transform(&mut self, t3: &dyn Fn(&mut Vec3), rot: f64, s: f64, mirror: bool) { + if let Some(a) = &mut self.oblique { + *a = cadcraft_geom::norm_angle(if mirror { rot - (*a - rot) } else { *a + rot }); + } + if let Some(j) = &mut self.jog_line { + t3(j); + } + if let Some(j) = &mut self.jogged { + t3(&mut j.center); + t3(&mut j.jog); + } + for b in &mut self.breaks { + t3(&mut b.at); + b.size *= s; + } + } +} + +/// The definition of a jogged radius dimension: the dimension line starts at an overriding +/// centre and jogs on its way to the arc. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct JoggedRadius { + /// Where the dimension line starts instead of the true centre. + pub center: Vec3, + /// The middle of the jog. + pub jog: Vec3, +} + +/// A gap in a dimension's lines: everything within `size / 2` of `at` is left out. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct DimBreak { + pub at: Vec3, + pub size: f64, + /// Made by DIMBREAK Auto (recomputed by the next Auto) rather than picked by hand. + pub auto: bool, +} + +/// An inspection dimension (DIMINSPECT). +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Inspection { + /// Frame shape: 0 round ends, 1 angular ends, 2 none. + pub shape: u8, + /// Text left of the value ("" = none). + pub label: String, + /// Inspection rate right of the value ("" = none). + pub rate: String, +} + +/// A dimension definition point that follows an object (associative dimensions). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DimAssoc { + /// Definition point: "defpt", "p13", "p14", "p15" or "p16". + pub point: String, + /// The object it is attached to. + pub handle: Handle, + pub snap: AssocSnap, +} + +/// Where on the object an associative definition point sits. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum AssocSnap { + /// Start point of a line/arc (or polyline vertex 0). + Start, + /// End point of a line/arc. + End, + Mid, + /// Centre of a circle/arc. + Center, + /// On a circle/arc at a fixed angle (radians) from its centre. + OnCircle { + angle: f64, + }, + /// Intersection of this (line) object with another line. + Intersection { + other: Handle, + }, + /// Vertex `index` of a polyline. + Vertex { + index: usize, + }, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Leader { + pub vertices: Vec, + #[serde(default = "yes")] + pub arrow: bool, + #[serde(default)] + pub spline: bool, + #[serde(default = "standard")] + pub style: String, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MLeader { + pub leaders: Vec>, + pub landing: Vec3, + pub dogleg: f64, + pub text: Option, + #[serde(default = "standard")] + pub style: String, + #[serde(default = "one")] + pub arrow_size: f64, + /// Leader lines are smooth curves through their points (leader type Spline). An empty + /// `leaders` is a multileader without a leader line (leader type None). + #[serde(default)] + pub spline: bool, + /// Block content in place of the text: a block reference placed at the landing. + #[serde(default)] + pub block: Option, +} + +impl MLeader { + /// Each leader line as drawn: through its points to the landing, smoothed for spline leaders. + pub fn leader_paths(&self) -> Vec> { + self.leaders + .iter() + .map(|l| { + let pts: Vec = l.iter().map(|v| v.xy()).chain(std::iter::once(self.landing.xy())).collect(); + if self.spline && pts.len() > 2 { GSpline::from_fit_points(&pts).tessellate(1e-3) } else { pts } + }) + .collect() + } +} diff --git a/crates/doc/src/entity/fill.rs b/crates/doc/src/entity/fill.rs new file mode 100644 index 000000000..1b69108a0 --- /dev/null +++ b/crates/doc/src/entity/fill.rs @@ -0,0 +1,61 @@ +use super::common::Handle; +use super::*; + +/// A hatch boundary loop: closed polyline with bulges. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct HatchLoop { + pub vertices: Vec, + #[serde(default)] + pub outer: bool, + /// The objects the loop was made from (DXF group 97 count, 330 handles): an associative + /// hatch re-fills the loop from them when they change. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub sources: Vec, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Hatch { + pub pattern: String, + pub solid: bool, + pub loops: Vec, + #[serde(default = "one")] + pub scale: f64, + #[serde(default)] + pub angle: f64, + #[serde(default)] + pub associative: bool, + /// 0 normal (odd parity), 1 outer, 2 ignore. + #[serde(default)] + pub style: u8, + #[serde(default)] + pub elevation: f64, + /// Gradient fill: name + second colour. + #[serde(default)] + pub gradient: Option, + #[serde(default)] + pub origin: Vec2, + #[serde(default)] + pub background: Option, + /// The pattern's line families as a file defined them, for a pattern that isn't in the + /// standard library (unit scale and angle, like the library's; empty otherwise). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub pattern_lines: Vec, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Gradient { + pub name: String, + pub color1: Color, + pub color2: Color, + #[serde(default)] + pub angle: f64, + #[serde(default)] + pub centered: bool, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Wipeout { + pub boundary: Vec, +} diff --git a/crates/doc/src/entity/layout.rs b/crates/doc/src/entity/layout.rs new file mode 100644 index 000000000..7ebe49eee --- /dev/null +++ b/crates/doc/src/entity/layout.rs @@ -0,0 +1,168 @@ +use super::common::Handle; +use super::*; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Viewport { + pub center: Vec3, + pub width: f64, + pub height: f64, + pub view_center: Vec2, + pub view_height: f64, + #[serde(default)] + pub id: u32, + #[serde(default)] + pub locked: bool, + #[serde(default)] + pub frozen_layers: Vec, + /// Per-viewport layer colour overrides (VP Color in the Layer Properties Manager). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub layer_colors: Vec<(String, Color)>, + /// The paper-space entity whose outline clips the viewport (a closed polyline, circle, + /// ellipse or spline; DXF group 340). `None`: the rectangle `center`/`width`/`height`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub clip: Option, + /// Display on (MVIEW ON/OFF). An off viewport shows its border but no model space. + #[serde(default = "yes")] + pub on: bool, +} + +impl Default for Viewport { + fn default() -> Self { + Viewport { + center: Vec3::ZERO, + width: 1.0, + height: 1.0, + view_center: Vec2::ZERO, + view_height: 1.0, + id: 0, + locked: false, + frozen_layers: Vec::new(), + layer_colors: Vec::new(), + clip: None, + on: true, + } + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Image { + pub insert: Vec3, + /// One pixel's width (U) and height (V) vectors in drawing units. + pub u: Vec3, + pub v: Vec3, + /// Image size in pixels. + pub size: Vec2, + /// The image file (its IMAGEDEF object's path). + pub path: String, + /// The image definition's name (its key in the drawing's image dictionary). + #[serde(default, skip_serializing_if = "String::is_empty")] + pub name: String, + /// Clip boundary in pixel coordinates (origin at the top-left corner of the image, y down): + /// two opposite corners of a rectangle, or a polygon's vertices. Empty: the whole image. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub clip: Vec, + /// The clip boundary is applied. + #[serde(default)] + pub clipping: bool, + /// Display flags: 1 show the image, 2 show it when not aligned with the screen, 4 use the + /// clip boundary, 8 transparency on. + #[serde(default = "image_display")] + pub display: u16, + /// Brightness, contrast and fade, 0..=100. + #[serde(default = "image_fifty")] + pub brightness: u8, + #[serde(default = "image_fifty")] + pub contrast: u8, + #[serde(default)] + pub fade: u8, + /// The definition's default size of one pixel and its resolution unit (0 none, 2 cm, 5 inch). + #[serde(default = "image_pixel")] + pub pixel_size: Vec2, + #[serde(default)] + pub resolution_units: u8, +} + +fn image_display() -> u16 { + 7 +} + +fn image_fifty() -> u8 { + 50 +} + +fn image_pixel() -> Vec2 { + Vec2::new(1.0, 1.0) +} + +impl Default for Image { + fn default() -> Self { + Image { + insert: Vec3::ZERO, + u: Vec3::new(1.0, 0.0, 0.0), + v: Vec3::new(0.0, 1.0, 0.0), + size: Vec2::new(1.0, 1.0), + path: String::new(), + name: String::new(), + clip: Vec::new(), + clipping: false, + display: image_display(), + brightness: image_fifty(), + contrast: image_fifty(), + fade: 0, + pixel_size: image_pixel(), + resolution_units: 0, + } + } +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TableCell { + pub text: String, + #[serde(default)] + pub merged: Option<(u32, u32)>, + /// The cell's own cell style; empty: its row's. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub style: String, + /// A formula (`=Sum(A1:B3)`); `text` holds its value. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub formula: Option, + /// The cell's own text height (DXF 140 in the cell record), before the table's scale; + /// `None`: its cell style's. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub text_height: Option, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Table { + pub insert: Vec3, + pub col_widths: Vec, + pub row_heights: Vec, + pub cells: Vec>, + #[serde(default = "standard")] + pub style: String, + #[serde(default = "default_text_h")] + pub text_height: f64, + /// First row is a title (merged across, larger text). + #[serde(default)] + pub title: bool, + /// The row after the title holds column headers. + #[serde(default)] + pub header: bool, + /// The cell style of each row; missing or empty: by position (Title, Header, Data). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub row_styles: Vec, + /// Table breaking (TABLEBREAK): the table drawn in several parts. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub breaks: Option, + /// The CSV file the cells come from (DATALINK). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub data_link: Option, +} + +fn default_text_h() -> f64 { + 0.18 +} diff --git a/crates/doc/src/entity/text.rs b/crates/doc/src/entity/text.rs new file mode 100644 index 000000000..a4eff0427 --- /dev/null +++ b/crates/doc/src/entity/text.rs @@ -0,0 +1,141 @@ +use super::*; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum HAlign { + #[default] + Left, + Center, + Right, + Aligned, + Middle, + Fit, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum VAlign { + #[default] + Baseline, + Bottom, + Middle, + Top, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Text { + pub insert: Vec3, + #[serde(default)] + pub align_pt: Option, + pub height: f64, + pub value: String, + #[serde(default)] + pub rotation: f64, + #[serde(default = "one")] + pub width_factor: f64, + #[serde(default)] + pub oblique: f64, + #[serde(default = "standard")] + pub style: String, + #[serde(default)] + pub halign: HAlign, + #[serde(default)] + pub valign: VAlign, +} + +impl Text { + /// The point the text is placed by, as the Insertion snap and the grip show it: the alignment + /// point (DXF group 11) of justified text, the start point (group 10) of left-baseline, + /// aligned and fit text. + pub fn justify_point(&self) -> Vec2 { + match (self.halign, self.valign) { + (HAlign::Left, VAlign::Baseline) | (HAlign::Aligned | HAlign::Fit, _) => self.insert.xy(), + _ => self.align_pt.unwrap_or(self.insert).xy(), + } + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MText { + pub insert: Vec3, + pub height: f64, + /// Reference rectangle width (0 = no wrapping). + #[serde(default)] + pub width: f64, + /// 1..=9: TL TC TR ML MC MR BL BC BR. + #[serde(default = "attach_tl")] + pub attach: u8, + #[serde(default)] + pub rotation: f64, + #[serde(default = "standard")] + pub style: String, + /// Raw MTEXT contents with inline codes (\P, {\fArial|b1;...}, \S1/2; …). + pub contents: String, + #[serde(default = "one")] + pub line_spacing: f64, + /// Line spacing style (DXF 73): Exactly (`true`) keeps `line_spacing` whatever the character + /// heights; At least (`false`, the default) lets taller characters push lines apart. + #[serde(default)] + pub line_spacing_exact: bool, + /// Columns the text flows through, each `width` wide (DXF 75/76/78/79/48/49/50); `None`: + /// one column. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub columns: Option, +} + +fn attach_tl() -> u8 { + 1 +} + +/// Static columns: a fixed number of columns of one height. Dynamic columns: as many columns +/// as the text needs, of one height (auto height) or each its own (manual heights). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum MTextColumnType { + #[default] + Static, + Dynamic, +} + +/// MTEXT columns (MTEXT Columns option, DXF Reference MTEXT groups 75–79, 48–50). +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct MTextColumns { + #[serde(rename = "type")] + pub kind: MTextColumnType, + /// Number of columns (static columns: the columns there are). + pub count: u16, + /// Space between two columns. + pub gutter: f64, + /// Height of every column (static, and dynamic with auto height). + pub height: f64, + /// Dynamic columns: one height for all columns (`height`) instead of `heights`. + pub auto_height: bool, + /// Dynamic columns with manual heights: the height of each column. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub heights: Vec, + /// Columns run right to left. + pub flow_reversed: bool, +} + +impl MTextColumns { + /// The width of `count` columns of `column_width` and their gutters. + pub fn total_width(&self, column_width: f64) -> f64 { + let n = f64::from(self.count.max(1)); + column_width * n + self.gutter.max(0.0) * (n - 1.0) + } + + /// The tallest column. + pub fn max_height(&self) -> f64 { + self.heights.iter().copied().fold(self.height, f64::max).max(0.0) + } + + /// Scale every length (SCALE, block insertion). + pub fn scale(&mut self, s: f64) { + self.gutter *= s; + self.height *= s; + self.heights.iter_mut().for_each(|h| *h *= s); + } +} diff --git a/crates/doc/src/tables.rs b/crates/doc/src/tables.rs index 328634831..a2c63ca6a 100644 --- a/crates/doc/src/tables.rs +++ b/crates/doc/src/tables.rs @@ -441,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. @@ -458,29 +501,7 @@ 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. @@ -502,16 +523,7 @@ impl DimStyle { _ => 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" | "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, - }; + let out_of_range = Self::out_of_range(field, &coerced); if out_of_range { rejected.push(k.clone()); continue; diff --git a/crates/engine/src/blockedit.rs b/crates/engine/src/blockedit.rs index 4dab97f1f..fae52757e 100644 --- a/crates/engine/src/blockedit.rs +++ b/crates/engine/src/blockedit.rs @@ -144,6 +144,11 @@ impl BlockEdit { } let host = &self.host.doc; let mut d = editor.clone(); + // When the block is saved under another name, the edited block keeps the drawing's + // description and block `name` gets the description changed in the editor. + if !name.eq_ignore_ascii_case(&self.name) { + keep_description(&mut d, host, &self.name); + } d.model = host.model.clone(); d.layouts = host.layouts.clone(); d.groups = host.groups.clone(); @@ -171,6 +176,21 @@ impl BlockEdit { } } +/// Give block `name` of `d` the description `host` has for it, or, when `host` has no such block, +/// drop the empty definition a description gives a block the editor started and never saved. +fn keep_description(d: &mut Drawing, host: &Drawing, name: &str) { + match host.block(name) { + Some(h) => { + if let Some((_, b)) = d.blocks.iter_mut().find(|(k, _)| k.eq_ignore_ascii_case(name)) + && b.description != h.description + { + Arc::make_mut(b).description = h.description.clone(); + } + } + None => d.blocks.retain(|k, b| !(k.eq_ignore_ascii_case(name) && b.entities.is_empty())), + } +} + impl DocState { /// The drawing as a file saves it: outside the Block Editor's changes while it is open. pub fn drawing(&self) -> &Arc { @@ -272,6 +292,24 @@ impl DocState { let d = be.compose(&self.doc, &name)?; let original = d.block(&name).map(|b| b.entities.clone()).unwrap_or_default(); let name = d.block(&name).map(|b| b.name.clone()).unwrap_or(name); + // When BSAVEAS saves the block under another name, the editor's drawing and each undo and + // redo step get the block as saved and the edited block as the drawing has it, so a later + // save and the description shown start from them after an undo too. + if !name.eq_ignore_ascii_case(&be.name) + && let Some(b) = d.block(&name).cloned() + { + let was = be.name.clone(); + let reconcile = |doc: &mut Arc| { + let editor = Arc::make_mut(doc); + keep_description(editor, &d, &was); + editor.blocks.retain(|k, _| !k.eq_ignore_ascii_case(&name)); + editor.blocks.insert(name.clone(), b.clone()); + }; + reconcile(&mut self.doc); + for step in self.undo.iter_mut().chain(self.redo.iter_mut()) { + reconcile(&mut step.doc); + } + } let editor = self.doc.clone(); let Some(be) = &mut self.block_edit else { return Ok(()) }; let before = be.host.doc.clone(); diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd.rs similarity index 100% rename from crates/engine/src/cmd/mod.rs rename to crates/engine/src/cmd.rs diff --git a/crates/engine/src/cmd/annotate.rs b/crates/engine/src/cmd/annotate.rs index 6fdd0869d..08f87fc4b 100644 --- a/crates/engine/src/cmd/annotate.rs +++ b/crates/engine/src/cmd/annotate.rs @@ -13,6 +13,21 @@ use super::{CommandSpec, bad, point_param, point_req, points_param, str_param, t use crate::{Accept, EngineError, Input, Interactive, LeaderOrder, MLeaderOptions, Prompt, Result, Session, Step}; +mod angular; +mod common; +mod edit; +mod linear; +mod mleader; +mod radial; + +use angular::{AngularM, ArcLenM, run_angular, run_arclen}; +pub(super) use common::segment_at; +use common::{DimText, add_dim, assoc_enabled, circle_of, dim, pick_at}; +use edit::{DimSpaceM, OverrideM, dims_of, run_dimspace, run_dimtedit, run_disassociate, run_override, run_reassociate, run_tedit, run_update}; +use linear::{ChainM, LinearM, MAX_CHAIN, OrdinateM, run_aligned, run_baseline, run_continue, run_linear, run_ordinate}; +use mleader::{MLeaderM, run_leader, run_mleader}; +use radial::{RadialM, run_diameter, run_radius}; + pub fn specs() -> Vec { vec![ CommandSpec::new("dimlinear", "Linear", run_linear) @@ -116,1717 +131,10 @@ pub fn specs() -> Vec { .collect() } -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, text: &str) -> EntityKind { - EntityKind::Dimension(Dimension { - kind, - defpt: v3(defpt), - text_mid: Vec3::ZERO, - p13: v3(p13), - p14: v3(p14), - p15: v3(p15), - p16: v3(Vec2::ZERO), - text: text.into(), - style: style(s), - measurement: 0.0, - text_rotation: 0.0, - user_text_pos: false, - block: None, - overrides: s.doc().map(|d| d.dim_overrides()).unwrap_or_default(), - assoc: Vec::new(), - marks: Default::default(), - }) -} - -/// Horizontal (0) or vertical (90°) from where the dimension line is placed (AutoCAD's rule). -fn auto_rotation(p1: Vec2, p2: Vec2, at: Vec2) -> f64 { - let (x0, x1) = (p1.x.min(p2.x), p1.x.max(p2.x)); - let (y0, y1) = (p1.y.min(p2.y), p1.y.max(p2.y)); - let outside_y = at.y < y0 || at.y > y1; - let outside_x = at.x < x0 || at.x > x1; - if outside_x && !outside_y { std::f64::consts::FRAC_PI_2 } else { 0.0 } -} - -fn linear_kind(p1: Vec2, p2: Vec2, at: Vec2, rotation: Option, aligned: bool) -> DimKind { - if aligned { DimKind::Aligned } else { DimKind::Linear { rotation: rotation.unwrap_or_else(|| auto_rotation(p1, p2, at)) } } -} - -/// Add a dimension. With DIMASSOC on, definition points that sit on object endpoints, -/// vertices or centres are attached to them (associative dimensions). -fn add_dim(s: &mut Session, k: EntityKind) -> Result { - let mut k = k; - if let EntityKind::Dimension(dm) = &mut k - && dm.assoc.is_empty() - && assoc_enabled(s) - { - dm.assoc = crate::assoc::reassociate(s.doc()?, &s.space(), dm); - } - let h = s.add_entity(k)?; - s.last_dim = Some(h); - Ok(h) -} - -fn assoc_enabled(s: &Session) -> bool { - s.doc().map(|d| d.header.i64("DIMASSOC", 2) != 0).unwrap_or(false) -} - -fn with_assoc(k: EntityKind, assoc: Vec) -> EntityKind { - match k { - EntityKind::Dimension(mut d) => { - d.assoc = assoc; - EntityKind::Dimension(d) - } - other => other, - } -} - -/// Two-line angular dimension (type 2) between two lines, attached to them. -fn angular_from_lines(s: &Session, h1: Handle, h2: Handle, at: Vec2) -> Option { - let d = s.doc().ok()?; - let line = |h: Handle| match &d.entity(h)?.kind { - EntityKind::Line(l) => Some((l.a.xy(), l.b.xy())), - _ => None, - }; - let ((a1, a2), (b1, b2)) = (line(h1)?, line(h2)?); - let (v, _, _) = cadcraft_geom::line_line_infinite(a1, a2, b1, b2)?; - // Use the leg ends on the side of the picked arc location (the quadrant the user meant). - let pick = |p: Vec2, q: Vec2, snap_p: AssocSnap, snap_q: AssocSnap| { - let dir = if (p - v).len() > (q - v).len() { (p, snap_p) } else { (q, snap_q) }; - let other = if dir.1 == AssocSnap::Start { (q, AssocSnap::End) } else { (p, AssocSnap::Start) }; - let side = |x: Vec2| (x - v).normalized().dot((at - v).normalized()); - if side(other.0) > side(dir.0) && other.0.dist(v) > 1e-9 { other } else { dir } - }; - let (p1, s1) = pick(a1, a2, AssocSnap::Start, AssocSnap::End); - let (p2, s2) = pick(b1, b2, AssocSnap::Start, AssocSnap::End); - // A two-line angular dimension (type 2): line 1 is p13 -> p14, line 2 is defpt -> p15, p16 is on the arc. - // The picked leg end goes last in each pair, so the renderer measures between p14 and p15. - let (o1, t1) = if s1 == AssocSnap::Start { (a2, AssocSnap::End) } else { (a1, AssocSnap::Start) }; - let (o2, t2) = if s2 == AssocSnap::Start { (b2, AssocSnap::End) } else { (b1, AssocSnap::Start) }; - let mut k = dim(s, DimKind::Angular, o2, o1, p1, p2, ""); - if let EntityKind::Dimension(d) = &mut k { - d.p16 = v3(at); - } - let mut assoc = Vec::new(); - if assoc_enabled(s) { - assoc = vec![ - DimAssoc { point: "p13".into(), handle: h1, snap: t1 }, - DimAssoc { point: "p14".into(), handle: h1, snap: s1 }, - DimAssoc { point: "defpt".into(), handle: h2, snap: t2 }, - DimAssoc { point: "p15".into(), handle: h2, snap: s2 }, - ]; - } - Some(with_assoc(k, assoc)) -} - -fn run_linear(s: &mut Session, p: &Value) -> Result { - let (a, b) = match p.get("object").and_then(|v| v.as_str().and_then(Handle::parse_hex).or_else(|| v.as_u64().map(Handle))) { - Some(h) => object_extent(s, h).ok_or_else(|| bad("dimlinear", "`object` must be a line, arc, circle or polyline segment"))?, - None => (point_req("dimlinear", p, "p1")?, point_req("dimlinear", p, "p2")?), - }; - 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, str_param(p, "text").unwrap_or("")); - Ok(json!({ "handle": add_dim(s, k)?.hex() })) -} - -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, str_param(p, "text").unwrap_or("")); - Ok(json!({ "handle": add_dim(s, k)?.hex() })) -} - -/// Centre and radius of a circle/arc entity. -fn circle_of(s: &Session, h: Handle) -> Option<(Vec2, f64)> { - match &s.doc().ok()?.entity(h)?.kind { - EntityKind::Circle(c) => Some((c.center.xy(), c.radius)), - EntityKind::Arc(a) => Some((a.center.xy(), a.radius)), - _ => None, - } -} - -fn radial(s: &mut Session, p: &Value, diameter: bool) -> Result { - let id = if diameter { "dimdiameter" } else { "dimradius" }; - let (c, pt, at) = if let (Some(c), Some(pt)) = (point_param(p, "center"), point_param(p, "point")) { - (c, pt, None) - } else { - let h = targets(s, p)?.first().copied().ok_or_else(|| bad(id, "`handle` of a circle/arc, or {center, point}"))?; - let (c, r) = circle_of(s, h).ok_or_else(|| bad(id, "object is not a circle or arc"))?; - let at = point_param(p, "at"); - let dir = at.unwrap_or(c + Vec2::from_angle(std::f64::consts::FRAC_PI_4)); - (c, c + (dir - c).normalized() * r, at) - }; - if c.near(pt, 1e-12) { - 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, "") - } else { - dim(s, DimKind::Radius, c, Vec2::ZERO, Vec2::ZERO, pt, "") - }; - let k = match at { - Some(at) => text_at(k, at), - None => k, - }; - Ok(json!({ "handle": add_dim(s, k)?.hex() })) -} - -/// The dimension line location of a radius/diameter dimension puts the text there, inside or -/// outside the circle, as a user-positioned text (DXF flag 70 bit 128), like AutoCAD. -fn text_at(mut k: EntityKind, at: Vec2) -> EntityKind { - if let EntityKind::Dimension(d) = &mut k { - d.text_mid = v3(at); - d.user_text_pos = true; - } - k -} - -fn run_radius(s: &mut Session, p: &Value) -> Result { - radial(s, p, false) -} -fn run_diameter(s: &mut Session, p: &Value) -> Result { - radial(s, p, true) -} - -fn hex_param(p: &Value, key: &str) -> Option { - p.get(key).and_then(|v| v.as_str().and_then(Handle::parse_hex).or_else(|| v.as_u64().map(Handle))) -} - -fn run_angular(s: &mut Session, p: &Value) -> Result { - if let Some(ls) = p.get("lines").and_then(Value::as_array) { - let hs: Vec = ls.iter().filter_map(|v| v.as_str().and_then(Handle::parse_hex).or_else(|| v.as_u64().map(Handle))).collect(); - let (Some(h1), Some(h2)) = (hs.first(), hs.get(1)) else { return Err(bad("dimangular", "`lines` needs two line handles")) }; - let at = point_req("dimangular", p, "at")?; - let k = angular_from_lines(s, *h1, *h2, at).ok_or_else(|| bad("dimangular", "objects must be two non-parallel lines"))?; - return Ok(json!({ "handle": add_dim(s, k)?.hex() })); - } - if let Some(h) = hex_param(p, "arc") { - let at = point_req("dimangular", p, "at")?; - let a = match s.doc()?.entity(h).map(|e| e.kind.clone()) { - Some(EntityKind::Arc(a)) => a, - _ => 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, ""); - return Ok(json!({ "handle": add_dim(s, k)?.hex() })); - } - let v = point_req("dimangular", p, "vertex")?; - let a = point_req("dimangular", p, "p1")?; - let b = point_req("dimangular", p, "p2")?; - let at = point_req("dimangular", p, "at")?; - 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, ""); - Ok(json!({ "handle": add_dim(s, k)?.hex() })) -} - -fn run_arclen(s: &mut Session, p: &Value) -> Result { - let h = targets(s, p)?.first().copied().ok_or_else(|| bad("dimarc", "`handle` of an arc is required"))?; - let at = point_req("dimarc", p, "at")?; - let a = match s.doc()?.entity(h).map(|e| e.kind.clone()) { - Some(EntityKind::Arc(a)) => a, - _ => 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, ""); - Ok(json!({ "handle": add_dim(s, k)?.hex() })) -} - -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, ""); - Ok(json!({ "handle": add_dim(s, k)?.hex() })) -} - -/// The last linear/aligned dimension (for CONTINUE/BASELINE). -fn last_linear(s: &Session) -> Option { - let h = s.last_dim?; - match &s.doc().ok()?.entity(h)?.kind { - EntityKind::Dimension(d) if matches!(d.kind, DimKind::Linear { .. } | DimKind::Aligned) => Some(d.clone()), - _ => None, - } -} - -/// Next dimension in a chain from `prev` to a new second origin. -fn chained(s: &Session, prev: &Dimension, next: Vec2, baseline: bool) -> EntityKind { - // DIMDLI × the overall scale of the dimension being continued: its overrides (SETVAR DIM*) - // included, and DIMSCALE 0 falling back to the drawing's DIMSCALE as when it is drawn. - let spacing = s - .doc() - .ok() - .map(|d| { - let st = d.dim_style(&prev.style).cloned().unwrap_or_default().with_overrides(&prev.overrides); - st.baseline_spacing * st.effective_scale(d.header.f64("DIMSCALE", 1.0)) - }) - .filter(|v| v.is_finite()) - .unwrap_or(0.38); - let dir = match prev.kind { - DimKind::Linear { rotation } => Vec2::from_angle(rotation), - _ => (prev.p14.xy() - prev.p13.xy()).normalized(), - }; - let first = if baseline { prev.p13.xy() } else { prev.p14.xy() }; - let mut defpt = prev.defpt.xy(); - if baseline { - // Offset the dimension line away from the measured points. - let n = dir.perp(); - let side = if (defpt - prev.p13.xy()).dot(n) >= 0.0 { 1.0 } else { -1.0 }; - defpt += n * (spacing * side); - } - EntityKind::Dimension(Dimension { - p13: v3(first), - p14: v3(next), - defpt: v3(defpt), - text: String::new(), - block: None, - assoc: Vec::new(), - marks: Default::default(), - user_text_pos: false, - ..prev.clone() - }) -} - -/// Most dimensions one DIMCONTINUE/DIMBASELINE call adds. -const MAX_CHAIN: usize = 100_000; - -fn chain_run(s: &mut Session, p: &Value, baseline: bool) -> Result { - let id = if baseline { "dimbaseline" } else { "dimcontinue" }; - let pts = p.get("points").and_then(Value::as_array).ok_or_else(|| bad(id, "`points` is required"))?; - if pts.len() > MAX_CHAIN { - return Err(bad(id, format!("at most {MAX_CHAIN} `points` per call"))); - } - let pts = points_param(p, "points").ok_or_else(|| bad(id, "`points` must be a list of points"))?; - // One snap index for the whole chain: looking up each new point's object by scanning the - // drawing made long chains quadratic (the added dimensions are no snap targets). - let snaps = if assoc_enabled(s) { Some(crate::assoc::SnapIndex::new(s.doc()?, &s.space())) } else { None }; - let mut out = Vec::new(); - for q in pts { - let prev = last_linear(s).ok_or_else(|| bad(id, "no linear dimension to continue"))?; - let mut k = chained(s, &prev, q, baseline); - if let (Some(ix), EntityKind::Dimension(dm)) = (&snaps, &mut k) { - dm.assoc = ix.auto_assoc(&[("p13", dm.p13.xy()), ("p14", dm.p14.xy())]); - } - let h = s.add_entity(k)?; - s.last_dim = Some(h); - out.push(h.hex()); - } - Ok(json!({ "handles": out })) -} - -fn run_continue(s: &mut Session, p: &Value) -> Result { - chain_run(s, p, false) -} -fn run_baseline(s: &mut Session, p: &Value) -> Result { - chain_run(s, p, true) -} - /// QDIM: its modes and prompts. #[path = "annotate_qdim.rs"] mod qdim; -/// A multileader from arrowhead to landing; `landing` false leaves out the landing line (dogleg). -fn mleader_kind(s: &Session, pts: &[Vec2], text: &str, landing: bool) -> Option { - let (landing_pt, arrow_pts) = pts.split_last()?; - let d = s.doc().ok()?; - let cur = d.header.str("CMLEADERSTYLE", "Standard"); - let st = d.mleader_styles.iter().find(|m| m.name.eq_ignore_ascii_case(&cur)).or(d.mleader_styles.first()).cloned().unwrap_or_default(); - // The style's sizes are the drawn sizes: a multileader style has no scale of its own and - // DIMSCALE (a dimension-style setting) does not apply, as in AutoCAD. - let th = st.text_height; - let dir = if arrow_pts.first().is_some_and(|a| a.x > landing_pt.x) { -1.0 } else { 1.0 }; - let attach = if dir > 0.0 { 4 } else { 6 }; - let dogleg = if landing { st.dogleg } else { 0.0 }; - let tpos = *landing_pt + Vec2::new((dogleg + st.landing_gap) * dir, 0.0); - Some(EntityKind::MLeader(MLeader { - leaders: vec![arrow_pts.iter().map(|p| v3(*p)).collect()], - landing: v3(*landing_pt), - dogleg, - text: (!text.is_empty()).then(|| MText { - insert: v3(tpos), - height: th, - width: 0.0, - attach, - rotation: 0.0, - style: st.text_style.clone(), - contents: text.replace('\n', "\\P"), - line_spacing: 1.0, - line_spacing_exact: false, - columns: None, - }), - style: st.name.clone(), - arrow_size: st.arrow_size, - spline: false, - block: None, - })) -} - -fn run_mleader(s: &mut Session, p: &Value) -> Result { - let pts = points_param(p, "points").ok_or_else(|| bad("mleader", "`points` (arrow … landing) is required"))?; - let content = super::mleader_content::json_content(s, p)?; - // Leader type None places the content at the one point given. - if pts.len() < 2 && !(content.leader == crate::LeaderType::None && pts.len() == 1) { - return Err(bad("mleader", "need an arrowhead and a landing point")); - } - let k = mleader_kind(s, &pts, str_param(p, "text").unwrap_or(""), true).ok_or_else(|| bad("mleader", "bad points"))?; - let k = super::mleader_content::apply(s, k, &content)?; - Ok(json!({ "handle": s.add_entity(k)?.hex() })) -} - -fn run_leader(s: &mut Session, p: &Value) -> Result { - let pts = points_param(p, "points").ok_or_else(|| bad("leader", "`points` is required"))?; - if pts.len() < 2 { - return Err(bad("leader", "need 2+ points")); - } - let st = style(s); - let h = s.add_entity(EntityKind::Leader(cadcraft_doc::Leader { - vertices: pts.iter().map(|q| v3(*q)).collect(), - arrow: true, - spline: false, - style: st, - }))?; - let mut out = vec![h.hex()]; - if let (Some(t), Some(last)) = (str_param(p, "text"), pts.last()) { - let th = s.doc()?.dim_style(&style(s)).map(|d| d.text_height).unwrap_or(0.18); - out.push( - s.add_entity(EntityKind::MText(MText { - insert: v3(*last + Vec2::new(th * 0.5, th * 0.5)), - height: th, - width: 0.0, - attach: 4, - rotation: 0.0, - style: "Standard".into(), - contents: t.into(), - line_spacing: 1.0, - line_spacing_exact: false, - columns: None, - }))? - .hex(), - ); - } - Ok(json!({ "handles": out })) -} - -fn run_update(s: &mut Session, p: &Value) -> Result { - let hs = targets(s, p)?; - let st = style(s); - let d = s.doc_mut()?; - let mut n = 0; - for h in hs { - let _ = d.modify_entity(h, |e| { - if let EntityKind::Dimension(dm) = &mut e.kind { - dm.style = st.clone(); - dm.block = None; - n += 1; - } - }); - } - Ok(json!({ "updated": n })) -} - -/// DIMOVERRIDE: per-dimension style overrides (`clear` removes them); `text` sets the text. -fn run_override(s: &mut Session, p: &Value) -> Result { - if p.get("text").is_none() { - return super::props::dim_override(s, p); - } - let hs = targets(s, p)?; - let t = str_param(p, "text").unwrap_or("").to_string(); - // Dimension variables given next to `text` are applied first, so an unknown name still errors before anything changes. - let vars: serde_json::Map = p - .as_object() - .map(|o| o.iter().filter(|(k, _)| !matches!(k.as_str(), "text" | "handles" | "handle")).map(|(k, v)| (k.clone(), v.clone())).collect()) - .unwrap_or_default(); - let mut ignored = Value::Null; - if !vars.is_empty() { - let mut rest = vars; - rest.insert("handles".into(), json!(hs.iter().map(|h| h.hex()).collect::>())); - ignored = super::props::dim_override(s, &Value::Object(rest))?.get("ignored").cloned().unwrap_or(Value::Null); - } - let d = s.doc_mut()?; - for h in &hs { - let _ = d.modify_entity(*h, |e| { - if let EntityKind::Dimension(dm) = &mut e.kind { - dm.text = t.clone(); - dm.block = None; - } - }); - } - match ignored { - Value::Null => Ok(json!({ "changed": hs.len() })), - ig => Ok(json!({ "changed": hs.len(), "ignored": ig })), - } -} - -/// The two points a picked object is dimensioned between (DIMLINEAR "select object"). -fn object_extent(s: &Session, h: Handle) -> Option<(Vec2, Vec2)> { - let e = s.doc().ok()?.entity(h)?; - if let Some((c, r)) = circle_of(s, h).filter(|_| matches!(e.kind, EntityKind::Circle(_))) { - return Some((c - Vec2::X * r, c + Vec2::X * r)); - } - e.kind.prims().into_iter().find_map(|pr| match pr { - Prim::Seg(sg @ (Segment::Line(_) | Segment::Arc { .. })) => Some((sg.start(), sg.end())), - _ => None, - }) -} - -fn dims_of(s: &Session, hs: &[Handle]) -> Vec { - let Ok(d) = s.doc() else { return Vec::new() }; - hs.iter().copied().filter(|h| d.entity(*h).is_some_and(|e| matches!(e.kind, EntityKind::Dimension(_)))).collect() -} - -fn run_reassociate(s: &mut Session, p: &Value) -> Result { - let hs = dims_of(s, &targets(s, p)?); - let space = s.space(); - let mut plan = Vec::new(); - { - let d = s.doc()?; - for h in &hs { - if let Some(EntityKind::Dimension(dm)) = d.entity(*h).map(|e| &e.kind) { - plan.push((*h, crate::assoc::reassociate(d, &space, dm))); - } - } - } - let mut points = 0; - let mut n = 0; - let d = s.doc_mut()?; - for (h, a) in plan { - points += a.len(); - n += usize::from(!a.is_empty()); - let _ = d.modify_entity(h, |e| { - if let EntityKind::Dimension(dm) = &mut e.kind { - dm.assoc = a; - } - }); - } - Ok(json!({ "dimensions": hs.len(), "associated": n, "points": points, "message": format!("{n} of {} dimension(s) associated.", hs.len()) })) -} - -fn run_disassociate(s: &mut Session, p: &Value) -> Result { - let hs = dims_of(s, &targets(s, p)?); - let d = s.doc_mut()?; - let mut n = 0; - for h in &hs { - let _ = d.modify_entity(*h, |e| { - if let EntityKind::Dimension(dm) = &mut e.kind - && !dm.assoc.is_empty() - { - dm.assoc.clear(); - n += 1; - } - }); - } - Ok(json!({ "disassociated": n, "message": format!("{n} disassociated.") })) -} - -/// DIMTEDIT: move/rotate/justify dimension text. -fn run_tedit(s: &mut Session, p: &Value, mode: &str) -> Result { - let mut q = p.as_object().cloned().unwrap_or_default(); - q.insert("mode".into(), json!(mode)); - run_dimtedit(s, &Value::Object(q)) -} - -fn run_dimtedit(s: &mut Session, p: &Value) -> Result { - let hs = dims_of(s, &targets(s, p)?); - if hs.is_empty() { - return Err(bad("dimtedit", "select dimensions")); - } - let mode = str_param(p, "mode").unwrap_or("").to_ascii_lowercase(); - let at = point_param(p, "at"); - let angle = p.get("angle").and_then(Value::as_f64).filter(|a| a.is_finite()).map(f64::to_radians); - if mode == "angle" && angle.is_none() { - return Err(bad("dimtedit", "`angle` (degrees) is required")); - } - let d = s.doc_mut()?; - let dimscale = d.header.f64("DIMSCALE", 1.0); - for h in &hs { - let st = match d.entity(*h).map(|e| &e.kind) { - Some(EntityKind::Dimension(dm)) => d.dim_style(&dm.style).cloned().unwrap_or_default().with_overrides(&dm.overrides), - _ => continue, - }; - d.modify_entity(*h, |e| { - let EntityKind::Dimension(dm) = &mut e.kind else { return }; - dm.block = None; - if let Some(q) = at { - dm.text_mid = v3(q); - dm.user_text_pos = true; - // DIMTMOVE 0: the dimension line moves with the text. - st.follow_text(dm, dimscale); - } - match mode.as_str() { - "home" => { - dm.user_text_pos = false; - dm.text_rotation = 0.0; - dm.overrides.remove("textJust"); - } - "angle" => dm.text_rotation = angle.unwrap_or(0.0), - "left" | "center" | "right" => { - dm.user_text_pos = false; - let j = match mode.as_str() { - "left" => 1, - "right" => 2, - _ => 0, - }; - dm.overrides.insert("textJust".into(), json!(j)); - } - _ => {} - } - })?; - } - Ok(json!({ "changed": hs.len() })) -} - -/// DIMSPACE: equal spacing of parallel linear dimensions from a base dimension. -fn run_dimspace(s: &mut Session, p: &Value) -> Result { - let base = hex_param(p, "base").ok_or_else(|| bad("dimspace", "`base` dimension is required"))?; - let hs: Vec = dims_of(s, &targets(s, p)?).into_iter().filter(|h| *h != base).collect(); - let d = s.doc()?; - let Some(EntityKind::Dimension(b)) = d.entity(base).map(|e| e.kind.clone()) else { return Err(bad("dimspace", "`base` is not a dimension")) }; - let st = d.dim_style(&b.style).cloned().unwrap_or_default().with_overrides(&b.overrides); - let spacing = p - .get("spacing") - .and_then(Value::as_f64) - .filter(|v| v.is_finite() && *v >= 0.0) - .unwrap_or(st.baseline_spacing * st.effective_scale(d.header.f64("DIMSCALE", 1.0)) * 2.0); - let dir = match b.kind { - DimKind::Linear { rotation } => Vec2::from_angle(rotation), - DimKind::Aligned => (b.p14.xy() - b.p13.xy()).normalized(), - _ => return Err(bad("dimspace", "base must be a linear or aligned dimension")), - }; - let n = dir.perp(); - let base_off = (b.defpt.xy() - b.p13.xy()).dot(n); - let side = if base_off >= 0.0 { 1.0 } else { -1.0 }; - // Order the others by their current distance from the base line. - let mut others: Vec<(Handle, f64)> = hs - .iter() - .filter_map(|h| match d.entity(*h).map(|e| &e.kind) { - Some(EntityKind::Dimension(o)) if matches!(o.kind, DimKind::Linear { .. } | DimKind::Aligned) => { - Some((*h, (o.defpt.xy() - b.defpt.xy()).dot(n) * side)) - } - _ => None, - }) - .collect(); - others.sort_by(|a, c| a.1.total_cmp(&c.1)); - let line_pt = b.defpt.xy(); - let dm = s.doc_mut()?; - for (i, (h, _)) in others.iter().enumerate() { - let target = line_pt + n * (side * spacing * (i as f64 + 1.0)); - dm.modify_entity(*h, |e| { - if let EntityKind::Dimension(o) = &mut e.kind { - let cur = o.defpt.xy(); - let moved = cur + n * (target - cur).dot(n); - o.defpt = v3(moved); - o.user_text_pos = false; - o.block = None; - } - })?; - } - Ok(json!({ "spaced": others.len() })) -} - -/// DIMSPACE at the command line: select the base dimension, select the dimensions to space, then -/// the spacing (Enter or Auto takes the DIMDLI-based default). -#[derive(Default)] -struct DimSpaceM { - base: Option, - sel: SelectPhase, - spacing_phase: bool, -} - -impl DimSpaceM { - fn apply(&self, s: &mut Session, base: Handle, spacing: Option) -> Result { - let mut p = serde_json::Map::new(); - p.insert("base".into(), json!(base.hex())); - p.insert("handles".into(), json!(self.sel.picked.iter().map(|h| h.hex()).collect::>())); - if let Some(v) = spacing { - p.insert("spacing".into(), json!(v)); - } - let r = run_dimspace(s, &Value::Object(p))?; - s.set_selection(Vec::new()); - s.echo(format!("{} dimension(s) spaced.", r.get("spaced").and_then(Value::as_u64).unwrap_or(0))); - Ok(Step::Done) - } -} - -impl Interactive for DimSpaceM { - fn name(&self) -> &'static str { - "DIMSPACE" - } - fn begin(&mut self, _s: &mut Session) -> Result { - self.sel = SelectPhase::single(); - Ok(Step::Continue) - } - fn prompt(&self, _s: &Session) -> Prompt { - if self.spacing_phase { - return Prompt::new("Enter value", Accept::NUMBER).kw(&["Auto"]).default("Auto"); - } - if self.base.is_none() { - return Prompt::new("Select base dimension", Accept::SELECT); - } - Prompt::new("Select dimensions to space", Accept::SELECT) - } - fn input(&mut self, s: &mut Session, i: Input) -> Result { - if self.spacing_phase { - let Some(base) = self.base else { return Ok(Step::Cancel) }; - return match i { - Input::Enter => self.apply(s, base, None), - Input::Keyword(k) | Input::Text(k) if k.trim().eq_ignore_ascii_case("a") || k.trim().eq_ignore_ascii_case("auto") => { - self.apply(s, base, None) - } - Input::Text(t) => match super::machines::number(&t).filter(|v| v.is_finite() && *v >= 0.0) { - Some(v) => self.apply(s, base, Some(v)), - None => { - s.echo("Requires a non-negative number or Auto."); - Ok(Step::Continue) - } - }, - _ => Ok(Step::Continue), - }; - } - match self.sel.feed(s, &i)? { - SelOutcome::More => Ok(Step::Continue), - SelOutcome::Empty => Ok(Step::Done), - SelOutcome::Done(hs) => { - if self.base.is_none() { - match hs.first().copied().filter(|h| !dims_of(s, &[*h]).is_empty()) { - Some(b) => { - self.base = Some(b); - self.sel = SelectPhase::default(); - } - None => { - s.echo("Select a dimension."); - self.sel = SelectPhase::single(); - } - } - } else { - self.sel.picked = hs; - self.spacing_phase = true; - } - Ok(Step::Continue) - } - } - } -} - -/// DIMOVERRIDE at the command line: variable, value (repeat), Enter, then select dimensions. -struct OverrideM { - vars: serde_json::Map, - var: Option, - clear: bool, - selecting: Option, -} - -impl OverrideM { - fn new(_s: &Session) -> Self { - OverrideM { vars: serde_json::Map::new(), var: None, clear: false, selecting: None } - } - fn apply(&self, s: &mut Session, hs: Vec) -> Result { - let mut p = self.vars.clone(); - p.insert("handles".into(), json!(hs.iter().map(|h| h.hex()).collect::>())); - if self.clear { - p.insert("clear".into(), json!(true)); - } - let r = super::props::dim_override(s, &Value::Object(p))?; - s.echo(format!("{} dimension(s) changed.", r.get("changed").and_then(Value::as_u64).unwrap_or(0))); - Ok(Step::Done) - } -} - -impl Interactive for OverrideM { - fn name(&self) -> &'static str { - "DIMOVERRIDE" - } - fn prompt(&self, _s: &Session) -> Prompt { - if let Some(sel) = &self.selecting { - return sel.prompt(); - } - match &self.var { - Some(v) => Prompt::new(format!("Enter new value for dimension variable {}", v.to_ascii_uppercase()), Accept::TEXT), - None => Prompt::new("Enter dimension variable name to override", Accept::TEXT).kw(&["Clear overrides"]), - } - } - fn input(&mut self, s: &mut Session, i: Input) -> Result { - if let Some(sel) = &mut self.selecting { - return match sel.feed(s, &i)? { - super::machines::SelOutcome::More => Ok(Step::Continue), - super::machines::SelOutcome::Empty => Ok(Step::Done), - super::machines::SelOutcome::Done(hs) => self.apply(s, hs), - }; - } - match (self.var.take(), i) { - (None, Input::Keyword(k)) if k.starts_with("Clear") => { - self.clear = true; - self.selecting = Some(super::machines::SelectPhase::default()); - } - (None, Input::Text(t)) => { - let t = t.trim().to_string(); - if t.eq_ignore_ascii_case("c") { - self.clear = true; - self.selecting = Some(super::machines::SelectPhase::default()); - } else if cadcraft_doc::DimStyle::field_name(&t).is_some() { - self.var = Some(t); - } else { - s.echo(format!("Unknown dimension variable \"{t}\".")); - } - } - (None, Input::Enter) => { - if self.vars.is_empty() { - return Ok(Step::Cancel); - } - self.selecting = Some(super::machines::SelectPhase::default()); - } - (Some(v), Input::Text(t)) => { - let val = t.trim().parse::().map(|x| json!(x)).unwrap_or_else(|_| json!(t.trim())); - self.vars.insert(v, val); - } - (Some(v), _) => self.var = Some(v), - _ => {} - } - Ok(Step::Continue) - } - fn preview(&self, _s: &Session, _c: Vec2) -> Vec { - Vec::new() - } -} - -// ---------------- interactive ---------------- - -fn pick_at(s: &Session, p: Vec2) -> Option { - let ap = s.pixel_size() * s.settings.pickbox.max(1.0) * 1.5; - crate::select::pick(s.doc().ok()?, &s.space(), p, ap) -} - -/// The straight or curved piece of `h` picked at `p`: a line, an arc, or the polyline segment nearest `p`. -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 }), - EntityKind::LwPolyline(pl) => cadcraft_geom::Polyline { vertices: pl.vertices.clone(), closed: pl.closed } - .segments() - .into_iter() - .min_by(|x, y| x.closest(p).dist(p).total_cmp(&y.closest(p).dist(p))), - _ => None, - } -} - -/// What a dimension placement prompt asks after Mtext, Text or Angle. -#[derive(Clone, Copy, PartialEq)] -enum DimAsk { - Text, - Angle, -} - -/// The Mtext / Text / Angle options of the dimension placement prompts: replace the measured text -/// (`<>` stands for the measurement) and rotate the text. -#[derive(Default)] -struct DimText { - text: String, - angle: f64, - asking: Option, -} - -impl DimText { - /// Opens the sub-prompt for Mtext, Text or Angle; `false` for any other keyword. - fn keyword(&mut self, k: &str) -> bool { - self.asking = match k { - "Mtext" | "Text" => Some(DimAsk::Text), - "Angle" => Some(DimAsk::Angle), - _ => return false, - }; - true - } - fn prompt(&self) -> Option { - Some(match self.asking? { - DimAsk::Text => Prompt::new("Enter dimension text (<> = measurement)", Accept::TEXT), - DimAsk::Angle => Prompt::new("Specify angle of dimension text", Accept::NUMBER), - }) - } - /// Takes the answer to the open sub-prompt (Enter keeps the current value); `false` when none is open. - fn answer(&mut self, s: &mut Session, i: &Input) -> bool { - let Some(a) = self.asking else { return false }; - match (a, i) { - (DimAsk::Text, Input::Text(t)) => self.text = t.clone(), - (DimAsk::Angle, Input::Text(t)) => match s.angle_settings().direction(t) { - Some(r) => self.angle = r, - None => { - s.echo("Requires a valid angle."); - return true; - } - }, - _ => {} - } - self.asking = None; - true - } - /// `k` with this text and text angle. - fn apply(&self, mut k: EntityKind) -> EntityKind { - if let EntityKind::Dimension(d) = &mut k { - d.text = self.text.clone(); - d.text_rotation = self.angle; - } - k - } -} - -struct LinearM { - aligned: bool, - pts: Vec, - rotation: Option, - text: DimText, - selecting: bool, - /// Asking for the dimension line angle (Rotated). - rotating: bool, -} - -impl LinearM { - fn new(aligned: bool) -> Self { - LinearM { aligned, pts: Vec::new(), rotation: None, text: DimText::default(), selecting: false, rotating: false } - } -} - -impl Interactive for LinearM { - fn name(&self) -> &'static str { - if self.aligned { "DIMALIGNED" } else { "DIMLINEAR" } - } - fn prompt(&self, _s: &Session) -> Prompt { - if self.rotating { - return Prompt::new("Specify angle of dimension line", Accept::NUMBER); - } - if let Some(p) = self.text.prompt() { - return p; - } - if self.selecting { - return Prompt::new("Select object to dimension", Accept::POINT); - } - match self.pts.len() { - 0 => Prompt::new("Specify first extension line origin", Accept::POINT).default("select object"), - 1 => Prompt::new("Specify second extension line origin", Accept::POINT).base_opt(self.pts.first().copied()), - // Once the orientation is forced, only the text options remain. - _ if self.aligned || self.rotation.is_some() => { - Prompt::new("Specify dimension line location", Accept::POINT).kw(&["Mtext", "Text", "Angle"]) - } - _ => Prompt::new("Specify dimension line location", Accept::POINT).kw(&["Mtext", "Text", "Angle", "Horizontal", "Vertical", "Rotated"]), - } - } - fn input(&mut self, s: &mut Session, i: Input) -> Result { - if self.rotating { - if let Input::Text(t) = &i { - let Some(r) = s.angle_settings().direction(t) else { - s.echo("Requires a valid angle."); - return Ok(Step::Continue); - }; - self.rotation = Some(r); - } - self.rotating = false; - return Ok(Step::Continue); - } - if self.text.answer(s, &i) { - return Ok(Step::Continue); - } - if self.selecting { - if let Input::Point(p) = i { - let Some(h) = pick_at(s, p) else { - s.echo("*Invalid selection*"); - return Ok(Step::Continue); - }; - let seg = - s.doc()?.entity(h).and_then(|e| e.kind.prims().into_iter().find_map(|pr| if let Prim::Seg(sg) = pr { Some(sg) } else { None })); - match seg { - Some(sg @ Segment::Line(_)) | Some(sg @ Segment::Arc { .. }) => { - self.pts = vec![sg.start(), sg.end()]; - self.selecting = false; - } - None => { - if let Some((c, r)) = circle_of(s, h) { - self.pts = vec![c - Vec2::X * r, c + Vec2::X * r]; - self.selecting = false; - } else { - s.echo("Object cannot be dimensioned this way."); - } - } - } - } - return Ok(Step::Continue); - } - match (self.pts.len(), i) { - (0, Input::Enter) => { - self.selecting = true; - Ok(Step::Continue) - } - (n, Input::Point(p)) if n < 2 => { - self.pts.push(p); - Ok(Step::Continue) - } - (_, 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, "")); - 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(); - let g = cadcraft_render::dimension_geometry(&d, &st, 1.0); - s.echo(format!("Dimension text = {}", g.value)); - } - Ok(Step::Done) - } - (_, Input::Keyword(k)) => { - match k.as_str() { - "Horizontal" => self.rotation = Some(0.0), - "Vertical" => self.rotation = Some(std::f64::consts::FRAC_PI_2), - "Rotated" => self.rotating = true, - other => { - self.text.keyword(other); - } - } - Ok(Step::Continue) - } - (_, Input::Enter) => Ok(Step::Cancel), - _ => Ok(Step::Continue), - } - } - 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, ""))], - _ => Vec::new(), - } - } -} - -struct RadialM { - diameter: bool, - target: Option<(Vec2, f64)>, - text: DimText, -} - -impl RadialM { - fn new(diameter: bool) -> Self { - RadialM { diameter, target: None, text: DimText::default() } - } - 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, "") - } else { - dim(s, DimKind::Radius, c, Vec2::ZERO, Vec2::ZERO, pt, "") - }); - text_at(k, at) - } -} - -impl Interactive for RadialM { - fn name(&self) -> &'static str { - if self.diameter { "DIMDIAMETER" } else { "DIMRADIUS" } - } - fn prompt(&self, _s: &Session) -> Prompt { - if let Some(p) = self.text.prompt() { - return p; - } - match self.target { - None => Prompt::new("Select arc or circle", Accept::POINT), - Some((c, _)) => Prompt::new("Specify dimension line location", Accept::POINT).kw(&["Mtext", "Text", "Angle"]).base(c), - } - } - fn input(&mut self, s: &mut Session, i: Input) -> Result { - if self.text.answer(s, &i) { - return Ok(Step::Continue); - } - match (self.target, i) { - (None, Input::Point(p)) => { - match pick_at(s, p).and_then(|h| circle_of(s, h)) { - Some(t) => self.target = Some(t), - None => s.echo("Object selected is not a circle or arc."), - } - Ok(Step::Continue) - } - (Some((c, r)), Input::Point(at)) => { - let k = self.kind(s, c, r, at); - add_dim(s, k)?; - Ok(Step::Done) - } - (Some(_), Input::Keyword(k)) => { - self.text.keyword(&k); - Ok(Step::Continue) - } - (_, Input::Enter) => Ok(Step::Cancel), - _ => Ok(Step::Continue), - } - } - fn preview(&self, s: &Session, c: Vec2) -> Vec { - self.target.map(|(ctr, r)| vec![self.kind(s, ctr, r, c)]).unwrap_or_default() - } -} - -#[derive(Default)] -struct AngularM { - handles: Vec, - first: Option<(Vec2, Vec2)>, - second: Option<(Vec2, Vec2)>, - vertex_mode: Vec, - vertex: bool, - arc: Option<(Vec2, Vec2, Vec2)>, - text: DimText, - /// Quadrant: a point inside the angle to measure; the arc location then only sets the radius. - quadrant: Option, - asking_quadrant: bool, -} - -impl AngularM { - /// (vertex, p1, p2) of the angle being dimensioned. - fn geometry(&self) -> Option<(Vec2, Vec2, Vec2)> { - if let Some(a) = self.arc { - return Some(a); - } - if let [v, a, b] = self.vertex_mode.as_slice() { - return Some((*v, *a, *b)); - } - let ((a1, a2), (b1, b2)) = (self.first?, self.second?); - let (v, _, _) = cadcraft_geom::line_line_infinite(a1, a2, b1, b2)?; - let far = |p: Vec2, q: Vec2| if p.dist(v) > q.dist(v) { p } else { q }; - Some((v, far(a1, a2), far(b1, b2))) - } - /// The arc location `at`, turned into the locked quadrant (same radius) when one is set. - fn located(&self, v: Vec2, at: Vec2) -> Vec2 { - match self.quadrant { - Some(q) if q.dist(v) > 1e-12 && at.dist(v) > 1e-12 => v + (q - v).normalized() * at.dist(v), - _ => at, - } - } -} - -impl Interactive for AngularM { - fn name(&self) -> &'static str { - "DIMANGULAR" - } - fn prompt(&self, _s: &Session) -> Prompt { - if let Some(p) = self.text.prompt() { - return p; - } - if self.asking_quadrant { - return Prompt::new("Specify quadrant", Accept::POINT); - } - if self.geometry().is_some() { - return Prompt::new("Specify dimension arc line location", Accept::POINT).kw(&["Mtext", "Text", "Angle", "Quadrant"]); - } - if self.vertex { - return Prompt::new( - ["Specify angle vertex", "Specify first angle endpoint", "Specify second angle endpoint"] - .get(self.vertex_mode.len()) - .copied() - .unwrap_or(""), - Accept::POINT, - ); - } - 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 self.text.answer(s, &i) { - return Ok(Step::Continue); - } - if self.asking_quadrant { - if let Input::Point(q) = i { - self.quadrant = Some(q); - } - self.asking_quadrant = false; - return Ok(Step::Continue); - } - if let Some((v, a, b)) = self.geometry() { - return match i { - Input::Point(at) => { - let at = self.located(v, at); - let k = match (self.handles.as_slice(), self.arc) { - ([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, "")); - add_dim(s, self.text.apply(k))?; - Ok(Step::Done) - } - Input::Keyword(k) => { - if k == "Quadrant" { - self.asking_quadrant = true; - } else { - self.text.keyword(&k); - } - Ok(Step::Continue) - } - Input::Enter => Ok(Step::Cancel), - _ => Ok(Step::Continue), - }; - } - if self.vertex { - match i { - Input::Point(p) => self.vertex_mode.push(p), - Input::Enter => return Ok(Step::Cancel), - _ => {} - } - return Ok(Step::Continue); - } - match i { - Input::Enter if self.first.is_none() => { - self.vertex = true; - Ok(Step::Continue) - } - Input::Point(p) => { - let Some(h) = 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(g) = circle.filter(|_| self.first.is_none()) { - // The center is the vertex and the pick the first endpoint; ask for the second. - self.vertex = true; - self.vertex_mode = vec![g.center, g.closest(p)]; - return Ok(Step::Continue); - } - match segment_at(s, h, p) { - Some(Segment::Arc { arc: g, .. }) if self.first.is_none() => { - self.arc = Some((g.center, g.start_point(), g.end_point())); - } - Some(Segment::Line(l)) => { - self.handles.push(h); - let seg = (l.a, l.b); - if self.first.is_none() { - self.first = Some(seg); - } else { - self.second = Some(seg); - } - } - _ => s.echo("Select an arc, circle or line."), - } - Ok(Step::Continue) - } - Input::Enter => Ok(Step::Cancel), - _ => Ok(Step::Continue), - } - } - 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, ""))]).unwrap_or_default() - } -} - -#[derive(Default)] -struct ArcLenM { - arc: Option, - text: DimText, - /// Partial: the points picked so far on the arc (`None` when not choosing a part). - partial: Option>, -} - -/// The part of `arc` between the points nearest `p` and `q` (in the arc's direction). -fn arc_part(arc: &cadcraft_geom::Arc, p: Vec2, q: Vec2) -> Option { - let sweep = arc.sweep(); - // Offset along the arc from its start, clamped to the nearer end when the point is off the arc. - let along = |x: Vec2| { - let t = cadcraft_geom::ccw_sweep(arc.start, arc.center.angle_to(x)); - if t <= sweep { - t - } else if t - sweep < std::f64::consts::TAU - t { - sweep - } else { - 0.0 - } - }; - let (t1, t2) = (along(p), along(q)); - let (lo, hi) = (t1.min(t2), t1.max(t2)); - (hi - lo > 1e-9).then(|| cadcraft_geom::Arc::new(arc.center, arc.radius, arc.start + lo, arc.start + hi)) -} - -impl Interactive for ArcLenM { - fn name(&self) -> &'static str { - "DIMARC" - } - fn prompt(&self, _s: &Session) -> Prompt { - if let Some(p) = self.text.prompt() { - return p; - } - if let Some(picked) = &self.partial { - return match picked.first() { - None => Prompt::new("Specify first point for arc length dimension", Accept::POINT), - Some(&f) => Prompt::new("Specify second point for arc length dimension", Accept::POINT).base(f), - }; - } - match self.arc { - None => Prompt::new("Select arc or polyline arc segment", Accept::POINT), - Some(_) => Prompt::new("Specify arc length dimension location", Accept::POINT).kw(&["Mtext", "Text", "Angle", "Partial"]), - } - } - fn input(&mut self, s: &mut Session, i: Input) -> Result { - if self.text.answer(s, &i) { - return Ok(Step::Continue); - } - if let Some(picked) = self.partial.take() { - match (i, picked.first().copied(), self.arc) { - (Input::Point(p), None, _) => self.partial = Some(vec![p]), - (Input::Point(q), Some(p), Some(g)) => match arc_part(&g, p, q) { - Some(part) => self.arc = Some(part), - None => s.echo("The two points must be different."), - }, - (Input::Enter, ..) => {} - _ => self.partial = Some(picked), - } - return Ok(Step::Continue); - } - match (self.arc, i) { - (None, Input::Point(p)) => { - if let Some(EntityKind::Arc(a)) = pick_at(s, p).and_then(|h| s.doc().ok()?.entity(h).map(|e| e.kind.clone())) { - self.arc = Some(cadcraft_geom::Arc::new(a.center.xy(), a.radius, a.start, a.end)); - } else { - s.echo("Select an arc."); - } - 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, "")))?; - Ok(Step::Done) - } - (Some(_), Input::Keyword(k)) => { - if k == "Partial" { - self.partial = Some(Vec::new()); - } else { - self.text.keyword(&k); - } - Ok(Step::Continue) - } - (_, Input::Enter) => Ok(Step::Cancel), - _ => Ok(Step::Continue), - } - } - fn preview(&self, s: &Session, c: Vec2) -> Vec { - 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, ""))]).unwrap_or_default() - } -} - -#[derive(Default)] -struct OrdinateM { - feature: Option, - /// Xdatum / Ydatum: the forced ordinate type (`true` = X datum). - xtype: Option, - text: DimText, -} - -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, "")) - } -} - -impl Interactive for OrdinateM { - fn name(&self) -> &'static str { - "DIMORDINATE" - } - fn prompt(&self, _s: &Session) -> Prompt { - if let Some(p) = self.text.prompt() { - return p; - } - match (self.feature, self.xtype) { - (None, _) => Prompt::new("Specify feature location", Accept::POINT), - (Some(f), None) => Prompt::new("Specify leader endpoint", Accept::POINT).kw(&["Xdatum", "Ydatum", "Mtext", "Text", "Angle"]).base(f), - (Some(f), Some(_)) => Prompt::new("Specify leader endpoint", Accept::POINT).kw(&["Mtext", "Text", "Angle"]).base(f), - } - } - fn input(&mut self, s: &mut Session, i: Input) -> Result { - if self.text.answer(s, &i) { - return Ok(Step::Continue); - } - match (self.feature, i) { - (None, Input::Point(p)) => { - self.feature = Some(p); - Ok(Step::Continue) - } - (Some(_), Input::Keyword(k)) => { - match k.as_str() { - "Xdatum" => self.xtype = Some(true), - "Ydatum" => self.xtype = Some(false), - other => { - self.text.keyword(other); - } - } - Ok(Step::Continue) - } - (Some(f), Input::Point(l)) => { - add_dim(s, self.kind(s, f, l))?; - Ok(Step::Done) - } - (_, Input::Enter) => Ok(Step::Cancel), - _ => Ok(Step::Continue), - } - } - fn preview(&self, s: &Session, c: Vec2) -> Vec { - self.feature.map(|f| vec![self.kind(s, f, c)]).unwrap_or_default() - } -} - -struct ChainM { - baseline: bool, - created: Vec, -} - -impl ChainM { - fn new(_s: &Session, baseline: bool) -> Self { - ChainM { baseline, created: Vec::new() } - } -} - -impl Interactive for ChainM { - fn name(&self) -> &'static str { - if self.baseline { "DIMBASELINE" } else { "DIMCONTINUE" } - } - fn begin(&mut self, s: &mut Session) -> Result { - if last_linear(s).is_none() { - s.echo("No linear dimension to continue. Create one with DIMLINEAR first."); - return Ok(Step::Done); - } - Ok(Step::Continue) - } - fn prompt(&self, s: &Session) -> Prompt { - Prompt::new("Specify a second extension line origin", Accept::POINT) - .kw(&["Select", "Undo"]) - .default("Select") - .base_opt(last_linear(s).map(|d| if self.baseline { d.p13.xy() } else { d.p14.xy() })) - } - fn input(&mut self, s: &mut Session, i: Input) -> Result { - match i { - Input::Point(p) => { - let prev = last_linear(s).ok_or_else(|| EngineError::Other("no linear dimension".into()))?; - let k = chained(s, &prev, p, self.baseline); - self.created.push(add_dim(s, k)?); - Ok(Step::Continue) - } - Input::Keyword(k) if k == "Undo" => { - if let Some(h) = self.created.pop() { - s.doc_mut()?.remove_entity(h); - s.last_dim = self.created.last().copied(); - } - Ok(Step::Continue) - } - Input::Enter => Ok(Step::Done), - _ => Ok(Step::Continue), - } - } - fn preview(&self, s: &Session, c: Vec2) -> Vec { - last_linear(s).map(|d| vec![chained(s, &d, c, self.baseline)]).unwrap_or_default() - } -} - -/// The MLEADER Options prompt being answered. -#[derive(Clone, Copy, PartialEq)] -enum LeaderOpt { - Menu, - LeaderType, - Landing, - ContentType, - MaxPoints, - FirstAngle, - SecondAngle, - BlockName, - BlockAttach, -} - -/// Upper bound for MLEADER Maxpoints. -const MAX_LEADER_POINTS: usize = 64; - -struct MLeaderM { - order: LeaderOrder, - /// Points in the order they were placed. - pts: Vec, - /// All leader points are placed. - leader_done: bool, - /// The leader text once entered (Content first enters it before the leader). - text: Option, - opt: Option, - max_points: usize, - /// First and second angle constraints (radians; 0 = free). - angles: [f64; 2], - landing: bool, - content: bool, - leader_type: crate::LeaderType, - /// Block content: the block (`block_name`) instead of text. - block: bool, - block_name: String, - block_center: bool, -} - -impl MLeaderM { - /// Starts with the order and Options chosen the last time (as AutoCAD does). - fn new(s: &Session) -> Self { - let o = s.last_used.mleader; - let max_points = o.max_points.clamp(2, MAX_LEADER_POINTS); - MLeaderM { - order: o.order, - pts: Vec::new(), - leader_done: false, - text: None, - opt: None, - max_points, - angles: o.angles, - landing: o.landing, - content: o.content, - leader_type: o.leader_type, - // Block content needs a block that still exists. - block: o.block && s.doc().is_ok_and(|d| d.block(&s.last_used.mleader_block).is_some()), - block_name: s.last_used.mleader_block.clone(), - block_center: o.block_center, - } - } - /// Keep the order and Options for the next MLEADER. - fn remember(&self, s: &mut Session) { - s.last_used.mleader = MLeaderOptions { - order: self.order, - max_points: self.max_points, - angles: self.angles, - landing: self.landing, - content: self.content, - leader_type: self.leader_type, - block: self.block, - block_center: self.block_center, - }; - s.last_used.mleader_block = self.block_name.clone(); - } - /// The leader type and block content for `mleader_content::apply`. - fn content_spec(&self) -> super::mleader_content::Content { - super::mleader_content::Content { - leader: self.leader_type, - block: (self.content && self.block).then(|| (self.block_name.clone(), self.block_center)), - } - } - /// Leader type None: the one point placed is the content location. - fn no_leader(&self) -> bool { - self.leader_type == crate::LeaderType::None - } - /// The leader from arrowhead to landing, with `extra` placed next. - fn leader(&self, extra: Option) -> Vec { - let mut pts = self.pts.clone(); - pts.extend(extra); - if self.order != LeaderOrder::Arrowhead { - pts.reverse(); - } - pts - } - /// `p` with the first/second angle constraint applied to the segment it ends (counted from the - /// first point placed). - fn constrained(&self, p: Vec2) -> Vec2 { - let (Some(&prev), Some(&inc)) = (self.pts.last(), self.angles.get(self.pts.len().wrapping_sub(1))) else { return p }; - let len = prev.dist(p); - if inc <= 1e-9 || !inc.is_finite() || len <= 1e-12 { - return p; - } - Vec2::polar(prev, len, ((p - prev).angle() / inc).round() * inc) - } - /// The text is asked now: right after the content location (Content first) or after the leader. - fn wants_text(&self) -> bool { - self.content - && !self.block - && self.text.is_none() - && (self.leader_done || (self.order == LeaderOrder::Content && self.pts.len() == 1 && !self.no_leader())) - } - fn finish(&self, s: &mut Session) -> Result { - if self.no_leader() && !self.content { - s.echo("A multileader needs a leader line or content; nothing was drawn."); - return Ok(Step::Cancel); - } - let text = self.text.clone().unwrap_or_default(); - let k = mleader_kind(s, &self.leader(None), &text, self.landing).ok_or_else(|| EngineError::Other("bad leader".into()))?; - let k = super::mleader_content::apply(s, k, &self.content_spec())?; - s.add_entity(k)?; - Ok(Step::Done) - } - fn option_prompt(o: LeaderOpt) -> Prompt { - let kw = super::curves::KW; - match o { - LeaderOpt::Menu => Prompt::new("Enter an option", kw) - .kw(&["Leader type", "leader lAnding", "Content type", "Maxpoints", "First angle", "Second angle", "eXit options"]) - .default("eXit options"), - LeaderOpt::LeaderType => Prompt::new("Select a leader type", kw).kw(&["Straight", "sPline", "None"]).default("Straight"), - LeaderOpt::Landing => Prompt::new("Use landing", kw).kw(&["Yes", "No"]).default("Yes"), - LeaderOpt::ContentType => Prompt::new("Select a content type", kw).kw(&["Block", "Mtext", "None"]).default("Mtext"), - LeaderOpt::MaxPoints => Prompt::new("Enter the maximum points for leader line", Accept::NUMBER), - LeaderOpt::FirstAngle => Prompt::new("Enter first angle constraint", Accept::NUMBER), - LeaderOpt::SecondAngle => Prompt::new("Enter second angle constraint", Accept::NUMBER), - LeaderOpt::BlockName => Prompt::new("Enter block name", kw), - LeaderOpt::BlockAttach => { - Prompt::new("Specify block attachment", kw).kw(&["Center extents", "Insertion point"]).default("Center extents") - } - } - } - /// One answer at the Options prompts; Enter keeps the current value. - fn option(&mut self, s: &mut Session, o: LeaderOpt, i: Input) -> Option { - use LeaderOpt::*; - match (o, i) { - (Menu, Input::Keyword(k)) => match k.as_str() { - "Leader type" => Some(LeaderType), - "leader lAnding" => Some(Landing), - "Content type" => Some(ContentType), - "Maxpoints" => Some(MaxPoints), - "First angle" => Some(FirstAngle), - "Second angle" => Some(SecondAngle), - _ => None, - }, - (Menu, Input::Enter) => None, - (LeaderType, Input::Keyword(k)) => { - self.leader_type = match k.as_str() { - "sPline" => crate::LeaderType::Spline, - "None" => crate::LeaderType::None, - _ => crate::LeaderType::Straight, - }; - Some(Menu) - } - (Landing, Input::Keyword(k)) => { - self.landing = k == "Yes"; - Some(Menu) - } - (ContentType, Input::Keyword(k)) => { - self.content = k != "None"; - self.block = false; - // Block content asks which block, then how it attaches. - if k == "Block" { Some(BlockName) } else { Some(Menu) } - } - (BlockName, Input::Text(t)) => match super::mleader_content::block_name(s, &t) { - Ok(n) => { - self.block_name = n; - self.block = true; - Some(BlockAttach) - } - Err(e) => { - s.echo(e.to_string()); - Some(BlockName) - } - }, - (BlockName, Input::Enter) => { - // Enter keeps the block chosen before, if there is one. - if super::mleader_content::block_name(s, &self.block_name).is_ok() { - self.block = true; - Some(BlockAttach) - } else { - s.echo("Requires the name of a block defined in the drawing."); - Some(BlockName) - } - } - (BlockAttach, Input::Keyword(k)) => { - self.block_center = k == "Center extents"; - Some(Menu) - } - (MaxPoints, Input::Text(t)) => match t.trim().parse::() { - Ok(n) if (2..=MAX_LEADER_POINTS).contains(&n) => { - self.max_points = n; - Some(Menu) - } - _ => { - s.echo(format!("Requires an integer between 2 and {MAX_LEADER_POINTS}.")); - Some(MaxPoints) - } - }, - (FirstAngle | SecondAngle, Input::Text(t)) => match s.angle_settings().amount(&t).filter(|a| *a >= 0.0) { - Some(a) => { - self.angles[usize::from(o == SecondAngle)] = a; - Some(Menu) - } - None => { - s.echo("Requires a valid angle."); - Some(o) - } - }, - (Menu | LeaderType | Landing | ContentType | BlockAttach, Input::Text(_)) => { - s.echo("Invalid option keyword."); - Some(o) - } - (_, Input::Enter) => Some(Menu), - _ => Some(o), - } - } -} - -impl Interactive for MLeaderM { - fn name(&self) -> &'static str { - "MLEADER" - } - fn prompt(&self, _s: &Session) -> Prompt { - if let Some(o) = self.opt { - return Self::option_prompt(o); - } - if self.wants_text() { - return Prompt::new("Enter leader text", Accept::TEXT); - } - if self.no_leader() && self.pts.is_empty() { - let what = if self.content && self.block { "Specify location of the block" } else { "Specify location of the text" }; - return Prompt::new(what, Accept::POINT).kw(&["Options"]).default("Options"); - } - let last = self.pts.len() + 1 >= self.max_points; - match (self.pts.last(), self.order) { - (None, LeaderOrder::Arrowhead) => Prompt::new("Specify leader arrowhead location", Accept::POINT) - .kw(&["leader Landing first", "Content first", "Options"]) - .default("Options"), - (None, LeaderOrder::Landing) => Prompt::new("Specify leader landing location", Accept::POINT) - .kw(&["leader arrowHead first", "Content first", "Options"]) - .default("Options"), - (None, LeaderOrder::Content) => Prompt::new("Specify location of the text", Accept::POINT) - .kw(&["leader arrowHead first", "leader Landing first", "Options"]) - .default("Options"), - (Some(&b), _) if !last => Prompt::new("Specify next point", Accept::POINT).base(b), - (Some(&b), LeaderOrder::Arrowhead) => Prompt::new("Specify leader landing location", Accept::POINT).base(b), - (Some(&b), _) => Prompt::new("Specify leader arrowhead location", Accept::POINT).base(b), - } - } - fn input(&mut self, s: &mut Session, i: Input) -> Result { - if let Some(o) = self.opt { - self.opt = self.option(s, o, i); - self.remember(s); - return Ok(Step::Continue); - } - if self.wants_text() { - self.text = Some(if let Input::Text(t) = i { t } else { String::new() }); - return if self.leader_done { self.finish(s) } else { Ok(Step::Continue) }; - } - match i { - Input::Point(p) => { - let p = self.constrained(p); - self.pts.push(p); - if self.pts.len() >= self.max_points || self.no_leader() { - self.leader_done = true; - } - } - Input::Keyword(k) if self.pts.is_empty() => { - match k.as_str() { - "leader Landing first" => self.order = LeaderOrder::Landing, - "leader arrowHead first" => self.order = LeaderOrder::Arrowhead, - "Content first" => self.order = LeaderOrder::Content, - _ => self.opt = Some(LeaderOpt::Menu), - } - self.remember(s); - } - Input::Enter if self.pts.is_empty() => self.opt = Some(LeaderOpt::Menu), - // Enter at "next point" ends the leader at the last point. - Input::Enter if self.pts.len() >= 2 => self.leader_done = true, - Input::Enter => return Ok(Step::Cancel), - _ => {} - } - if self.leader_done && !self.wants_text() { self.finish(s) } else { Ok(Step::Continue) } - } - fn preview(&self, s: &Session, c: Vec2) -> Vec { - if self.opt.is_some() || self.wants_text() || self.leader_done || self.pts.is_empty() { - return Vec::new(); - } - let text = self.text.as_deref().unwrap_or(""); - mleader_kind(s, &self.leader(Some(self.constrained(c))), text, self.landing) - .and_then(|k| super::mleader_content::apply(s, k, &self.content_spec()).ok()) - .into_iter() - .collect() - } -} - #[path = "annotate_tedit.rs"] mod tedit; @@ -1834,5 +142,4 @@ mod tedit; mod dimedit; #[cfg(test)] -#[path = "annotate_tests.rs"] mod tests; diff --git a/crates/engine/src/cmd/annotate/angular.rs b/crates/engine/src/cmd/annotate/angular.rs new file mode 100644 index 000000000..e576798ca --- /dev/null +++ b/crates/engine/src/cmd/annotate/angular.rs @@ -0,0 +1,330 @@ +use super::common::{DimText, add_dim, assoc_enabled, dim, hex_param, pick_at, segment_at, with_assoc}; +use super::*; + +/// Two-line angular dimension (type 2) between two lines, attached to them. +fn angular_from_lines(s: &Session, h1: Handle, h2: Handle, at: Vec2) -> Option { + let d = s.doc().ok()?; + let line = |h: Handle| match &d.entity(h)?.kind { + EntityKind::Line(l) => Some((l.a.xy(), l.b.xy())), + _ => None, + }; + let ((a1, a2), (b1, b2)) = (line(h1)?, line(h2)?); + let (v, _, _) = cadcraft_geom::line_line_infinite(a1, a2, b1, b2)?; + // Use the leg ends on the side of the picked arc location (the quadrant the user meant). + let pick = |p: Vec2, q: Vec2, snap_p: AssocSnap, snap_q: AssocSnap| { + let dir = if (p - v).len() > (q - v).len() { (p, snap_p) } else { (q, snap_q) }; + let other = if dir.1 == AssocSnap::Start { (q, AssocSnap::End) } else { (p, AssocSnap::Start) }; + let side = |x: Vec2| (x - v).normalized().dot((at - v).normalized()); + if side(other.0) > side(dir.0) && other.0.dist(v) > 1e-9 { other } else { dir } + }; + let (p1, s1) = pick(a1, a2, AssocSnap::Start, AssocSnap::End); + let (p2, s2) = pick(b1, b2, AssocSnap::Start, AssocSnap::End); + // A two-line angular dimension (type 2): line 1 is p13 -> p14, line 2 is defpt -> p15, p16 is on the arc. + // The picked leg end goes last in each pair, so the renderer measures between p14 and p15. + let (o1, t1) = if s1 == AssocSnap::Start { (a2, AssocSnap::End) } else { (a1, AssocSnap::Start) }; + let (o2, t2) = if s2 == AssocSnap::Start { (b2, AssocSnap::End) } else { (b1, AssocSnap::Start) }; + let mut k = dim(s, DimKind::Angular, o2, o1, p1, p2, ""); + if let EntityKind::Dimension(d) = &mut k { + d.p16 = v3(at); + } + let mut assoc = Vec::new(); + if assoc_enabled(s) { + assoc = vec![ + DimAssoc { point: "p13".into(), handle: h1, snap: t1 }, + DimAssoc { point: "p14".into(), handle: h1, snap: s1 }, + DimAssoc { point: "defpt".into(), handle: h2, snap: t2 }, + DimAssoc { point: "p15".into(), handle: h2, snap: s2 }, + ]; + } + Some(with_assoc(k, assoc)) +} + +pub(super) fn run_angular(s: &mut Session, p: &Value) -> Result { + if let Some(ls) = p.get("lines").and_then(Value::as_array) { + let hs: Vec = ls.iter().filter_map(|v| v.as_str().and_then(Handle::parse_hex).or_else(|| v.as_u64().map(Handle))).collect(); + let (Some(h1), Some(h2)) = (hs.first(), hs.get(1)) else { return Err(bad("dimangular", "`lines` needs two line handles")) }; + let at = point_req("dimangular", p, "at")?; + let k = angular_from_lines(s, *h1, *h2, at).ok_or_else(|| bad("dimangular", "objects must be two non-parallel lines"))?; + return Ok(json!({ "handle": add_dim(s, k)?.hex() })); + } + if let Some(h) = hex_param(p, "arc") { + let at = point_req("dimangular", p, "at")?; + let a = match s.doc()?.entity(h).map(|e| e.kind.clone()) { + Some(EntityKind::Arc(a)) => a, + _ => 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, ""); + return Ok(json!({ "handle": add_dim(s, k)?.hex() })); + } + let v = point_req("dimangular", p, "vertex")?; + let a = point_req("dimangular", p, "p1")?; + let b = point_req("dimangular", p, "p2")?; + let at = point_req("dimangular", p, "at")?; + 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, ""); + Ok(json!({ "handle": add_dim(s, k)?.hex() })) +} + +pub(super) fn run_arclen(s: &mut Session, p: &Value) -> Result { + let h = targets(s, p)?.first().copied().ok_or_else(|| bad("dimarc", "`handle` of an arc is required"))?; + let at = point_req("dimarc", p, "at")?; + let a = match s.doc()?.entity(h).map(|e| e.kind.clone()) { + Some(EntityKind::Arc(a)) => a, + _ => 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, ""); + Ok(json!({ "handle": add_dim(s, k)?.hex() })) +} + +#[derive(Default)] +pub(super) struct AngularM { + handles: Vec, + first: Option<(Vec2, Vec2)>, + second: Option<(Vec2, Vec2)>, + vertex_mode: Vec, + vertex: bool, + arc: Option<(Vec2, Vec2, Vec2)>, + text: DimText, + /// Quadrant: a point inside the angle to measure; the arc location then only sets the radius. + quadrant: Option, + asking_quadrant: bool, +} + +impl AngularM { + /// (vertex, p1, p2) of the angle being dimensioned. + fn geometry(&self) -> Option<(Vec2, Vec2, Vec2)> { + if let Some(a) = self.arc { + return Some(a); + } + if let [v, a, b] = self.vertex_mode.as_slice() { + return Some((*v, *a, *b)); + } + let ((a1, a2), (b1, b2)) = (self.first?, self.second?); + let (v, _, _) = cadcraft_geom::line_line_infinite(a1, a2, b1, b2)?; + let far = |p: Vec2, q: Vec2| if p.dist(v) > q.dist(v) { p } else { q }; + Some((v, far(a1, a2), far(b1, b2))) + } + /// The arc location `at`, turned into the locked quadrant (same radius) when one is set. + fn located(&self, v: Vec2, at: Vec2) -> Vec2 { + match self.quadrant { + Some(q) if q.dist(v) > 1e-12 && at.dist(v) > 1e-12 => v + (q - v).normalized() * at.dist(v), + _ => at, + } + } +} + +impl Interactive for AngularM { + fn name(&self) -> &'static str { + "DIMANGULAR" + } + fn prompt(&self, _s: &Session) -> Prompt { + if let Some(p) = self.text.prompt() { + return p; + } + if self.asking_quadrant { + return Prompt::new("Specify quadrant", Accept::POINT); + } + if self.geometry().is_some() { + return Prompt::new("Specify dimension arc line location", Accept::POINT).kw(&["Mtext", "Text", "Angle", "Quadrant"]); + } + if self.vertex { + return Prompt::new( + ["Specify angle vertex", "Specify first angle endpoint", "Specify second angle endpoint"] + .get(self.vertex_mode.len()) + .copied() + .unwrap_or(""), + Accept::POINT, + ); + } + 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 self.text.answer(s, &i) { + return Ok(Step::Continue); + } + if self.asking_quadrant { + if let Input::Point(q) = i { + self.quadrant = Some(q); + } + self.asking_quadrant = false; + return Ok(Step::Continue); + } + if let Some((v, a, b)) = self.geometry() { + return match i { + Input::Point(at) => { + let at = self.located(v, at); + let k = match (self.handles.as_slice(), self.arc) { + ([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, "")); + add_dim(s, self.text.apply(k))?; + Ok(Step::Done) + } + Input::Keyword(k) => { + if k == "Quadrant" { + self.asking_quadrant = true; + } else { + self.text.keyword(&k); + } + Ok(Step::Continue) + } + Input::Enter => Ok(Step::Cancel), + _ => Ok(Step::Continue), + }; + } + if self.vertex { + match i { + Input::Point(p) => self.vertex_mode.push(p), + Input::Enter => return Ok(Step::Cancel), + _ => {} + } + return Ok(Step::Continue); + } + match i { + Input::Enter if self.first.is_none() => { + self.vertex = true; + Ok(Step::Continue) + } + Input::Point(p) => { + let Some(h) = 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(g) = circle.filter(|_| self.first.is_none()) { + // The center is the vertex and the pick the first endpoint; ask for the second. + self.vertex = true; + self.vertex_mode = vec![g.center, g.closest(p)]; + return Ok(Step::Continue); + } + match segment_at(s, h, p) { + Some(Segment::Arc { arc: g, .. }) if self.first.is_none() => { + self.arc = Some((g.center, g.start_point(), g.end_point())); + } + Some(Segment::Line(l)) => { + self.handles.push(h); + let seg = (l.a, l.b); + if self.first.is_none() { + self.first = Some(seg); + } else { + self.second = Some(seg); + } + } + _ => s.echo("Select an arc, circle or line."), + } + Ok(Step::Continue) + } + Input::Enter => Ok(Step::Cancel), + _ => Ok(Step::Continue), + } + } + 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, ""))]).unwrap_or_default() + } +} + +#[derive(Default)] +pub(super) struct ArcLenM { + arc: Option, + text: DimText, + /// Partial: the points picked so far on the arc (`None` when not choosing a part). + partial: Option>, +} + +/// The part of `arc` between the points nearest `p` and `q` (in the arc's direction). +fn arc_part(arc: &cadcraft_geom::Arc, p: Vec2, q: Vec2) -> Option { + let sweep = arc.sweep(); + // Offset along the arc from its start, clamped to the nearer end when the point is off the arc. + let along = |x: Vec2| { + let t = cadcraft_geom::ccw_sweep(arc.start, arc.center.angle_to(x)); + if t <= sweep { + t + } else if t - sweep < std::f64::consts::TAU - t { + sweep + } else { + 0.0 + } + }; + let (t1, t2) = (along(p), along(q)); + let (lo, hi) = (t1.min(t2), t1.max(t2)); + (hi - lo > 1e-9).then(|| cadcraft_geom::Arc::new(arc.center, arc.radius, arc.start + lo, arc.start + hi)) +} + +impl Interactive for ArcLenM { + fn name(&self) -> &'static str { + "DIMARC" + } + fn prompt(&self, _s: &Session) -> Prompt { + if let Some(p) = self.text.prompt() { + return p; + } + if let Some(picked) = &self.partial { + return match picked.first() { + None => Prompt::new("Specify first point for arc length dimension", Accept::POINT), + Some(&f) => Prompt::new("Specify second point for arc length dimension", Accept::POINT).base(f), + }; + } + match self.arc { + None => Prompt::new("Select arc or polyline arc segment", Accept::POINT), + Some(_) => Prompt::new("Specify arc length dimension location", Accept::POINT).kw(&["Mtext", "Text", "Angle", "Partial"]), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + if self.text.answer(s, &i) { + return Ok(Step::Continue); + } + if let Some(picked) = self.partial.take() { + match (i, picked.first().copied(), self.arc) { + (Input::Point(p), None, _) => self.partial = Some(vec![p]), + (Input::Point(q), Some(p), Some(g)) => match arc_part(&g, p, q) { + Some(part) => self.arc = Some(part), + None => s.echo("The two points must be different."), + }, + (Input::Enter, ..) => {} + _ => self.partial = Some(picked), + } + return Ok(Step::Continue); + } + match (self.arc, i) { + (None, Input::Point(p)) => { + if let Some(EntityKind::Arc(a)) = pick_at(s, p).and_then(|h| s.doc().ok()?.entity(h).map(|e| e.kind.clone())) { + self.arc = Some(cadcraft_geom::Arc::new(a.center.xy(), a.radius, a.start, a.end)); + } else { + s.echo("Select an arc."); + } + 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, "")))?; + Ok(Step::Done) + } + (Some(_), Input::Keyword(k)) => { + if k == "Partial" { + self.partial = Some(Vec::new()); + } else { + self.text.keyword(&k); + } + Ok(Step::Continue) + } + (_, Input::Enter) => Ok(Step::Cancel), + _ => Ok(Step::Continue), + } + } + fn preview(&self, s: &Session, c: Vec2) -> Vec { + 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, ""))]).unwrap_or_default() + } +} diff --git a/crates/engine/src/cmd/annotate/common.rs b/crates/engine/src/cmd/annotate/common.rs new file mode 100644 index 000000000..2dec75558 --- /dev/null +++ b/crates/engine/src/cmd/annotate/common.rs @@ -0,0 +1,170 @@ +use super::*; + +pub(super) fn style(s: &Session) -> String { + s.doc().map(|d| d.header.str("DIMSTYLE", "Standard")).unwrap_or_else(|_| "Standard".into()) +} + +pub(super) fn dim(s: &Session, kind: DimKind, defpt: Vec2, p13: Vec2, p14: Vec2, p15: Vec2, text: &str) -> EntityKind { + EntityKind::Dimension(Dimension { + kind, + defpt: v3(defpt), + text_mid: Vec3::ZERO, + p13: v3(p13), + p14: v3(p14), + p15: v3(p15), + p16: v3(Vec2::ZERO), + text: text.into(), + style: style(s), + measurement: 0.0, + text_rotation: 0.0, + user_text_pos: false, + block: None, + overrides: s.doc().map(|d| d.dim_overrides()).unwrap_or_default(), + assoc: Vec::new(), + marks: Default::default(), + }) +} + +/// Horizontal (0) or vertical (90°) from where the dimension line is placed (AutoCAD's rule). +fn auto_rotation(p1: Vec2, p2: Vec2, at: Vec2) -> f64 { + let (x0, x1) = (p1.x.min(p2.x), p1.x.max(p2.x)); + let (y0, y1) = (p1.y.min(p2.y), p1.y.max(p2.y)); + let outside_y = at.y < y0 || at.y > y1; + let outside_x = at.x < x0 || at.x > x1; + if outside_x && !outside_y { std::f64::consts::FRAC_PI_2 } else { 0.0 } +} + +pub(super) fn linear_kind(p1: Vec2, p2: Vec2, at: Vec2, rotation: Option, aligned: bool) -> DimKind { + if aligned { DimKind::Aligned } else { DimKind::Linear { rotation: rotation.unwrap_or_else(|| auto_rotation(p1, p2, at)) } } +} + +/// Add a dimension. With DIMASSOC on, definition points that sit on object endpoints, +/// vertices or centres are attached to them (associative dimensions). +pub(super) fn add_dim(s: &mut Session, k: EntityKind) -> Result { + let mut k = k; + if let EntityKind::Dimension(dm) = &mut k + && dm.assoc.is_empty() + && assoc_enabled(s) + { + dm.assoc = crate::assoc::reassociate(s.doc()?, &s.space(), dm); + } + let h = s.add_entity(k)?; + s.last_dim = Some(h); + Ok(h) +} + +pub(super) fn assoc_enabled(s: &Session) -> bool { + s.doc().map(|d| d.header.i64("DIMASSOC", 2) != 0).unwrap_or(false) +} + +pub(super) fn with_assoc(k: EntityKind, assoc: Vec) -> EntityKind { + match k { + EntityKind::Dimension(mut d) => { + d.assoc = assoc; + EntityKind::Dimension(d) + } + other => other, + } +} + +/// Centre and radius of a circle/arc entity. +pub(super) fn circle_of(s: &Session, h: Handle) -> Option<(Vec2, f64)> { + match &s.doc().ok()?.entity(h)?.kind { + EntityKind::Circle(c) => Some((c.center.xy(), c.radius)), + EntityKind::Arc(a) => Some((a.center.xy(), a.radius)), + _ => None, + } +} + +/// The dimension line location of a radius/diameter dimension puts the text there, inside or +/// outside the circle, as a user-positioned text (DXF flag 70 bit 128), like AutoCAD. +pub(super) fn text_at(mut k: EntityKind, at: Vec2) -> EntityKind { + if let EntityKind::Dimension(d) = &mut k { + d.text_mid = v3(at); + d.user_text_pos = true; + } + k +} + +pub(super) fn hex_param(p: &Value, key: &str) -> Option { + p.get(key).and_then(|v| v.as_str().and_then(Handle::parse_hex).or_else(|| v.as_u64().map(Handle))) +} + +// ---------------- interactive ---------------- + +pub(super) fn pick_at(s: &Session, p: Vec2) -> Option { + let ap = s.pixel_size() * s.settings.pickbox.max(1.0) * 1.5; + crate::select::pick(s.doc().ok()?, &s.space(), p, ap) +} + +/// The straight or curved piece of `h` picked at `p`: a line, an arc, or the polyline segment nearest `p`. +pub(in crate::cmd) 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 }), + EntityKind::LwPolyline(pl) => cadcraft_geom::Polyline { vertices: pl.vertices.clone(), closed: pl.closed } + .segments() + .into_iter() + .min_by(|x, y| x.closest(p).dist(p).total_cmp(&y.closest(p).dist(p))), + _ => None, + } +} + +/// What a dimension placement prompt asks after Mtext, Text or Angle. +#[derive(Clone, Copy, PartialEq)] +enum DimAsk { + Text, + Angle, +} + +/// The Mtext / Text / Angle options of the dimension placement prompts: replace the measured text +/// (`<>` stands for the measurement) and rotate the text. +#[derive(Default)] +pub(super) struct DimText { + text: String, + angle: f64, + asking: Option, +} + +impl DimText { + /// Opens the sub-prompt for Mtext, Text or Angle; `false` for any other keyword. + pub(super) fn keyword(&mut self, k: &str) -> bool { + self.asking = match k { + "Mtext" | "Text" => Some(DimAsk::Text), + "Angle" => Some(DimAsk::Angle), + _ => return false, + }; + true + } + pub(super) fn prompt(&self) -> Option { + Some(match self.asking? { + DimAsk::Text => Prompt::new("Enter dimension text (<> = measurement)", Accept::TEXT), + DimAsk::Angle => Prompt::new("Specify angle of dimension text", Accept::NUMBER), + }) + } + /// Takes the answer to the open sub-prompt (Enter keeps the current value); `false` when none is open. + pub(super) fn answer(&mut self, s: &mut Session, i: &Input) -> bool { + let Some(a) = self.asking else { return false }; + match (a, i) { + (DimAsk::Text, Input::Text(t)) => self.text = t.clone(), + (DimAsk::Angle, Input::Text(t)) => match s.angle_settings().direction(t) { + Some(r) => self.angle = r, + None => { + s.echo("Requires a valid angle."); + return true; + } + }, + _ => {} + } + self.asking = None; + true + } + /// `k` with this text and text angle. + pub(super) fn apply(&self, mut k: EntityKind) -> EntityKind { + if let EntityKind::Dimension(d) = &mut k { + d.text = self.text.clone(); + d.text_rotation = self.angle; + } + k + } +} diff --git a/crates/engine/src/cmd/annotate/edit.rs b/crates/engine/src/cmd/annotate/edit.rs new file mode 100644 index 000000000..74292bc81 --- /dev/null +++ b/crates/engine/src/cmd/annotate/edit.rs @@ -0,0 +1,371 @@ +use super::common::{hex_param, style}; +use super::*; + +pub(super) fn run_update(s: &mut Session, p: &Value) -> Result { + let hs = targets(s, p)?; + let st = style(s); + let d = s.doc_mut()?; + let mut n = 0; + for h in hs { + let _ = d.modify_entity(h, |e| { + if let EntityKind::Dimension(dm) = &mut e.kind { + dm.style = st.clone(); + dm.block = None; + n += 1; + } + }); + } + Ok(json!({ "updated": n })) +} + +/// DIMOVERRIDE: per-dimension style overrides (`clear` removes them); `text` sets the text. +pub(super) fn run_override(s: &mut Session, p: &Value) -> Result { + if p.get("text").is_none() { + return crate::cmd::props::dim_override(s, p); + } + let hs = targets(s, p)?; + let t = str_param(p, "text").unwrap_or("").to_string(); + // Dimension variables given next to `text` are applied first, so an unknown name still errors before anything changes. + let vars: serde_json::Map = p + .as_object() + .map(|o| o.iter().filter(|(k, _)| !matches!(k.as_str(), "text" | "handles" | "handle")).map(|(k, v)| (k.clone(), v.clone())).collect()) + .unwrap_or_default(); + let mut ignored = Value::Null; + if !vars.is_empty() { + let mut rest = vars; + rest.insert("handles".into(), json!(hs.iter().map(|h| h.hex()).collect::>())); + ignored = crate::cmd::props::dim_override(s, &Value::Object(rest))?.get("ignored").cloned().unwrap_or(Value::Null); + } + let d = s.doc_mut()?; + for h in &hs { + let _ = d.modify_entity(*h, |e| { + if let EntityKind::Dimension(dm) = &mut e.kind { + dm.text = t.clone(); + dm.block = None; + } + }); + } + match ignored { + Value::Null => Ok(json!({ "changed": hs.len() })), + ig => Ok(json!({ "changed": hs.len(), "ignored": ig })), + } +} + +pub(super) fn dims_of(s: &Session, hs: &[Handle]) -> Vec { + let Ok(d) = s.doc() else { return Vec::new() }; + hs.iter().copied().filter(|h| d.entity(*h).is_some_and(|e| matches!(e.kind, EntityKind::Dimension(_)))).collect() +} + +pub(super) fn run_reassociate(s: &mut Session, p: &Value) -> Result { + let hs = dims_of(s, &targets(s, p)?); + let space = s.space(); + let mut plan = Vec::new(); + { + let d = s.doc()?; + for h in &hs { + if let Some(EntityKind::Dimension(dm)) = d.entity(*h).map(|e| &e.kind) { + plan.push((*h, crate::assoc::reassociate(d, &space, dm))); + } + } + } + let mut points = 0; + let mut n = 0; + let d = s.doc_mut()?; + for (h, a) in plan { + points += a.len(); + n += usize::from(!a.is_empty()); + let _ = d.modify_entity(h, |e| { + if let EntityKind::Dimension(dm) = &mut e.kind { + dm.assoc = a; + } + }); + } + Ok(json!({ "dimensions": hs.len(), "associated": n, "points": points, "message": format!("{n} of {} dimension(s) associated.", hs.len()) })) +} + +pub(super) fn run_disassociate(s: &mut Session, p: &Value) -> Result { + let hs = dims_of(s, &targets(s, p)?); + let d = s.doc_mut()?; + let mut n = 0; + for h in &hs { + let _ = d.modify_entity(*h, |e| { + if let EntityKind::Dimension(dm) = &mut e.kind + && !dm.assoc.is_empty() + { + dm.assoc.clear(); + n += 1; + } + }); + } + Ok(json!({ "disassociated": n, "message": format!("{n} disassociated.") })) +} + +/// DIMTEDIT: move/rotate/justify dimension text. +pub(super) fn run_tedit(s: &mut Session, p: &Value, mode: &str) -> Result { + let mut q = p.as_object().cloned().unwrap_or_default(); + q.insert("mode".into(), json!(mode)); + run_dimtedit(s, &Value::Object(q)) +} + +pub(super) fn run_dimtedit(s: &mut Session, p: &Value) -> Result { + let hs = dims_of(s, &targets(s, p)?); + if hs.is_empty() { + return Err(bad("dimtedit", "select dimensions")); + } + let mode = str_param(p, "mode").unwrap_or("").to_ascii_lowercase(); + let at = point_param(p, "at"); + let angle = p.get("angle").and_then(Value::as_f64).filter(|a| a.is_finite()).map(f64::to_radians); + if mode == "angle" && angle.is_none() { + return Err(bad("dimtedit", "`angle` (degrees) is required")); + } + let d = s.doc_mut()?; + let dimscale = d.header.f64("DIMSCALE", 1.0); + for h in &hs { + let st = match d.entity(*h).map(|e| &e.kind) { + Some(EntityKind::Dimension(dm)) => d.dim_style(&dm.style).cloned().unwrap_or_default().with_overrides(&dm.overrides), + _ => continue, + }; + d.modify_entity(*h, |e| { + let EntityKind::Dimension(dm) = &mut e.kind else { return }; + dm.block = None; + if let Some(q) = at { + dm.text_mid = v3(q); + dm.user_text_pos = true; + // DIMTMOVE 0: the dimension line moves with the text. + st.follow_text(dm, dimscale); + } + match mode.as_str() { + "home" => { + dm.user_text_pos = false; + dm.text_rotation = 0.0; + dm.overrides.remove("textJust"); + } + "angle" => dm.text_rotation = angle.unwrap_or(0.0), + "left" | "center" | "right" => { + dm.user_text_pos = false; + let j = match mode.as_str() { + "left" => 1, + "right" => 2, + _ => 0, + }; + dm.overrides.insert("textJust".into(), json!(j)); + } + _ => {} + } + })?; + } + Ok(json!({ "changed": hs.len() })) +} + +/// DIMSPACE: equal spacing of parallel linear dimensions from a base dimension. +pub(super) fn run_dimspace(s: &mut Session, p: &Value) -> Result { + let base = hex_param(p, "base").ok_or_else(|| bad("dimspace", "`base` dimension is required"))?; + let hs: Vec = dims_of(s, &targets(s, p)?).into_iter().filter(|h| *h != base).collect(); + let d = s.doc()?; + let Some(EntityKind::Dimension(b)) = d.entity(base).map(|e| e.kind.clone()) else { return Err(bad("dimspace", "`base` is not a dimension")) }; + let st = d.dim_style(&b.style).cloned().unwrap_or_default().with_overrides(&b.overrides); + let spacing = p + .get("spacing") + .and_then(Value::as_f64) + .filter(|v| v.is_finite() && *v >= 0.0) + .unwrap_or(st.baseline_spacing * st.effective_scale(d.header.f64("DIMSCALE", 1.0)) * 2.0); + let dir = match b.kind { + DimKind::Linear { rotation } => Vec2::from_angle(rotation), + DimKind::Aligned => (b.p14.xy() - b.p13.xy()).normalized(), + _ => return Err(bad("dimspace", "base must be a linear or aligned dimension")), + }; + let n = dir.perp(); + let base_off = (b.defpt.xy() - b.p13.xy()).dot(n); + let side = if base_off >= 0.0 { 1.0 } else { -1.0 }; + // Order the others by their current distance from the base line. + let mut others: Vec<(Handle, f64)> = hs + .iter() + .filter_map(|h| match d.entity(*h).map(|e| &e.kind) { + Some(EntityKind::Dimension(o)) if matches!(o.kind, DimKind::Linear { .. } | DimKind::Aligned) => { + Some((*h, (o.defpt.xy() - b.defpt.xy()).dot(n) * side)) + } + _ => None, + }) + .collect(); + others.sort_by(|a, c| a.1.total_cmp(&c.1)); + let line_pt = b.defpt.xy(); + let dm = s.doc_mut()?; + for (i, (h, _)) in others.iter().enumerate() { + let target = line_pt + n * (side * spacing * (i as f64 + 1.0)); + dm.modify_entity(*h, |e| { + if let EntityKind::Dimension(o) = &mut e.kind { + let cur = o.defpt.xy(); + let moved = cur + n * (target - cur).dot(n); + o.defpt = v3(moved); + o.user_text_pos = false; + o.block = None; + } + })?; + } + Ok(json!({ "spaced": others.len() })) +} + +/// DIMSPACE at the command line: select the base dimension, select the dimensions to space, then +/// the spacing (Enter or Auto takes the DIMDLI-based default). +#[derive(Default)] +pub(super) struct DimSpaceM { + base: Option, + sel: SelectPhase, + spacing_phase: bool, +} + +impl DimSpaceM { + fn apply(&self, s: &mut Session, base: Handle, spacing: Option) -> Result { + let mut p = serde_json::Map::new(); + p.insert("base".into(), json!(base.hex())); + p.insert("handles".into(), json!(self.sel.picked.iter().map(|h| h.hex()).collect::>())); + if let Some(v) = spacing { + p.insert("spacing".into(), json!(v)); + } + let r = run_dimspace(s, &Value::Object(p))?; + s.set_selection(Vec::new()); + s.echo(format!("{} dimension(s) spaced.", r.get("spaced").and_then(Value::as_u64).unwrap_or(0))); + Ok(Step::Done) + } +} + +impl Interactive for DimSpaceM { + fn name(&self) -> &'static str { + "DIMSPACE" + } + fn begin(&mut self, _s: &mut Session) -> Result { + self.sel = SelectPhase::single(); + Ok(Step::Continue) + } + fn prompt(&self, _s: &Session) -> Prompt { + if self.spacing_phase { + return Prompt::new("Enter value", Accept::NUMBER).kw(&["Auto"]).default("Auto"); + } + if self.base.is_none() { + return Prompt::new("Select base dimension", Accept::SELECT); + } + Prompt::new("Select dimensions to space", Accept::SELECT) + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + if self.spacing_phase { + let Some(base) = self.base else { return Ok(Step::Cancel) }; + return match i { + Input::Enter => self.apply(s, base, None), + Input::Keyword(k) | Input::Text(k) if k.trim().eq_ignore_ascii_case("a") || k.trim().eq_ignore_ascii_case("auto") => { + self.apply(s, base, None) + } + Input::Text(t) => match crate::cmd::machines::number(&t).filter(|v| v.is_finite() && *v >= 0.0) { + Some(v) => self.apply(s, base, Some(v)), + None => { + s.echo("Requires a non-negative number or Auto."); + Ok(Step::Continue) + } + }, + _ => Ok(Step::Continue), + }; + } + match self.sel.feed(s, &i)? { + SelOutcome::More => Ok(Step::Continue), + SelOutcome::Empty => Ok(Step::Done), + SelOutcome::Done(hs) => { + if self.base.is_none() { + match hs.first().copied().filter(|h| !dims_of(s, &[*h]).is_empty()) { + Some(b) => { + self.base = Some(b); + self.sel = SelectPhase::default(); + } + None => { + s.echo("Select a dimension."); + self.sel = SelectPhase::single(); + } + } + } else { + self.sel.picked = hs; + self.spacing_phase = true; + } + Ok(Step::Continue) + } + } + } +} + +/// DIMOVERRIDE at the command line: variable, value (repeat), Enter, then select dimensions. +pub(super) struct OverrideM { + vars: serde_json::Map, + var: Option, + clear: bool, + selecting: Option, +} + +impl OverrideM { + pub(super) fn new(_s: &Session) -> Self { + OverrideM { vars: serde_json::Map::new(), var: None, clear: false, selecting: None } + } + fn apply(&self, s: &mut Session, hs: Vec) -> Result { + let mut p = self.vars.clone(); + p.insert("handles".into(), json!(hs.iter().map(|h| h.hex()).collect::>())); + if self.clear { + p.insert("clear".into(), json!(true)); + } + let r = crate::cmd::props::dim_override(s, &Value::Object(p))?; + s.echo(format!("{} dimension(s) changed.", r.get("changed").and_then(Value::as_u64).unwrap_or(0))); + Ok(Step::Done) + } +} + +impl Interactive for OverrideM { + fn name(&self) -> &'static str { + "DIMOVERRIDE" + } + fn prompt(&self, _s: &Session) -> Prompt { + if let Some(sel) = &self.selecting { + return sel.prompt(); + } + match &self.var { + Some(v) => Prompt::new(format!("Enter new value for dimension variable {}", v.to_ascii_uppercase()), Accept::TEXT), + None => Prompt::new("Enter dimension variable name to override", Accept::TEXT).kw(&["Clear overrides"]), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + if let Some(sel) = &mut self.selecting { + return match sel.feed(s, &i)? { + crate::cmd::machines::SelOutcome::More => Ok(Step::Continue), + crate::cmd::machines::SelOutcome::Empty => Ok(Step::Done), + crate::cmd::machines::SelOutcome::Done(hs) => self.apply(s, hs), + }; + } + match (self.var.take(), i) { + (None, Input::Keyword(k)) if k.starts_with("Clear") => { + self.clear = true; + self.selecting = Some(crate::cmd::machines::SelectPhase::default()); + } + (None, Input::Text(t)) => { + let t = t.trim().to_string(); + if t.eq_ignore_ascii_case("c") { + self.clear = true; + self.selecting = Some(crate::cmd::machines::SelectPhase::default()); + } else if cadcraft_doc::DimStyle::field_name(&t).is_some() { + self.var = Some(t); + } else { + s.echo(format!("Unknown dimension variable \"{t}\".")); + } + } + (None, Input::Enter) => { + if self.vars.is_empty() { + return Ok(Step::Cancel); + } + self.selecting = Some(crate::cmd::machines::SelectPhase::default()); + } + (Some(v), Input::Text(t)) => { + let val = t.trim().parse::().map(|x| json!(x)).unwrap_or_else(|_| json!(t.trim())); + self.vars.insert(v, val); + } + (Some(v), _) => self.var = Some(v), + _ => {} + } + Ok(Step::Continue) + } + fn preview(&self, _s: &Session, _c: Vec2) -> Vec { + Vec::new() + } +} diff --git a/crates/engine/src/cmd/annotate/linear.rs b/crates/engine/src/cmd/annotate/linear.rs new file mode 100644 index 000000000..ab0a14214 --- /dev/null +++ b/crates/engine/src/cmd/annotate/linear.rs @@ -0,0 +1,360 @@ +use super::common::{DimText, add_dim, assoc_enabled, circle_of, dim, linear_kind, pick_at}; +use super::*; + +pub(super) fn run_linear(s: &mut Session, p: &Value) -> Result { + let (a, b) = match p.get("object").and_then(|v| v.as_str().and_then(Handle::parse_hex).or_else(|| v.as_u64().map(Handle))) { + Some(h) => object_extent(s, h).ok_or_else(|| bad("dimlinear", "`object` must be a line, arc, circle or polyline segment"))?, + None => (point_req("dimlinear", p, "p1")?, point_req("dimlinear", p, "p2")?), + }; + 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, str_param(p, "text").unwrap_or("")); + Ok(json!({ "handle": add_dim(s, k)?.hex() })) +} + +pub(super) 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, str_param(p, "text").unwrap_or("")); + Ok(json!({ "handle": add_dim(s, k)?.hex() })) +} + +pub(super) 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, ""); + Ok(json!({ "handle": add_dim(s, k)?.hex() })) +} + +/// The last linear/aligned dimension (for CONTINUE/BASELINE). +fn last_linear(s: &Session) -> Option { + let h = s.last_dim?; + match &s.doc().ok()?.entity(h)?.kind { + EntityKind::Dimension(d) if matches!(d.kind, DimKind::Linear { .. } | DimKind::Aligned) => Some(d.clone()), + _ => None, + } +} + +/// Next dimension in a chain from `prev` to a new second origin. +fn chained(s: &Session, prev: &Dimension, next: Vec2, baseline: bool) -> EntityKind { + // DIMDLI × the overall scale of the dimension being continued: its overrides (SETVAR DIM*) + // included, and DIMSCALE 0 falling back to the drawing's DIMSCALE as when it is drawn. + let spacing = s + .doc() + .ok() + .map(|d| { + let st = d.dim_style(&prev.style).cloned().unwrap_or_default().with_overrides(&prev.overrides); + st.baseline_spacing * st.effective_scale(d.header.f64("DIMSCALE", 1.0)) + }) + .filter(|v| v.is_finite()) + .unwrap_or(0.38); + let dir = match prev.kind { + DimKind::Linear { rotation } => Vec2::from_angle(rotation), + _ => (prev.p14.xy() - prev.p13.xy()).normalized(), + }; + let first = if baseline { prev.p13.xy() } else { prev.p14.xy() }; + let mut defpt = prev.defpt.xy(); + if baseline { + // Offset the dimension line away from the measured points. + let n = dir.perp(); + let side = if (defpt - prev.p13.xy()).dot(n) >= 0.0 { 1.0 } else { -1.0 }; + defpt += n * (spacing * side); + } + EntityKind::Dimension(Dimension { + p13: v3(first), + p14: v3(next), + defpt: v3(defpt), + text: String::new(), + block: None, + assoc: Vec::new(), + marks: Default::default(), + user_text_pos: false, + ..prev.clone() + }) +} + +/// Most dimensions one DIMCONTINUE/DIMBASELINE call adds. +pub(super) const MAX_CHAIN: usize = 100_000; + +fn chain_run(s: &mut Session, p: &Value, baseline: bool) -> Result { + let id = if baseline { "dimbaseline" } else { "dimcontinue" }; + let pts = p.get("points").and_then(Value::as_array).ok_or_else(|| bad(id, "`points` is required"))?; + if pts.len() > MAX_CHAIN { + return Err(bad(id, format!("at most {MAX_CHAIN} `points` per call"))); + } + let pts = points_param(p, "points").ok_or_else(|| bad(id, "`points` must be a list of points"))?; + // One snap index for the whole chain: looking up each new point's object by scanning the + // drawing made long chains quadratic (the added dimensions are no snap targets). + let snaps = if assoc_enabled(s) { Some(crate::assoc::SnapIndex::new(s.doc()?, &s.space())) } else { None }; + let mut out = Vec::new(); + for q in pts { + let prev = last_linear(s).ok_or_else(|| bad(id, "no linear dimension to continue"))?; + let mut k = chained(s, &prev, q, baseline); + if let (Some(ix), EntityKind::Dimension(dm)) = (&snaps, &mut k) { + dm.assoc = ix.auto_assoc(&[("p13", dm.p13.xy()), ("p14", dm.p14.xy())]); + } + let h = s.add_entity(k)?; + s.last_dim = Some(h); + out.push(h.hex()); + } + Ok(json!({ "handles": out })) +} + +pub(super) fn run_continue(s: &mut Session, p: &Value) -> Result { + chain_run(s, p, false) +} + +pub(super) fn run_baseline(s: &mut Session, p: &Value) -> Result { + chain_run(s, p, true) +} + +/// The two points a picked object is dimensioned between (DIMLINEAR "select object"). +fn object_extent(s: &Session, h: Handle) -> Option<(Vec2, Vec2)> { + let e = s.doc().ok()?.entity(h)?; + if let Some((c, r)) = circle_of(s, h).filter(|_| matches!(e.kind, EntityKind::Circle(_))) { + return Some((c - Vec2::X * r, c + Vec2::X * r)); + } + e.kind.prims().into_iter().find_map(|pr| match pr { + Prim::Seg(sg @ (Segment::Line(_) | Segment::Arc { .. })) => Some((sg.start(), sg.end())), + _ => None, + }) +} + +pub(super) struct LinearM { + aligned: bool, + pts: Vec, + rotation: Option, + text: DimText, + selecting: bool, + /// Asking for the dimension line angle (Rotated). + rotating: bool, +} + +impl LinearM { + pub(super) fn new(aligned: bool) -> Self { + LinearM { aligned, pts: Vec::new(), rotation: None, text: DimText::default(), selecting: false, rotating: false } + } +} + +impl Interactive for LinearM { + fn name(&self) -> &'static str { + if self.aligned { "DIMALIGNED" } else { "DIMLINEAR" } + } + fn prompt(&self, _s: &Session) -> Prompt { + if self.rotating { + return Prompt::new("Specify angle of dimension line", Accept::NUMBER); + } + if let Some(p) = self.text.prompt() { + return p; + } + if self.selecting { + return Prompt::new("Select object to dimension", Accept::POINT); + } + match self.pts.len() { + 0 => Prompt::new("Specify first extension line origin", Accept::POINT).default("select object"), + 1 => Prompt::new("Specify second extension line origin", Accept::POINT).base_opt(self.pts.first().copied()), + // Once the orientation is forced, only the text options remain. + _ if self.aligned || self.rotation.is_some() => { + Prompt::new("Specify dimension line location", Accept::POINT).kw(&["Mtext", "Text", "Angle"]) + } + _ => Prompt::new("Specify dimension line location", Accept::POINT).kw(&["Mtext", "Text", "Angle", "Horizontal", "Vertical", "Rotated"]), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + if self.rotating { + if let Input::Text(t) = &i { + let Some(r) = s.angle_settings().direction(t) else { + s.echo("Requires a valid angle."); + return Ok(Step::Continue); + }; + self.rotation = Some(r); + } + self.rotating = false; + return Ok(Step::Continue); + } + if self.text.answer(s, &i) { + return Ok(Step::Continue); + } + if self.selecting { + if let Input::Point(p) = i { + let Some(h) = pick_at(s, p) else { + s.echo("*Invalid selection*"); + return Ok(Step::Continue); + }; + let seg = + s.doc()?.entity(h).and_then(|e| e.kind.prims().into_iter().find_map(|pr| if let Prim::Seg(sg) = pr { Some(sg) } else { None })); + match seg { + Some(sg @ Segment::Line(_)) | Some(sg @ Segment::Arc { .. }) => { + self.pts = vec![sg.start(), sg.end()]; + self.selecting = false; + } + None => { + if let Some((c, r)) = circle_of(s, h) { + self.pts = vec![c - Vec2::X * r, c + Vec2::X * r]; + self.selecting = false; + } else { + s.echo("Object cannot be dimensioned this way."); + } + } + } + } + return Ok(Step::Continue); + } + match (self.pts.len(), i) { + (0, Input::Enter) => { + self.selecting = true; + Ok(Step::Continue) + } + (n, Input::Point(p)) if n < 2 => { + self.pts.push(p); + Ok(Step::Continue) + } + (_, 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, "")); + 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(); + let g = cadcraft_render::dimension_geometry(&d, &st, 1.0); + s.echo(format!("Dimension text = {}", g.value)); + } + Ok(Step::Done) + } + (_, Input::Keyword(k)) => { + match k.as_str() { + "Horizontal" => self.rotation = Some(0.0), + "Vertical" => self.rotation = Some(std::f64::consts::FRAC_PI_2), + "Rotated" => self.rotating = true, + other => { + self.text.keyword(other); + } + } + Ok(Step::Continue) + } + (_, Input::Enter) => Ok(Step::Cancel), + _ => Ok(Step::Continue), + } + } + fn preview(&self, s: &Session, c: Vec2) -> Vec { + match self.pts.as_slice() { + [a] => vec![crate::cmd::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, ""))], + _ => Vec::new(), + } + } +} + +#[derive(Default)] +pub(super) struct OrdinateM { + feature: Option, + /// Xdatum / Ydatum: the forced ordinate type (`true` = X datum). + xtype: Option, + text: DimText, +} + +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, "")) + } +} + +impl Interactive for OrdinateM { + fn name(&self) -> &'static str { + "DIMORDINATE" + } + fn prompt(&self, _s: &Session) -> Prompt { + if let Some(p) = self.text.prompt() { + return p; + } + match (self.feature, self.xtype) { + (None, _) => Prompt::new("Specify feature location", Accept::POINT), + (Some(f), None) => Prompt::new("Specify leader endpoint", Accept::POINT).kw(&["Xdatum", "Ydatum", "Mtext", "Text", "Angle"]).base(f), + (Some(f), Some(_)) => Prompt::new("Specify leader endpoint", Accept::POINT).kw(&["Mtext", "Text", "Angle"]).base(f), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + if self.text.answer(s, &i) { + return Ok(Step::Continue); + } + match (self.feature, i) { + (None, Input::Point(p)) => { + self.feature = Some(p); + Ok(Step::Continue) + } + (Some(_), Input::Keyword(k)) => { + match k.as_str() { + "Xdatum" => self.xtype = Some(true), + "Ydatum" => self.xtype = Some(false), + other => { + self.text.keyword(other); + } + } + Ok(Step::Continue) + } + (Some(f), Input::Point(l)) => { + add_dim(s, self.kind(s, f, l))?; + Ok(Step::Done) + } + (_, Input::Enter) => Ok(Step::Cancel), + _ => Ok(Step::Continue), + } + } + fn preview(&self, s: &Session, c: Vec2) -> Vec { + self.feature.map(|f| vec![self.kind(s, f, c)]).unwrap_or_default() + } +} + +pub(super) struct ChainM { + baseline: bool, + created: Vec, +} + +impl ChainM { + pub(super) fn new(_s: &Session, baseline: bool) -> Self { + ChainM { baseline, created: Vec::new() } + } +} + +impl Interactive for ChainM { + fn name(&self) -> &'static str { + if self.baseline { "DIMBASELINE" } else { "DIMCONTINUE" } + } + fn begin(&mut self, s: &mut Session) -> Result { + if last_linear(s).is_none() { + s.echo("No linear dimension to continue. Create one with DIMLINEAR first."); + return Ok(Step::Done); + } + Ok(Step::Continue) + } + fn prompt(&self, s: &Session) -> Prompt { + Prompt::new("Specify a second extension line origin", Accept::POINT) + .kw(&["Select", "Undo"]) + .default("Select") + .base_opt(last_linear(s).map(|d| if self.baseline { d.p13.xy() } else { d.p14.xy() })) + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + match i { + Input::Point(p) => { + let prev = last_linear(s).ok_or_else(|| EngineError::Other("no linear dimension".into()))?; + let k = chained(s, &prev, p, self.baseline); + self.created.push(add_dim(s, k)?); + Ok(Step::Continue) + } + Input::Keyword(k) if k == "Undo" => { + if let Some(h) = self.created.pop() { + s.doc_mut()?.remove_entity(h); + s.last_dim = self.created.last().copied(); + } + Ok(Step::Continue) + } + Input::Enter => Ok(Step::Done), + _ => Ok(Step::Continue), + } + } + fn preview(&self, s: &Session, c: Vec2) -> Vec { + last_linear(s).map(|d| vec![chained(s, &d, c, self.baseline)]).unwrap_or_default() + } +} diff --git a/crates/engine/src/cmd/annotate/mleader.rs b/crates/engine/src/cmd/annotate/mleader.rs new file mode 100644 index 000000000..c2e0a63df --- /dev/null +++ b/crates/engine/src/cmd/annotate/mleader.rs @@ -0,0 +1,389 @@ +use super::common::style; +use super::*; + +/// A multileader from arrowhead to landing; `landing` false leaves out the landing line (dogleg). +fn mleader_kind(s: &Session, pts: &[Vec2], text: &str, landing: bool) -> Option { + let (landing_pt, arrow_pts) = pts.split_last()?; + let d = s.doc().ok()?; + let cur = d.header.str("CMLEADERSTYLE", "Standard"); + let st = d.mleader_styles.iter().find(|m| m.name.eq_ignore_ascii_case(&cur)).or(d.mleader_styles.first()).cloned().unwrap_or_default(); + // The style's sizes are the drawn sizes: a multileader style has no scale of its own and + // DIMSCALE (a dimension-style setting) does not apply, as in AutoCAD. + let th = st.text_height; + let dir = if arrow_pts.first().is_some_and(|a| a.x > landing_pt.x) { -1.0 } else { 1.0 }; + let attach = if dir > 0.0 { 4 } else { 6 }; + let dogleg = if landing { st.dogleg } else { 0.0 }; + let tpos = *landing_pt + Vec2::new((dogleg + st.landing_gap) * dir, 0.0); + Some(EntityKind::MLeader(MLeader { + leaders: vec![arrow_pts.iter().map(|p| v3(*p)).collect()], + landing: v3(*landing_pt), + dogleg, + text: (!text.is_empty()).then(|| MText { + insert: v3(tpos), + height: th, + width: 0.0, + attach, + rotation: 0.0, + style: st.text_style.clone(), + contents: text.replace('\n', "\\P"), + line_spacing: 1.0, + line_spacing_exact: false, + columns: None, + }), + style: st.name.clone(), + arrow_size: st.arrow_size, + spline: false, + block: None, + })) +} + +pub(super) fn run_mleader(s: &mut Session, p: &Value) -> Result { + let pts = points_param(p, "points").ok_or_else(|| bad("mleader", "`points` (arrow … landing) is required"))?; + let content = crate::cmd::mleader_content::json_content(s, p)?; + // Leader type None places the content at the one point given. + if pts.len() < 2 && !(content.leader == crate::LeaderType::None && pts.len() == 1) { + return Err(bad("mleader", "need an arrowhead and a landing point")); + } + let k = mleader_kind(s, &pts, str_param(p, "text").unwrap_or(""), true).ok_or_else(|| bad("mleader", "bad points"))?; + let k = crate::cmd::mleader_content::apply(s, k, &content)?; + Ok(json!({ "handle": s.add_entity(k)?.hex() })) +} + +pub(super) fn run_leader(s: &mut Session, p: &Value) -> Result { + let pts = points_param(p, "points").ok_or_else(|| bad("leader", "`points` is required"))?; + if pts.len() < 2 { + return Err(bad("leader", "need 2+ points")); + } + let st = style(s); + let h = s.add_entity(EntityKind::Leader(cadcraft_doc::Leader { + vertices: pts.iter().map(|q| v3(*q)).collect(), + arrow: true, + spline: false, + style: st, + }))?; + let mut out = vec![h.hex()]; + if let (Some(t), Some(last)) = (str_param(p, "text"), pts.last()) { + let th = s.doc()?.dim_style(&style(s)).map(|d| d.text_height).unwrap_or(0.18); + out.push( + s.add_entity(EntityKind::MText(MText { + insert: v3(*last + Vec2::new(th * 0.5, th * 0.5)), + height: th, + width: 0.0, + attach: 4, + rotation: 0.0, + style: "Standard".into(), + contents: t.into(), + line_spacing: 1.0, + line_spacing_exact: false, + columns: None, + }))? + .hex(), + ); + } + Ok(json!({ "handles": out })) +} + +/// The MLEADER Options prompt being answered. +#[derive(Clone, Copy, PartialEq)] +enum LeaderOpt { + Menu, + LeaderType, + Landing, + ContentType, + MaxPoints, + FirstAngle, + SecondAngle, + BlockName, + BlockAttach, +} + +/// Upper bound for MLEADER Maxpoints. +const MAX_LEADER_POINTS: usize = 64; + +pub(super) struct MLeaderM { + order: LeaderOrder, + /// Points in the order they were placed. + pts: Vec, + /// All leader points are placed. + leader_done: bool, + /// The leader text once entered (Content first enters it before the leader). + text: Option, + opt: Option, + max_points: usize, + /// First and second angle constraints (radians; 0 = free). + angles: [f64; 2], + landing: bool, + content: bool, + leader_type: crate::LeaderType, + /// Block content: the block (`block_name`) instead of text. + block: bool, + block_name: String, + block_center: bool, +} + +impl MLeaderM { + /// Starts with the order and Options chosen the last time (as AutoCAD does). + pub(super) fn new(s: &Session) -> Self { + let o = s.last_used.mleader; + let max_points = o.max_points.clamp(2, MAX_LEADER_POINTS); + MLeaderM { + order: o.order, + pts: Vec::new(), + leader_done: false, + text: None, + opt: None, + max_points, + angles: o.angles, + landing: o.landing, + content: o.content, + leader_type: o.leader_type, + // Block content needs a block that still exists. + block: o.block && s.doc().is_ok_and(|d| d.block(&s.last_used.mleader_block).is_some()), + block_name: s.last_used.mleader_block.clone(), + block_center: o.block_center, + } + } + /// Keep the order and Options for the next MLEADER. + fn remember(&self, s: &mut Session) { + s.last_used.mleader = MLeaderOptions { + order: self.order, + max_points: self.max_points, + angles: self.angles, + landing: self.landing, + content: self.content, + leader_type: self.leader_type, + block: self.block, + block_center: self.block_center, + }; + s.last_used.mleader_block = self.block_name.clone(); + } + /// The leader type and block content for `mleader_content::apply`. + fn content_spec(&self) -> crate::cmd::mleader_content::Content { + crate::cmd::mleader_content::Content { + leader: self.leader_type, + block: (self.content && self.block).then(|| (self.block_name.clone(), self.block_center)), + } + } + /// Leader type None: the one point placed is the content location. + fn no_leader(&self) -> bool { + self.leader_type == crate::LeaderType::None + } + /// The leader from arrowhead to landing, with `extra` placed next. + fn leader(&self, extra: Option) -> Vec { + let mut pts = self.pts.clone(); + pts.extend(extra); + if self.order != LeaderOrder::Arrowhead { + pts.reverse(); + } + pts + } + /// `p` with the first/second angle constraint applied to the segment it ends (counted from the + /// first point placed). + fn constrained(&self, p: Vec2) -> Vec2 { + let (Some(&prev), Some(&inc)) = (self.pts.last(), self.angles.get(self.pts.len().wrapping_sub(1))) else { return p }; + let len = prev.dist(p); + if inc <= 1e-9 || !inc.is_finite() || len <= 1e-12 { + return p; + } + Vec2::polar(prev, len, ((p - prev).angle() / inc).round() * inc) + } + /// The text is asked now: right after the content location (Content first) or after the leader. + fn wants_text(&self) -> bool { + self.content + && !self.block + && self.text.is_none() + && (self.leader_done || (self.order == LeaderOrder::Content && self.pts.len() == 1 && !self.no_leader())) + } + fn finish(&self, s: &mut Session) -> Result { + if self.no_leader() && !self.content { + s.echo("A multileader needs a leader line or content; nothing was drawn."); + return Ok(Step::Cancel); + } + let text = self.text.clone().unwrap_or_default(); + let k = mleader_kind(s, &self.leader(None), &text, self.landing).ok_or_else(|| EngineError::Other("bad leader".into()))?; + let k = crate::cmd::mleader_content::apply(s, k, &self.content_spec())?; + s.add_entity(k)?; + Ok(Step::Done) + } + fn option_prompt(o: LeaderOpt) -> Prompt { + let kw = crate::cmd::curves::KW; + match o { + LeaderOpt::Menu => Prompt::new("Enter an option", kw) + .kw(&["Leader type", "leader lAnding", "Content type", "Maxpoints", "First angle", "Second angle", "eXit options"]) + .default("eXit options"), + LeaderOpt::LeaderType => Prompt::new("Select a leader type", kw).kw(&["Straight", "sPline", "None"]).default("Straight"), + LeaderOpt::Landing => Prompt::new("Use landing", kw).kw(&["Yes", "No"]).default("Yes"), + LeaderOpt::ContentType => Prompt::new("Select a content type", kw).kw(&["Block", "Mtext", "None"]).default("Mtext"), + LeaderOpt::MaxPoints => Prompt::new("Enter the maximum points for leader line", Accept::NUMBER), + LeaderOpt::FirstAngle => Prompt::new("Enter first angle constraint", Accept::NUMBER), + LeaderOpt::SecondAngle => Prompt::new("Enter second angle constraint", Accept::NUMBER), + LeaderOpt::BlockName => Prompt::new("Enter block name", kw), + LeaderOpt::BlockAttach => { + Prompt::new("Specify block attachment", kw).kw(&["Center extents", "Insertion point"]).default("Center extents") + } + } + } + /// One answer at the Options prompts; Enter keeps the current value. + fn option(&mut self, s: &mut Session, o: LeaderOpt, i: Input) -> Option { + use LeaderOpt::*; + match (o, i) { + (Menu, Input::Keyword(k)) => match k.as_str() { + "Leader type" => Some(LeaderType), + "leader lAnding" => Some(Landing), + "Content type" => Some(ContentType), + "Maxpoints" => Some(MaxPoints), + "First angle" => Some(FirstAngle), + "Second angle" => Some(SecondAngle), + _ => None, + }, + (Menu, Input::Enter) => None, + (LeaderType, Input::Keyword(k)) => { + self.leader_type = match k.as_str() { + "sPline" => crate::LeaderType::Spline, + "None" => crate::LeaderType::None, + _ => crate::LeaderType::Straight, + }; + Some(Menu) + } + (Landing, Input::Keyword(k)) => { + self.landing = k == "Yes"; + Some(Menu) + } + (ContentType, Input::Keyword(k)) => { + self.content = k != "None"; + self.block = false; + // Block content asks which block, then how it attaches. + if k == "Block" { Some(BlockName) } else { Some(Menu) } + } + (BlockName, Input::Text(t)) => match crate::cmd::mleader_content::block_name(s, &t) { + Ok(n) => { + self.block_name = n; + self.block = true; + Some(BlockAttach) + } + Err(e) => { + s.echo(e.to_string()); + Some(BlockName) + } + }, + (BlockName, Input::Enter) => { + // Enter keeps the block chosen before, if there is one. + if crate::cmd::mleader_content::block_name(s, &self.block_name).is_ok() { + self.block = true; + Some(BlockAttach) + } else { + s.echo("Requires the name of a block defined in the drawing."); + Some(BlockName) + } + } + (BlockAttach, Input::Keyword(k)) => { + self.block_center = k == "Center extents"; + Some(Menu) + } + (MaxPoints, Input::Text(t)) => match t.trim().parse::() { + Ok(n) if (2..=MAX_LEADER_POINTS).contains(&n) => { + self.max_points = n; + Some(Menu) + } + _ => { + s.echo(format!("Requires an integer between 2 and {MAX_LEADER_POINTS}.")); + Some(MaxPoints) + } + }, + (FirstAngle | SecondAngle, Input::Text(t)) => match s.angle_settings().amount(&t).filter(|a| *a >= 0.0) { + Some(a) => { + self.angles[usize::from(o == SecondAngle)] = a; + Some(Menu) + } + None => { + s.echo("Requires a valid angle."); + Some(o) + } + }, + (Menu | LeaderType | Landing | ContentType | BlockAttach, Input::Text(_)) => { + s.echo("Invalid option keyword."); + Some(o) + } + (_, Input::Enter) => Some(Menu), + _ => Some(o), + } + } +} + +impl Interactive for MLeaderM { + fn name(&self) -> &'static str { + "MLEADER" + } + fn prompt(&self, _s: &Session) -> Prompt { + if let Some(o) = self.opt { + return Self::option_prompt(o); + } + if self.wants_text() { + return Prompt::new("Enter leader text", Accept::TEXT); + } + if self.no_leader() && self.pts.is_empty() { + let what = if self.content && self.block { "Specify location of the block" } else { "Specify location of the text" }; + return Prompt::new(what, Accept::POINT).kw(&["Options"]).default("Options"); + } + let last = self.pts.len() + 1 >= self.max_points; + match (self.pts.last(), self.order) { + (None, LeaderOrder::Arrowhead) => Prompt::new("Specify leader arrowhead location", Accept::POINT) + .kw(&["leader Landing first", "Content first", "Options"]) + .default("Options"), + (None, LeaderOrder::Landing) => Prompt::new("Specify leader landing location", Accept::POINT) + .kw(&["leader arrowHead first", "Content first", "Options"]) + .default("Options"), + (None, LeaderOrder::Content) => Prompt::new("Specify location of the text", Accept::POINT) + .kw(&["leader arrowHead first", "leader Landing first", "Options"]) + .default("Options"), + (Some(&b), _) if !last => Prompt::new("Specify next point", Accept::POINT).base(b), + (Some(&b), LeaderOrder::Arrowhead) => Prompt::new("Specify leader landing location", Accept::POINT).base(b), + (Some(&b), _) => Prompt::new("Specify leader arrowhead location", Accept::POINT).base(b), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + if let Some(o) = self.opt { + self.opt = self.option(s, o, i); + self.remember(s); + return Ok(Step::Continue); + } + if self.wants_text() { + self.text = Some(if let Input::Text(t) = i { t } else { String::new() }); + return if self.leader_done { self.finish(s) } else { Ok(Step::Continue) }; + } + match i { + Input::Point(p) => { + let p = self.constrained(p); + self.pts.push(p); + if self.pts.len() >= self.max_points || self.no_leader() { + self.leader_done = true; + } + } + Input::Keyword(k) if self.pts.is_empty() => { + match k.as_str() { + "leader Landing first" => self.order = LeaderOrder::Landing, + "leader arrowHead first" => self.order = LeaderOrder::Arrowhead, + "Content first" => self.order = LeaderOrder::Content, + _ => self.opt = Some(LeaderOpt::Menu), + } + self.remember(s); + } + Input::Enter if self.pts.is_empty() => self.opt = Some(LeaderOpt::Menu), + // Enter at "next point" ends the leader at the last point. + Input::Enter if self.pts.len() >= 2 => self.leader_done = true, + Input::Enter => return Ok(Step::Cancel), + _ => {} + } + if self.leader_done && !self.wants_text() { self.finish(s) } else { Ok(Step::Continue) } + } + fn preview(&self, s: &Session, c: Vec2) -> Vec { + if self.opt.is_some() || self.wants_text() || self.leader_done || self.pts.is_empty() { + return Vec::new(); + } + let text = self.text.as_deref().unwrap_or(""); + mleader_kind(s, &self.leader(Some(self.constrained(c))), text, self.landing) + .and_then(|k| crate::cmd::mleader_content::apply(s, k, &self.content_spec()).ok()) + .into_iter() + .collect() + } +} diff --git a/crates/engine/src/cmd/annotate/radial.rs b/crates/engine/src/cmd/annotate/radial.rs new file mode 100644 index 000000000..a83f7dfd2 --- /dev/null +++ b/crates/engine/src/cmd/annotate/radial.rs @@ -0,0 +1,100 @@ +use super::common::{DimText, add_dim, circle_of, dim, pick_at, text_at}; +use super::*; + +fn radial(s: &mut Session, p: &Value, diameter: bool) -> Result { + let id = if diameter { "dimdiameter" } else { "dimradius" }; + let (c, pt, at) = if let (Some(c), Some(pt)) = (point_param(p, "center"), point_param(p, "point")) { + (c, pt, None) + } else { + let h = targets(s, p)?.first().copied().ok_or_else(|| bad(id, "`handle` of a circle/arc, or {center, point}"))?; + let (c, r) = circle_of(s, h).ok_or_else(|| bad(id, "object is not a circle or arc"))?; + let at = point_param(p, "at"); + let dir = at.unwrap_or(c + Vec2::from_angle(std::f64::consts::FRAC_PI_4)); + (c, c + (dir - c).normalized() * r, at) + }; + if c.near(pt, 1e-12) { + 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, "") + } else { + dim(s, DimKind::Radius, c, Vec2::ZERO, Vec2::ZERO, pt, "") + }; + let k = match at { + Some(at) => text_at(k, at), + None => k, + }; + Ok(json!({ "handle": add_dim(s, k)?.hex() })) +} + +pub(super) fn run_radius(s: &mut Session, p: &Value) -> Result { + radial(s, p, false) +} + +pub(super) fn run_diameter(s: &mut Session, p: &Value) -> Result { + radial(s, p, true) +} + +pub(super) struct RadialM { + diameter: bool, + target: Option<(Vec2, f64)>, + text: DimText, +} + +impl RadialM { + pub(super) fn new(diameter: bool) -> Self { + RadialM { diameter, target: None, text: DimText::default() } + } + 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, "") + } else { + dim(s, DimKind::Radius, c, Vec2::ZERO, Vec2::ZERO, pt, "") + }); + text_at(k, at) + } +} + +impl Interactive for RadialM { + fn name(&self) -> &'static str { + if self.diameter { "DIMDIAMETER" } else { "DIMRADIUS" } + } + fn prompt(&self, _s: &Session) -> Prompt { + if let Some(p) = self.text.prompt() { + return p; + } + match self.target { + None => Prompt::new("Select arc or circle", Accept::POINT), + Some((c, _)) => Prompt::new("Specify dimension line location", Accept::POINT).kw(&["Mtext", "Text", "Angle"]).base(c), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + if self.text.answer(s, &i) { + return Ok(Step::Continue); + } + match (self.target, i) { + (None, Input::Point(p)) => { + match pick_at(s, p).and_then(|h| circle_of(s, h)) { + Some(t) => self.target = Some(t), + None => s.echo("Object selected is not a circle or arc."), + } + Ok(Step::Continue) + } + (Some((c, r)), Input::Point(at)) => { + let k = self.kind(s, c, r, at); + add_dim(s, k)?; + Ok(Step::Done) + } + (Some(_), Input::Keyword(k)) => { + self.text.keyword(&k); + Ok(Step::Continue) + } + (_, Input::Enter) => Ok(Step::Cancel), + _ => Ok(Step::Continue), + } + } + fn preview(&self, s: &Session, c: Vec2) -> Vec { + self.target.map(|(ctr, r)| vec![self.kind(s, ctr, r, c)]).unwrap_or_default() + } +} diff --git a/crates/engine/src/cmd/annotate_tests.rs b/crates/engine/src/cmd/annotate/tests.rs similarity index 100% rename from crates/engine/src/cmd/annotate_tests.rs rename to crates/engine/src/cmd/annotate/tests.rs diff --git a/crates/engine/src/cmd/blocks.rs b/crates/engine/src/cmd/blocks.rs index 15d75da14..ca4cfffec 100644 --- a/crates/engine/src/cmd/blocks.rs +++ b/crates/engine/src/cmd/blocks.rs @@ -19,6 +19,7 @@ use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, St mod attedit; mod burst; +mod description; mod dyngrip; mod extract; pub(crate) mod refedit; @@ -28,12 +29,12 @@ pub fn specs() -> Vec { CommandSpec::new("block", "Make...", run_block) .menu(&["Draw", "Block", "Make..."]) .alias(&["b", "bmake"]) - .params("{name, base: [x,y], handles?, keep?: \"convert\"|\"retain\"|\"delete\" (default \"convert\"), description?, annotative?}") + .params("{name, base: [x,y], handles?, keep?: \"convert\"|\"retain\"|\"delete\" (default \"convert\"), description? (up to 255 characters, one line), annotative?}") .interactive(|_| Ok(Box::new(BlockM::default()))), // -BLOCK erases the selected objects once the block is defined. CommandSpec::new("-block", "Create Block", run_dash_block) .alias(&["-b"]) - .params("{name, base: [x,y], handles?, keep?: \"delete\"|\"retain\"|\"convert\" (default \"delete\"), description?}") + .params("{name, base: [x,y], handles?, keep?: \"delete\"|\"retain\"|\"convert\" (default \"delete\"), description? (up to 255 characters, one line)}") .interactive(|_| Ok(Box::new(BlockM { dash: true, ..BlockM::default() }))), CommandSpec::new("insert", "Block...", run_insert) .menu(&["Insert", "Block..."]) @@ -79,6 +80,7 @@ pub fn specs() -> Vec { blockreplace::blockreplace_spec(), extract::dataextraction_spec(), extract::eattext_spec(), + description::description_spec(), ] .into_iter() .chain(attribs::attdisp_menu_specs()) @@ -266,7 +268,9 @@ fn run_block(s: &mut Session, p: &Value) -> Result { let base = point_param(p, "base").unwrap_or(Vec2::ZERO); let hs = targets(s, p)?; let keep = keep_param("block", p, Keep::Convert)?; - let r = make_block(s, &name, base, &hs, keep, str_param(p, "description").unwrap_or(""))?; + let description = str_param(p, "description").unwrap_or(""); + description::check_text("block", description)?; + let r = make_block(s, &name, base, &hs, keep, description)?; // An annotative block: its references (the converted selection too) are annotative. if bool_or(p, "annotative", false) { let d = s.doc_mut()?; diff --git a/crates/engine/src/cmd/blocks/bedit.rs b/crates/engine/src/cmd/blocks/bedit.rs index f11bb3091..5b5d0b1cc 100644 --- a/crates/engine/src/cmd/blocks/bedit.rs +++ b/crates/engine/src/cmd/blocks/bedit.rs @@ -85,7 +85,7 @@ pub(crate) fn refused_in_editor(s: &Session, id: &str) -> Option { } /// The block a block reference refers to. -fn block_of(d: &Drawing, h: Handle) -> Option { +pub(super) fn block_of(d: &Drawing, h: Handle) -> Option { match d.entity(h).map(|e| &e.kind) { Some(EntityKind::Insert(i)) => Some(dynamic_definition(d, &i.block).unwrap_or_else(|| i.block.clone())), _ => None, @@ -129,7 +129,7 @@ fn open(s: &mut Session, name: &str, create: bool) -> Result { } /// The one block reference selected, if exactly one is. -fn selected_reference(s: &Session) -> Option { +pub(super) fn selected_reference(s: &Session) -> Option { let d = s.doc().ok()?; let refs: Vec = s.selection().iter().filter_map(|h| block_of(d, *h)).collect(); match refs.as_slice() { diff --git a/crates/engine/src/cmd/blocks/description.rs b/crates/engine/src/cmd/blocks/description.rs new file mode 100644 index 000000000..96ad4df49 --- /dev/null +++ b/crates/engine/src/cmd/blocks/description.rs @@ -0,0 +1,188 @@ +//! Block Description (Modify > Object > Block Description...): changes the description of a +//! block definition. In the Block Editor it changes the edited block's description, which BSAVE +//! saves into the drawing as it saves the block's objects. + +use std::sync::Arc; + +use cadcraft_doc::Block; +use serde_json::{Value, json}; + +use super::bedit::selected_reference; +use crate::cmd::{CommandSpec, bad, has_doc, str_param}; +use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step}; + +/// The longest description a block takes, in characters. +const MAX_LEN: usize = 255; + +pub(super) fn description_spec() -> CommandSpec { + CommandSpec::new("blocks.description", "Block Description...", run) + .menu(&["Modify", "Object", "Block Description..."]) + .params("{name? (default the block the Block Editor is editing, else the one selected block reference's block), description (up to 255 characters, one line; \"\" clears it)} → {block, description}") + .enabled(has_doc) + .interactive(|_| Ok(Box::new(DescriptionM::default()))) +} + +/// The block the Block Editor is editing, when it is open on a block (not on the drawing itself). +fn edited_block(s: &Session) -> Option { + s.state().ok()?.block_editor().filter(|n| !n.eq_ignore_ascii_case(crate::blockedit::CURRENT_DRAWING)).map(str::to_string) +} + +/// The block that a call without a name is about: the edited block, else the selected reference's. +fn default_block(s: &Session) -> Option { + edited_block(s).or_else(|| selected_reference(s)) +} + +/// Block `name` as the drawing spells it, when its description can be changed: while the Block +/// Editor edits a block, only that block's. The block the editor has started and not saved yet has +/// no definition, and is named as the editor names it. +fn check_block(s: &Session, name: &str) -> Result { + let name = name.trim(); + if let Some(edited) = edited_block(s) + && !edited.eq_ignore_ascii_case(name) + { + return Err(bad( + "blocks.description", + format!("The Block Editor is editing \"{edited}\"; close it (BCLOSE) to change another block's description."), + )); + } + let d = s.doc()?; + match d.block(name) { + Some(b) if b.xref_path.is_some() => { + Err(bad("blocks.description", format!("\"{}\" is an external reference; its description comes from its drawing.", b.name))) + } + Some(b) if b.anonymous || b.name.starts_with('*') => { + Err(bad("blocks.description", format!("\"{}\" is an anonymous block made by another object; it has no description to change.", b.name))) + } + Some(b) => Ok(b.name.clone()), + None => match edited_block(s) { + Some(e) if e.eq_ignore_ascii_case(name) => Ok(e), + _ => Err(bad("blocks.description", format!("Block \"{name}\" not found."))), + }, + } +} + +/// A description a block can take (command `cmd`'s): one line of up to [`MAX_LEN`] characters. +pub(super) fn check_text(cmd: &str, text: &str) -> Result<()> { + if text.contains(['\n', '\r']) { + return Err(bad(cmd, "the description must be one line")); + } + if text.chars().count() > MAX_LEN { + return Err(bad(cmd, format!("the description can be up to {MAX_LEN} characters"))); + } + Ok(()) +} + +/// Block `name`'s definition: the drawing's, else, for the block the Block Editor edits, the one +/// outside the editor (a block saved under a new name with BSAVEAS is not in the editor's tables). +fn definition(s: &Session, name: &str) -> Option> { + let st = s.state().ok()?; + st.doc.block(name).or_else(|| st.block_edit.as_ref().and_then(|be| be.host.doc.block(name))).cloned() +} + +/// The description of block `name` (as [`check_block`] gave it), or "" for one not yet saved. +fn current(s: &Session, name: &str) -> String { + definition(s, name).map(|b| b.description.clone()).unwrap_or_default() +} + +/// Give block `name` (as [`check_block`] gave it) the description `text`. +fn set(s: &mut Session, name: &str, text: &str) -> Result { + check_text("blocks.description", text)?; + let outside = definition(s, name); + let doc = s.doc_mut()?; + match doc.blocks.iter_mut().find(|(k, _)| k.eq_ignore_ascii_case(name)) { + Some((_, b)) => { + if b.description != text { + Arc::make_mut(b).description = text.to_string(); + } + } + // The block the Block Editor edits and the editor's tables don't hold (one it started, or + // one BSAVEAS made): BSAVE saves it with this description. + None => { + let mut b = outside.map(|b| (*b).clone()).unwrap_or_else(|| Block::new(name)); + b.description = text.to_string(); + doc.blocks.insert(name.to_string(), Arc::new(b)); + } + } + if s.state()?.block_editor().is_some() { + s.echo(format!("Description of block \"{name}\" changed; BSAVE saves it into the drawing.")); + } else { + s.echo(format!("Description of block \"{name}\" changed.")); + } + Ok(json!({ "block": name, "description": text })) +} + +fn run(s: &mut Session, p: &Value) -> Result { + let name = match str_param(p, "name") { + Some(n) => n.to_string(), + None => default_block(s) + .ok_or_else(|| bad("blocks.description", "`name` is required (or open the block in the Block Editor, or select one block reference)"))?, + }; + let text = str_param(p, "description").ok_or_else(|| bad("blocks.description", "`description` is required"))?; + let name = check_block(s, &name)?; + set(s, &name, text) +} + +// ---------------- interactive ---------------- + +/// Block Description: the block (the edited one, the selected reference's, or a name typed or a +/// reference picked), then its new description; Enter keeps the description it has. +#[derive(Default)] +struct DescriptionM { + /// The block whose description is asked for, once known. + block: Option, +} + +impl Interactive for DescriptionM { + fn name(&self) -> &'static str { + "BLOCKS.DESCRIPTION" + } + fn begin(&mut self, s: &mut Session) -> Result { + if let Some(name) = default_block(s) { + self.block = Some(check_block(s, &name)?); + } + Ok(Step::Continue) + } + fn prompt(&self, s: &Session) -> Prompt { + match &self.block { + Some(name) => { + let cur = current(s, name); + let p = Prompt::new(format!("Enter description for block \"{name}\""), Accept::TEXT); + if cur.is_empty() { p } else { p.default(cur) } + } + None => Prompt::new( + "Enter block name or select a block reference", + Accept { point: true, number: false, text: true, select: true, enter: true }, + ), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + if let Some(name) = self.block.clone() { + return match i { + Input::Text(t) => set(s, &name, t.trim()).map(|_| Step::Done), + Input::Enter => Ok(Step::Done), + _ => Ok(Step::Continue), + }; + } + match i { + Input::Text(t) | Input::Keyword(t) => { + self.block = Some(check_block(s, &t)?); + Ok(Step::Continue) + } + Input::Pick(hs) => { + let picked = { + let d = s.doc()?; + hs.iter().find_map(|h| super::bedit::block_of(d, *h)) + }; + match picked { + Some(n) => { + self.block = Some(check_block(s, &n)?); + Ok(Step::Continue) + } + None => Err(EngineError::Other("Select a block reference.".into())), + } + } + Input::Enter => Ok(Step::Done), + _ => Ok(Step::Continue), + } + } +} diff --git a/crates/engine/src/cmd/constraints.rs b/crates/engine/src/cmd/constraints.rs index b37ff9882..5b685dd45 100644 --- a/crates/engine/src/cmd/constraints.rs +++ b/crates/engine/src/cmd/constraints.rs @@ -1200,5 +1200,4 @@ pub fn after_command(s: &mut Session, before: Option<&Arc>, ok: bool) { } #[cfg(test)] -#[path = "constraints_tests.rs"] mod tests; diff --git a/crates/engine/src/cmd/constraints_tests.rs b/crates/engine/src/cmd/constraints/tests.rs similarity index 100% rename from crates/engine/src/cmd/constraints_tests.rs rename to crates/engine/src/cmd/constraints/tests.rs diff --git a/crates/engine/src/cmd/draw.rs b/crates/engine/src/cmd/draw.rs index 6f4b77c84..52bf23ace 100644 --- a/crates/engine/src/cmd/draw.rs +++ b/crates/engine/src/cmd/draw.rs @@ -16,6 +16,23 @@ use super::{ use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step, snap}; +mod circle; +mod ellipse; +mod line_pline; +mod point; +mod polygon; +mod spline; +mod text; + +use circle::{CircleM, run_arc, run_circle}; +pub(crate) use ellipse::ellipse_machine; +use ellipse::{EllipseM, run_ellipse}; +use line_pline::{LineM, PlineM, run_line, run_pline}; +use point::{DonutM, PointM, RayM, run_donut, run_point, run_ray}; +use polygon::{PolygonM, run_polygon}; +use spline::{SplineM, run_spline}; +use text::{MTextM, TextM, run_mtext, run_text}; + /// Every draw command's spec: JSON parameters, menu path, aliases and the interactive prompt /// machine each command starts. pub fn specs() -> Vec { @@ -104,1531 +121,5 @@ fn added(h: cadcraft_doc::Handle) -> Result { Ok(json!({ "handle": h.hex() })) } -// ---------------- JSON forms ---------------- - -fn run_line(s: &mut Session, p: &Value) -> Result { - let pts = points_param(p, "points").ok_or_else(|| bad("line", "`points` [[x,y],...] (2 or more) is required"))?; - if pts.len() < 2 { - return Err(bad("line", "need at least 2 points")); - } - // A repeated point would make a zero-length line. - let pts = distinct_points(&pts); - if pts.len() < 2 { - return Err(bad("line", "need at least 2 different points")); - } - let mut hs = Vec::new(); - for w in pts.windows(2) { - if let [a, b] = w { - hs.push(s.add_entity(line(*a, *b))?.hex()); - } - } - if bool_or(p, "closed", false) - && pts.len() > 2 - && let (Some(a), Some(b)) = (pts.last(), pts.first()) - && !a.near(*b, 1e-12) - { - hs.push(s.add_entity(line(*a, *b))?.hex()); - } - Ok(json!({ "handles": hs })) -} - -fn vertex_value(v: &Value) -> Option { - if let Some(p) = point_value(v) { - return Some(PolyVertex::new(p)); - } - let p = point_value(v.get("p")?)?; - Some(PolyVertex { p, bulge: f64_or(v, "bulge", 0.0), start_width: f64_or(v, "startWidth", 0.0), end_width: f64_or(v, "endWidth", 0.0) }) -} - -fn run_pline(s: &mut Session, p: &Value) -> Result { - let raw = p.get("vertices").or_else(|| p.get("points")).and_then(Value::as_array); - for v in raw.into_iter().flatten() { - size_param("pline", v, "startWidth", true)?; - size_param("pline", v, "endWidth", true)?; - } - let vs: Vec = raw.map(|a| a.iter().filter_map(vertex_value).collect()).unwrap_or_default(); - if vs.len() < 2 { - return Err(bad("pline", "`vertices` needs at least 2 points")); - } - let width = size_param("pline", p, "width", true)?.unwrap_or(0.0); - let mut k = lwpoly(vs, bool_or(p, "closed", false)); - if let EntityKind::LwPolyline(pl) = &mut k { - pl.const_width = width; - } - added(s.add_entity(k)?) -} - -fn run_circle(s: &mut Session, p: &Value) -> Result { - let c = if let Some(center) = point_param(p, "center") { - // Checked before `Circle::new`, which would turn a negative radius positive. - let r = match size_param("circle", p, "radius", false)? { - Some(r) => r, - None => size_param("circle", p, "diameter", false)?.ok_or_else(|| bad("circle", "`radius` or `diameter` is required"))? / 2.0, - }; - Circle::new(center, r) - } else if let (Some(a), Some(b)) = (point_param(p, "p1"), point_param(p, "p2")) { - match point_param(p, "p3") { - Some(c3) => Circle::from_3_points(a, b, c3).ok_or_else(|| bad("circle", "points are collinear"))?, - None => Circle::from_2_points(a, b), - } - } else { - return Err(bad("circle", "give {center, radius} or {p1, p2[, p3]}")); - }; - if !(c.radius > 1e-12 && c.radius.is_finite()) { - return Err(bad("circle", "radius must be positive")); - } - added(s.add_entity(circle(c.center, c.radius))?) -} - -fn run_arc(s: &mut Session, p: &Value) -> Result { - let a = if let (Some(a), Some(b), Some(c)) = (point_param(p, "p1"), point_param(p, "p2"), point_param(p, "p3")) { - Arc::from_3_points(a, b, c).ok_or_else(|| bad("arc", "points are collinear"))? - } else if let (Some(st), Some(c), Some(e)) = (point_param(p, "start"), point_param(p, "center"), point_param(p, "end")) { - Arc::from_start_center_end(st, c, e) - } else if let Some(c) = point_param(p, "center") { - let r = f64_req("arc", p, "radius")?; - // Checked before `Arc::new`, which would turn a negative radius positive (zero is - // refused below). - if r < 0.0 { - return Err(bad("arc", "`radius` must be positive")); - } - Arc::new(c, r, f64_req("arc", p, "start")?.to_radians(), f64_req("arc", p, "end")?.to_radians()) - } else { - return Err(bad("arc", "give {p1,p2,p3}, {start,center,end} or {center,radius,start,end}")); - }; - // Zero or non-finite radius (e.g. start == center): refuse, like the interactive ARC and the - // arc.* variants, before anything is added (#85). - let a = super::curves::finite_arc(a).ok_or_else(|| bad("arc", "these values do not define an arc"))?; - added(s.add_entity(arc(&a))?) -} - -fn run_polygon(s: &mut Session, p: &Value) -> Result { - // A `sides` that is not a whole number (e.g. -3) is refused, not replaced by the default 4. - let n = match p.get("sides") { - None | Some(Value::Null) => 4, - Some(v) => v.as_u64().and_then(|n| usize::try_from(n).ok()).unwrap_or(0), - }; - if !(3..=1024).contains(&n) { - return Err(bad("polygon", "sides must be 3..1024")); - } - let vs = if let Some(e) = points_param(p, "edge") { - let (Some(a), Some(b)) = (e.first(), e.get(1)) else { return Err(bad("polygon", "edge needs 2 points")) }; - if a.near(*b, 1e-12) { - return Err(bad("polygon", "edge endpoints must differ")); - } - polygon_from_edge(*a, *b, n) - } else { - let c = point_req("polygon", p, "center")?; - let r = size_param("polygon", p, "radius", false)?.ok_or_else(|| bad("polygon", "`radius` (number) is required"))?; - let inscribed = bool_or(p, "inscribed", true); - // Without an angle, the bottom edge is horizontal, as with a typed radius. - let rp = match p.get("angle").and_then(Value::as_f64).filter(|a| a.is_finite()) { - Some(a) => Vec2::polar(c, r, a.to_radians()), - None => polygon_typed_radius_point(c, n, r, inscribed), - }; - polygon_vertices(c, n, rp, inscribed) - }; - added(s.add_entity(lwpoly(vs, true))?) -} - -fn run_ellipse(s: &mut Session, p: &Value) -> Result { - if p.get("isocircle").is_some() { - return super::settings::iso::run_isocircle(s, p); - } - let c = point_req("ellipse", p, "center")?; - let m = point_req("ellipse", p, "major")?; - let ratio = f64_req("ellipse", p, "ratio")?; - if !(ratio > 0.0 && ratio <= 1.0) || m.len() < 1e-12 { - return Err(bad("ellipse", "ratio must be in (0, 1] and major non-zero")); - } - let start = f64_or(p, "start", 0.0).to_radians(); - let end = f64_or(p, "end", 360.0).to_radians(); - added(s.add_entity(EntityKind::Ellipse(cadcraft_doc::Ellipse { center: v3(c), major: v3(m), ratio, start, end }))?) -} - -fn run_point(s: &mut Session, p: &Value) -> Result { - let pts = match point_param(p, "at") { - Some(a) => vec![a], - None => points_param(p, "points").ok_or_else(|| bad("point", "`at` or `points` is required"))?, - }; - let mut hs = Vec::new(); - for q in pts { - hs.push(s.add_entity(EntityKind::Point(Point { p: v3(q), angle: 0.0 }))?.hex()); - } - Ok(json!({ "handles": hs })) -} - -fn run_ray(s: &mut Session, p: &Value) -> Result { - let b = point_req("ray", p, "base")?; - let t = point_req("ray", p, "through")?; - let dir = (t - b).normalized(); - if dir == Vec2::ZERO { - return Err(bad("ray", "through point equals base")); - } - added(s.add_entity(EntityKind::Ray(RayLine { base: v3(b), dir: v3(dir) }))?) -} - -fn run_spline(s: &mut Session, p: &Value) -> Result { - if p.get("object").is_some() { - return super::spline_opts::run_object(s, p); - } - let sp = if let Some(f) = points_param(p, "fit") { - if distinct_points(&f).len() < 2 { - return Err(bad("spline", "need 2+ different fit points")); - } - Spline::fit_with(&f, bool_or(p, "closed", false), super::spline_opts::json_options(s, p)?) - } else if let Some(c) = points_param(p, "control") { - if c.len() < 2 || c.len() > super::curves::MAX_GEN { - return Err(bad("spline", "need 2+ control points")); - } - // Degree 1..=10 as at the Degree prompt, `spline.cv` and DXF input (tessellation grows - // with its square). - Spline::from_control(c, p.get("degree").and_then(Value::as_u64).unwrap_or(3).clamp(1, 10) as usize) - } else { - return Err(bad("spline", "`fit` or `control` points are required")); - }; - added(s.add_entity(EntityKind::Spline(sp))?) -} - -fn donut(center: Vec2, inside: f64, outside: f64) -> EntityKind { - let r = (inside.abs() + outside.abs()) / 4.0; - let w = (outside.abs() - inside.abs()).abs() / 2.0; - let mut k = lwpoly(vec![PolyVertex::with_bulge(center - Vec2::X * r, 1.0), PolyVertex::with_bulge(center + Vec2::X * r, 1.0)], true); - if let EntityKind::LwPolyline(pl) = &mut k { - pl.const_width = w; - } - k -} - -fn run_donut(s: &mut Session, p: &Value) -> Result { - let c = point_req("donut", p, "center")?; - let inside = size_param("donut", p, "inside", true)?.unwrap_or(0.5); - let outside = size_param("donut", p, "outside", false)?.unwrap_or(1.0); - if inside > outside { - return Err(bad("donut", "`inside` must not be larger than `outside`")); - } - added(s.add_entity(donut(c, inside, outside))?) -} - -/// Justification code → (halign, valign). -pub(crate) fn justify(j: &str) -> Option<(HAlign, VAlign)> { - Some(match j.to_ascii_uppercase().as_str() { - "L" | "LEFT" => (HAlign::Left, VAlign::Baseline), - "C" | "CENTER" => (HAlign::Center, VAlign::Baseline), - "R" | "RIGHT" => (HAlign::Right, VAlign::Baseline), - "A" | "ALIGN" => (HAlign::Aligned, VAlign::Baseline), - "M" | "MIDDLE" => (HAlign::Middle, VAlign::Baseline), - "F" | "FIT" => (HAlign::Fit, VAlign::Baseline), - "TL" => (HAlign::Left, VAlign::Top), - "TC" => (HAlign::Center, VAlign::Top), - "TR" => (HAlign::Right, VAlign::Top), - "ML" => (HAlign::Left, VAlign::Middle), - "MC" => (HAlign::Center, VAlign::Middle), - "MR" => (HAlign::Right, VAlign::Middle), - "BL" => (HAlign::Left, VAlign::Bottom), - "BC" => (HAlign::Center, VAlign::Bottom), - "BR" => (HAlign::Right, VAlign::Bottom), - _ => return None, - }) -} - -pub(crate) fn make_text(s: &Session, at: Vec2, value: &str, height: f64, rotation: f64, j: (HAlign, VAlign)) -> EntityKind { - let style = s.doc().map(|d| d.header.str("TEXTSTYLE", "Standard")).unwrap_or_else(|_| "Standard".into()); - let (h, v) = j; - let left = h == HAlign::Left && v == VAlign::Baseline; - EntityKind::Text(Text { - insert: v3(at), - align_pt: if left { None } else { Some(v3(at)) }, - height, - value: value.into(), - rotation, - width_factor: 1.0, - oblique: 0.0, - style, - halign: h, - valign: v, - }) -} - -fn run_text(s: &mut Session, p: &Value) -> Result { - let at = point_req("text", p, "at")?; - let text = str_param(p, "text").ok_or_else(|| bad("text", "`text` is required"))?; - let height = f64_or(p, "height", s.doc()?.header.f64("TEXTSIZE", 0.2)); - if height <= 0.0 { - return Err(bad("text", "height must be positive")); - } - let j = justify(str_param(p, "justify").unwrap_or("L")).ok_or_else(|| bad("text", "unknown justification"))?; - let k = make_text(s, at, text, height, f64_or(p, "rotation", 0.0).to_radians(), j); - added(s.add_entity(k)?) -} - -fn run_mtext(s: &mut Session, p: &Value) -> Result { - let at = point_req("mtext", p, "at")?; - let text = str_param(p, "text").ok_or_else(|| bad("mtext", "`text` is required"))?; - let height = f64_or(p, "height", s.doc()?.header.f64("TEXTSIZE", 0.2)); - if height <= 0.0 { - return Err(bad("mtext", "height must be positive")); - } - let style = match str_param(p, "style") { - Some(n) => s.doc()?.text_style(n).map(|t| t.name.clone()).ok_or_else(|| bad("mtext", format!("no text style \"{n}\"")))?, - None => s.doc()?.header.str("TEXTSTYLE", "Standard"), - }; - let attach = match str_param(p, "justify") { - Some(j) => super::mtext_opts::attach_code(j).ok_or_else(|| bad("mtext", "`justify` must be TL, TC, TR, ML, MC, MR, BL, BC or BR"))?, - None => p.get("attach").and_then(Value::as_u64).unwrap_or(1).clamp(1, 9) as u8, - }; - let width = f64_or(p, "width", 0.0).max(0.0); - let k = EntityKind::MText(MText { - insert: v3(at), - height, - width, - attach, - rotation: f64_or(p, "rotation", 0.0).to_radians(), - style, - contents: super::mtext_opts::json_paragraphs(p, text.replace('\n', "\\P"), height)?, - line_spacing: f64_or(p, "lineSpacing", 1.0), - line_spacing_exact: bool_or(p, "lineSpacingExact", false), - columns: super::mtext_opts::json_columns(p, height, width)?, - }); - added(s.add_entity(k)?) -} - -// ---------------- interactive ---------------- - -#[derive(Default)] -struct LineM { - pts: Vec, - handles: Vec, - /// A first point picked as a deferred tangent/perpendicular, resolved by the next point. - first: Option, -} - -impl LineM { - /// Draw the first segment from the deferred first point to `end`. - fn resolve_first(&mut self, s: &mut Session, first: snap::Deferred, end: snap::LineEnd) -> Result { - let Some((a, b)) = snap::resolve_deferred(snap::LineEnd::Deferred(first), end) else { - let what = if first.mode == snap::mode::TAN { "tangent" } else { "perpendicular" }; - return Err(crate::EngineError::Other(format!("No {what} line through that point; pick another point."))); - }; - self.handles.push(s.add_entity(line(a, b))?); - self.pts = vec![a, b]; - self.first = None; - s.last_point = b; - Ok(Step::Continue) - } -} - -impl Interactive for LineM { - fn name(&self) -> &'static str { - "LINE" - } - fn prompt(&self, _s: &Session) -> Prompt { - match self.pts.len() { - 0 if self.first.is_some() => Prompt::new("Specify next point", Accept::POINT).kw(&["Undo"]).deferred(), - 0 => Prompt::new("Specify first point", Accept::POINT).deferred(), - 1 | 2 => Prompt::new("Specify next point", Accept::POINT).kw(&["Undo"]).base_opt(self.pts.last().copied()), - _ => Prompt::new("Specify next point", Accept::POINT).kw(&["Close", "Undo"]).base_opt(self.pts.last().copied()), - } - } - fn input(&mut self, s: &mut Session, i: Input) -> Result { - if self.pts.is_empty() - && let Some(first) = self.first - { - match i { - Input::Point(p) => { - // A deferred TAN first point with a typed `@d return self.resolve_first(s, first, snap::LineEnd::Deferred(d)), - _ => {} - } - } - match i { - Input::Deferred(d) if self.pts.is_empty() => { - self.first = Some(d); - Ok(Step::Continue) - } - // A deferred pick with a known last point (not offered by the prompt, but harmless). - Input::Deferred(d) => match self.pts.last().copied() { - Some(last) => match snap::resolve_deferred(snap::LineEnd::Point(last), snap::LineEnd::Deferred(d)) { - Some((_, p)) => self.input(s, Input::Point(p)), - None => Err(crate::EngineError::Other("No tangent or perpendicular from the last point.".into())), - }, - None => Ok(Step::Continue), - }, - Input::Point(p) => { - if let Some(last) = self.pts.last().copied() { - if last.near(p, 1e-12) { - return Ok(Step::Continue); - } - self.handles.push(s.add_entity(line(last, p))?); - } - self.pts.push(p); - Ok(Step::Continue) - } - Input::Keyword(k) if k == "Undo" => { - if self.first.take().is_some() { - return Ok(Step::Continue); - } - if let Some(h) = self.handles.pop() { - s.doc_mut()?.remove_entity(h); - } - self.pts.pop(); - Ok(Step::Continue) - } - Input::Keyword(k) if k == "Close" => { - if let (Some(a), Some(b)) = (self.pts.last().copied(), self.pts.first().copied()) { - s.add_entity(line(a, b))?; - } - Ok(Step::Done) - } - Input::Enter => Ok(Step::Done), - _ => Err(crate::EngineError::Other("Point or option keyword required.".into())), - } - } - fn preview(&self, s: &Session, c: Vec2) -> Vec { - if let Some(first) = self.first { - // Over a second curve, show the tangent-to-tangent line the click would draw. - let end = s.cursor_deferred.map_or(snap::LineEnd::Point(c), snap::LineEnd::Deferred); - return snap::resolve_deferred(snap::LineEnd::Deferred(first), end).map(|(a, b)| vec![line(a, b)]).unwrap_or_default(); - } - self.pts.last().map(|l| vec![line(*l, c)]).unwrap_or_default() - } -} - -#[derive(Default)] -struct PlineM { - verts: Vec, - arc_mode: bool, - /// Total width at the start and end of the next segment (Width / Halfwidth answers). - start_w: f64, - end_w: f64, - handle: Option, - /// 1 = asking the starting width, 2 = the ending width. - asking_width: u8, - /// The widths are being asked as half-widths (centre line to edge). - half: bool, - /// An option asking further questions (arc mode's Angle, CEnter…, line mode's Length). - ask: Option, -} - -impl PlineM { - fn entity(&self, closed: bool) -> EntityKind { - let mut k = lwpoly(self.verts.clone(), closed); - if let EntityKind::LwPolyline(pl) = &mut k { - // Every segment the same uniform width: store it as the constant width, as before. - let segs = if closed { pl.vertices.len() } else { pl.vertices.len().saturating_sub(1) }; - let w = pl.vertices.first().map_or(0.0, |v| v.start_width); - if pl.vertices.iter().take(segs).all(|v| v.start_width == w && v.end_width == w) { - pl.const_width = w; - for v in &mut pl.vertices { - (v.start_width, v.end_width) = (0.0, 0.0); - } - } - } - k - } - /// The next segment, starting at the last vertex, takes the requested widths (#104); the - /// segments after it are uniform at the ending width, as in AutoCAD. - fn begin_segment(&mut self) { - if let Some(last) = self.verts.last_mut() { - (last.start_width, last.end_width) = (self.start_w, self.end_w); - self.start_w = self.end_w; - } - } - fn sync(&mut self, s: &mut Session, closed: bool) -> Result<()> { - if self.verts.len() < 2 { - if let Some(h) = self.handle.take() { - s.doc_mut()?.remove_entity(h); - } - return Ok(()); - } - let k = self.entity(closed); - match self.handle { - Some(h) => { - s.doc_mut()?.modify_entity(h, |e| e.kind = k)?; - } - None => self.handle = Some(s.add_entity(k)?), - } - Ok(()) - } - /// The direction the polyline runs in at its last vertex (+X before the second vertex). - fn tangent(&self) -> Vec2 { - let n = self.verts.len(); - let Some(last) = self.verts.last() else { return Vec2::X }; - if n >= 2 { - let prev = self.verts.get(n - 2).copied().unwrap_or_default(); - match bulge_to_arc(prev.p, last.p, prev.bulge) { - Some((a, ccw)) => { - let r = (last.p - a.center).perp().normalized(); - if ccw { r } else { -r } - } - None => (last.p - prev.p).normalized(), - } - } else { - Vec2::X - } - } - /// Bulge for a tangent-continuing arc from the last vertex to `p`. - fn tangent_bulge(&self, p: Vec2) -> f64 { - let Some(last) = self.verts.last() else { return 0.0 }; - let dir = self.tangent(); - let chord = p - last.p; - // Included angle = 2 × angle between tangent and chord. - let ang = dir.cross(chord).atan2(dir.dot(chord)); - arc_to_bulge(2.0 * ang) - } -} - -impl Interactive for PlineM { - fn name(&self) -> &'static str { - "PLINE" - } - fn prompt(&self, s: &Session) -> Prompt { - if self.asking_width > 0 { - let unit = if self.half { "half-width" } else { "width" }; - let (which, w) = if self.asking_width == 1 { ("starting", self.start_w) } else { ("ending", self.end_w) }; - let shown = if self.half { w / 2.0 } else { w }; - return Prompt::new(format!("Specify {which} {unit}"), Accept::NUMBER).default(format!("{shown:.4}")); - } - let base = self.verts.last().map(|v| v.p); - if let (Some(a), Some(st)) = (self.ask, base) { - return a.prompt(st, self.tangent(), s); - } - match (self.verts.len(), self.arc_mode) { - (0, _) => Prompt::new("Specify start point", Accept::POINT), - (n, false) => { - let mut kw = vec!["Arc", "Halfwidth", "Length", "Undo", "Width"]; - if n >= 2 { - kw.insert(1, "Close"); - } - Prompt::new("Specify next point", Accept::POINT).kw(&kw).base_opt(base) - } - (_, true) => Prompt::new("Specify endpoint of arc", Accept::POINT) - .kw(&["Angle", "CEnter", "CLose", "Direction", "Halfwidth", "Line", "Radius", "Second pt", "Undo", "Width"]) - .base_opt(base), - } - } - fn input(&mut self, s: &mut Session, i: Input) -> Result { - if self.asking_width > 0 { - match i { - Input::Text(t) => { - let w = number(&t).filter(|w| w.is_finite()).ok_or_else(|| crate::EngineError::Other("Requires a distance.".into()))?; - // Halfwidth is centre line to edge: the total width is twice the answer (#83). - let w = w.max(0.0) * if self.half { 2.0 } else { 1.0 }; - if self.asking_width == 1 { - self.start_w = w; - } else { - self.end_w = w; - } - } - Input::Enter => {} - _ => return Ok(Step::Continue), - } - if self.asking_width == 1 { - // The ending width defaults to the starting width. - self.end_w = self.start_w; - } - self.asking_width = if self.asking_width == 1 { 2 } else { 0 }; - return Ok(Step::Continue); - } - if let (Some(a), Some(st)) = (self.ask, self.verts.last().map(|v| v.p)) { - match a.input(s, i, st, self.tangent())? { - Outcome::Ask(next) => self.ask = Some(next), - Outcome::Back => self.ask = None, - Outcome::Segment(end, bulge) => { - self.ask = None; - self.begin_segment(); - if let Some(last) = self.verts.last_mut() { - last.bulge = bulge; - } - self.verts.push(PolyVertex::new(end)); - self.sync(s, false)?; - } - } - return Ok(Step::Continue); - } - match i { - Input::Point(p) => { - if self.verts.is_empty() { - s.echo(format!("Current line-width is {:.4}", self.start_w)); - } - self.begin_segment(); - if self.arc_mode && !self.verts.is_empty() { - let b = self.tangent_bulge(p); - if let Some(last) = self.verts.last_mut() { - last.bulge = b; - } - } - self.verts.push(PolyVertex::new(p)); - self.sync(s, false)?; - Ok(Step::Continue) - } - Input::Keyword(k) => match k.as_str() { - "Arc" => { - self.arc_mode = true; - Ok(Step::Continue) - } - "Line" => { - self.arc_mode = false; - Ok(Step::Continue) - } - "Close" | "CLose" => { - if self.arc_mode - && let Some(first) = self.verts.first().map(|v| v.p) - { - let b = self.tangent_bulge(first); - if let Some(last) = self.verts.last_mut() { - last.bulge = b; - } - } - // The closing segment starts at the last vertex. - self.begin_segment(); - self.sync(s, true)?; - Ok(Step::Done) - } - "Undo" => { - self.verts.pop(); - if let Some(l) = self.verts.last_mut() { - l.bulge = 0.0; - (l.start_width, l.end_width) = (0.0, 0.0); - } - self.sync(s, false)?; - Ok(Step::Continue) - } - "Width" | "Halfwidth" => { - self.half = k == "Halfwidth"; - self.asking_width = 1; - Ok(Step::Continue) - } - k => { - match PlineAsk::from_keyword(k, self.arc_mode) { - Some(a) if !self.verts.is_empty() => self.ask = Some(a), - _ => return Err(crate::EngineError::Other("Point or option keyword required.".into())), - } - Ok(Step::Continue) - } - }, - Input::Enter => Ok(Step::Done), - _ => Err(crate::EngineError::Other("Point or option keyword required.".into())), - } - } - fn preview(&self, _s: &Session, c: Vec2) -> Vec { - let Some(last) = self.verts.last() else { return Vec::new() }; - if let Some(a) = self.ask { - return a - .preview(last.p, self.tangent(), c) - .map(|(e, b)| vec![lwpoly(vec![PolyVertex::with_bulge(last.p, b), PolyVertex::new(e)], false)]) - .unwrap_or_default(); - } - if self.arc_mode { - let b = self.tangent_bulge(c); - vec![lwpoly(vec![PolyVertex::with_bulge(last.p, b), PolyVertex::new(c)], false)] - } else { - vec![line(last.p, c)] - } - } -} - -#[derive(Default)] -struct CircleM { - mode: u8, // 0 center-radius, 1 = 3P, 2 = 2P, 3 = TTR - pts: Vec, - diameter: bool, - /// The `Ttr` option hands over to the tangent-circle machine. - delegate: Option>, -} - -impl Interactive for CircleM { - fn name(&self) -> &'static str { - "CIRCLE" - } - fn prompt(&self, s: &Session) -> Prompt { - if let Some(d) = &self.delegate { - return d.prompt(s); - } - let n = self.pts.len(); - let last_r = s.doc().map(|d| d.header.f64("CIRCLERAD", 0.0)).unwrap_or(0.0); - match (self.mode, n) { - (0, 0) => Prompt::new("Specify center point for circle", Accept::POINT).kw(&["3P", "2P", "Ttr (tan tan radius)"]), - (0, _) if self.diameter => { - Prompt::new("Specify diameter of circle", Accept::POINT_OR_NUMBER).base_opt(self.pts.first().copied()).distance() - } - (0, _) => { - let p = - Prompt::new("Specify radius of circle", Accept::POINT_OR_NUMBER).kw(&["Diameter"]).base_opt(self.pts.first().copied()).distance(); - if last_r > 0.0 { p.default(format!("{last_r:.4}")) } else { p } - } - (1, k) => Prompt::new( - ["Specify first point on circle", "Specify second point on circle", "Specify third point on circle"].get(k).copied().unwrap_or(""), - Accept::POINT, - ) - .base_opt(self.pts.last().copied()), - (2, k) => Prompt::new( - if k == 0 { "Specify first end point of circle's diameter" } else { "Specify second end point of circle's diameter" }, - Accept::POINT, - ) - .base_opt(self.pts.last().copied()), - _ => Prompt::new("Specify center point for circle", Accept::POINT), - } - } - fn input(&mut self, s: &mut Session, i: Input) -> Result { - if let Some(d) = &mut self.delegate { - return d.input(s, i); - } - match i { - Input::Keyword(k) => { - self.mode = match k.as_str() { - "3P" => 1, - "2P" => 2, - "Diameter" => { - self.diameter = true; - self.mode - } - _ => { - self.delegate = Some(Box::new(super::draw2::TanCircleM::new(true))); - 3 - } - }; - Ok(Step::Continue) - } - Input::Point(p) => { - if self.mode == 0 && self.pts.first().is_some_and(|c| c.near(p, 1e-12)) { - return Err(crate::EngineError::Other("Value must be positive and nonzero.".into())); - } - self.pts.push(p); - let done = match self.mode { - 0 if self.pts.len() == 2 => { - let (Some(c), Some(e)) = (self.pts.first(), self.pts.get(1)) else { return Ok(Step::Continue) }; - let r = if self.diameter { c.dist(*e) / 2.0 } else { c.dist(*e) }; - Some(Circle::new(*c, r)) - } - 1 if self.pts.len() == 3 => Circle::from_3_points(self.pts[0], self.pts[1], self.pts[2]), - 2 if self.pts.len() == 2 => Some(Circle::from_2_points(self.pts[0], self.pts[1])), - _ => None, - }; - if let Some(c) = done { - finish_circle(s, c)?; - return Ok(Step::Done); - } - Ok(Step::Continue) - } - Input::Text(t) if self.mode == 0 && self.pts.len() == 1 => { - let v = number(&t).ok_or_else(|| crate::EngineError::Other("Requires numeric distance or second point.".into()))?; - let r = if self.diameter { v / 2.0 } else { v }; - if r <= 0.0 { - return Err(crate::EngineError::Other("Value must be positive and nonzero.".into())); - } - finish_circle(s, Circle::new(self.pts[0], r))?; - Ok(Step::Done) - } - Input::Enter if self.mode == 0 && self.pts.len() == 1 => { - let r = s.doc()?.header.f64("CIRCLERAD", 0.0); - if r > 0.0 { - finish_circle(s, Circle::new(self.pts[0], r))?; - Ok(Step::Done) - } else { - Ok(Step::Continue) - } - } - Input::Enter => Ok(Step::Cancel), - // Garbage at a point prompt: report it and ask again. - Input::Text(_) if self.mode == 0 && self.pts.is_empty() => Err(crate::EngineError::Other("Point or option keyword required.".into())), - Input::Text(_) => Err(crate::EngineError::Other("Invalid point.".into())), - _ => Ok(Step::Continue), - } - } - fn preview(&self, s: &Session, c: Vec2) -> Vec { - if let Some(d) = &self.delegate { - return d.preview(s, c); - } - match (self.mode, self.pts.as_slice()) { - (0, [center]) => { - let r = if self.diameter { center.dist(c) / 2.0 } else { center.dist(c) }; - vec![circle(*center, r), line(*center, c)] - } - (1, [a, b]) => Circle::from_3_points(*a, *b, c).map(|ci| vec![circle(ci.center, ci.radius)]).unwrap_or_default(), - (1, [a]) | (2, [a]) => { - let ci = Circle::from_2_points(*a, c); - vec![circle(ci.center, ci.radius)] - } - _ => Vec::new(), - } - } -} - -fn finish_circle(s: &mut Session, c: Circle) -> Result<()> { - s.add_entity(circle(c.center, c.radius))?; - s.doc_mut()?.header.set_f64("CIRCLERAD", c.radius); - Ok(()) -} - -#[derive(Default)] -struct PolygonM { - sides: Option, - center: Option, - edge: bool, - edge_pts: Vec, - inscribed: Option, -} - -impl Interactive for PolygonM { - fn name(&self) -> &'static str { - "POLYGON" - } - fn prompt(&self, s: &Session) -> Prompt { - let def = s.doc().map(|d| d.header.i64("POLYSIDES", 4)).unwrap_or(4); - match (self.sides, self.edge, self.center, self.inscribed) { - (None, _, _, _) => Prompt::new("Enter number of sides", Accept::NUMBER).default(def.to_string()), - (Some(_), true, _, _) => Prompt::new( - if self.edge_pts.is_empty() { "Specify first endpoint of edge" } else { "Specify second endpoint of edge" }, - Accept::POINT, - ) - .base_opt(self.edge_pts.first().copied()), - (Some(_), false, None, _) => Prompt::new("Specify center of polygon", Accept::POINT).kw(&["Edge"]), - (Some(_), false, Some(_), None) => Prompt::new("Enter an option", Accept::TEXT) - .kw(&["Inscribed in circle", "Circumscribed about circle"]) - .default(if s.last_used.polygon_circumscribed { "C" } else { "I" }), - (Some(_), false, Some(c), Some(_)) => Prompt::new("Specify radius of circle", Accept::POINT_OR_NUMBER).base(c).distance(), - } - } - fn input(&mut self, s: &mut Session, i: Input) -> Result { - if self.sides.is_none() { - let n = match &i { - Input::Text(t) => t - .trim() - .parse::() - .ok() - .filter(|n| (3..=1024).contains(n)) - .ok_or_else(|| crate::EngineError::Other("Requires an integer between 3 and 1024.".into()))?, - Input::Enter => s.doc()?.header.i64("POLYSIDES", 4).clamp(3, 1024) as usize, - _ => return Ok(Step::Continue), - }; - self.sides = Some(n); - s.doc_mut()?.header.set_i64("POLYSIDES", n as i64); - return Ok(Step::Continue); - } - let n = self.sides.unwrap_or(4); - if self.edge { - if let Input::Point(p) = i { - if self.edge_pts.first().is_some_and(|a| a.near(p, 1e-12)) { - return Err(crate::EngineError::Other("Edge endpoints must differ.".into())); - } - self.edge_pts.push(p); - if self.edge_pts.len() == 2 { - s.add_entity(lwpoly(polygon_from_edge(self.edge_pts[0], self.edge_pts[1], n), true))?; - return Ok(Step::Done); - } - } - return Ok(Step::Continue); - } - match (self.center, self.inscribed, i) { - (None, _, Input::Keyword(k)) if k == "Edge" => self.edge = true, - (None, _, Input::Point(p)) => self.center = Some(p), - (Some(_), None, inp @ (Input::Keyword(_) | Input::Enter | Input::Text(_))) => { - // Enter takes the option used last time (the prompt's default). - let ins = match inp { - Input::Keyword(k) => k.starts_with('I'), - Input::Text(t) => !t.trim().to_ascii_lowercase().starts_with('c'), - _ => !s.last_used.polygon_circumscribed, - }; - self.inscribed = Some(ins); - s.last_used.polygon_circumscribed = !ins; - } - (Some(c), Some(ins), Input::Point(p)) => { - if c.near(p, 1e-12) { - return Err(crate::EngineError::Other("Value must be positive and nonzero.".into())); - } - s.add_entity(lwpoly(polygon_vertices(c, n, p, ins), true))?; - return Ok(Step::Done); - } - (Some(c), Some(ins), Input::Text(t)) => { - let r = number(&t).ok_or_else(|| crate::EngineError::Other("Requires numeric distance or point.".into()))?; - if !r.is_finite() || r <= 0.0 { - return Err(crate::EngineError::Other("Value must be positive and nonzero.".into())); - } - let rp = polygon_typed_radius_point(c, n, r, ins); - s.add_entity(lwpoly(polygon_vertices(c, n, rp, ins), true))?; - return Ok(Step::Done); - } - _ => {} - } - Ok(Step::Continue) - } - fn preview(&self, _s: &Session, c: Vec2) -> Vec { - let n = self.sides.unwrap_or(4); - if self.edge { - return self.edge_pts.first().map(|a| vec![lwpoly(polygon_from_edge(*a, c, n), true)]).unwrap_or_default(); - } - match (self.center, self.inscribed) { - (Some(ctr), Some(ins)) => vec![lwpoly(polygon_vertices(ctr, n, c, ins), true)], - _ => Vec::new(), - } - } -} - -/// ELLIPSE prompt machine starting in axis-end or center mode, optionally for an elliptical arc. -pub(crate) fn ellipse_machine(center: bool, arc: bool) -> Box { - Box::new(EllipseM { center_mode: center, arc_mode: arc, ..Default::default() }) -} - -#[derive(Default)] -struct EllipseM { - center_mode: bool, - arc_mode: bool, - pts: Vec, - /// The `Rotation` option: the next value is the rotation around the major axis. - rotation: bool, - ellipse: Option, - /// The `Parameter` option: arc start/end values are ellipse parameters, not true angles. - param: bool, - start: Option, - /// The Isocircle option (isometric snap style) in progress. - iso: Option, -} - -impl EllipseM { - fn axis(&self) -> Option<(Vec2, Vec2)> { - // (center, major vector) - match (self.center_mode, self.pts.as_slice()) { - (true, [c, e, ..]) => Some((*c, *e - *c)), - (false, [a, b, ..]) => Some((a.mid(*b), *b - a.mid(*b))), - _ => None, - } - } - fn build(&self, other: Vec2) -> Option { - let (c, m) = self.axis()?; - let ml = m.len(); - if ml < 1e-12 { - return None; - } - let u = m / ml; - let minor = ((other - c).dot(u.perp())).abs(); - let (major, ratio) = if minor > ml { (u.perp() * minor, ml / minor) } else { (m, minor / ml) }; - (ratio > 1e-9).then_some(Ellipse::full(c, major, ratio.min(1.0))) - } - /// The ellipse seen as a circle on the first axis rotated by `a` around it: ratio = cos(a), - /// for rotations up to 89.4°. - fn rotated(&self, a: f64) -> Option { - let (c, m) = self.axis()?; - let ratio = a.cos().abs(); - (m.len() >= 1e-12 && ratio >= 89.4f64.to_radians().cos()).then_some(Ellipse::full(c, m, ratio.min(1.0))) - } - /// The full ellipse is known: draw it, or go on to the arc angles. - fn place(&mut self, s: &mut Session, e: Ellipse) -> Result { - if self.arc_mode { - self.ellipse = Some(e); - return Ok(Step::Continue); - } - s.add_entity(EntityKind::Ellipse(cadcraft_doc::Ellipse { - center: v3(e.center), - major: v3(e.major), - ratio: e.ratio, - start: 0.0, - end: cadcraft_geom::TAU, - }))?; - Ok(Step::Done) - } -} - -impl Interactive for EllipseM { - fn name(&self) -> &'static str { - "ELLIPSE" - } - fn prompt(&self, s: &Session) -> Prompt { - if let Some(e) = &self.ellipse { - let msg = match (self.start.is_none(), self.param) { - (true, false) => "Specify start angle", - (false, false) => "Specify end angle", - (true, true) => "Specify start parameter", - (false, true) => "Specify end parameter", - }; - return Prompt::new(msg, Accept::POINT_OR_NUMBER).kw(if self.param { &["Angle"] } else { &["Parameter"] }).base(e.center); - } - if let Some(iso) = &self.iso { - return iso.prompt(); - } - match (self.center_mode, self.pts.len()) { - (false, 0) if s.settings.isodraft => Prompt::new("Specify axis endpoint of ellipse", Accept::POINT).kw(&["Arc", "Center", "Isocircle"]), - (false, 0) => Prompt::new("Specify axis endpoint of ellipse", Accept::POINT).kw(&["Arc", "Center"]), - (false, 1) => Prompt::new("Specify other endpoint of axis", Accept::POINT).base_opt(self.pts.first().copied()), - (true, 0) => Prompt::new("Specify center of ellipse", Accept::POINT), - (true, 1) => Prompt::new("Specify endpoint of axis", Accept::POINT).base_opt(self.pts.first().copied()), - _ if self.rotation => Prompt::new("Specify rotation around major axis", Accept::POINT_OR_NUMBER).base_opt(self.axis().map(|a| a.0)), - _ => Prompt::new("Specify distance to other axis", Accept::POINT_OR_NUMBER).kw(&["Rotation"]).base_opt(self.axis().map(|a| a.0)), - } - } - fn input(&mut self, s: &mut Session, i: Input) -> Result { - if let Some(e) = self.ellipse { - let ge = Ellipse { start: 0.0, end: cadcraft_geom::TAU, ..e }; - let ang = match i { - Input::Keyword(k) => { - match k.as_str() { - "Parameter" => self.param = true, - "Angle" => self.param = false, - _ => {} - } - return Ok(Step::Continue); - } - // A parameter is the angle from the major axis taken as the parameter itself. - Input::Point(p) if self.param => cadcraft_geom::norm_angle((p - e.center).angle() - e.major.angle()), - Input::Point(p) => ge.param_of(p), - Input::Text(t) => { - let a = s.angle_settings().amount(&t).ok_or_else(|| crate::EngineError::Other("Requires an angle.".into()))?; - if self.param { - cadcraft_geom::norm_angle(a) - } else { - // A typed angle is a true angle from the major axis; convert it to an ellipse parameter like a picked point. - ge.param_of(e.center + Vec2::from_angle(a + e.major.angle())) - } - } - _ => return Ok(Step::Continue), - }; - match self.start { - None => { - self.start = Some(ang); - return Ok(Step::Continue); - } - Some(st) => { - s.add_entity(EntityKind::Ellipse(cadcraft_doc::Ellipse { - center: v3(e.center), - major: v3(e.major), - ratio: e.ratio, - start: st, - end: ang, - }))?; - return Ok(Step::Done); - } - } - } - if let Some(iso) = &mut self.iso { - return match iso.input(s, i)? { - Some(e) => self.place(s, e), - None => Ok(Step::Continue), - }; - } - match i { - Input::Keyword(k) if k == "Isocircle" && self.pts.is_empty() => self.iso = Some(Default::default()), - Input::Keyword(k) if k == "Center" => self.center_mode = true, - Input::Keyword(k) if k == "Arc" => self.arc_mode = true, - Input::Keyword(k) if k == "Rotation" && self.pts.len() == 2 => self.rotation = true, - Input::Point(p) if self.pts.len() < 2 => self.pts.push(p), - Input::Point(p) if self.rotation => { - if let Some(e) = self.axis().and_then(|(c, _)| self.rotated((p - c).angle())) { - return self.place(s, e); - } - } - Input::Point(p) => { - if let Some(e) = self.build(p) { - return self.place(s, e); - } - } - Input::Text(t) if self.pts.len() == 2 && self.rotation => { - let e = s - .angle_settings() - .amount(&t) - .and_then(|a| self.rotated(a)) - .ok_or_else(|| crate::EngineError::Other("Requires an angle between 0 and 89.4 degrees.".into()))?; - return self.place(s, e); - } - Input::Text(t) if self.pts.len() == 2 => { - let d = number(&t).ok_or_else(|| crate::EngineError::Other("Requires a distance.".into()))?; - if let Some((c, m)) = self.axis() - && let Some(e) = self.build(c + m.normalized().perp() * d) - { - return self.place(s, e); - } - } - Input::Enter => return Ok(Step::Cancel), - _ => {} - } - Ok(Step::Continue) - } - fn preview(&self, s: &Session, c: Vec2) -> Vec { - if let Some(iso) = &self.iso { - return iso.preview(s, c).map(|e| super::settings::iso::ellipse_entity(&e)).into_iter().collect(); - } - if self.pts.len() < 2 { - return self.pts.first().map(|a| vec![line(*a, c)]).unwrap_or_default(); - } - let e = if self.rotation { self.axis().and_then(|(ctr, _)| self.rotated((c - ctr).angle())) } else { self.build(c) }; - e.map(|e| { - vec![EntityKind::Ellipse(cadcraft_doc::Ellipse { - center: v3(e.center), - major: v3(e.major), - ratio: e.ratio, - start: 0.0, - end: cadcraft_geom::TAU, - })] - }) - .unwrap_or_default() - } -} - -struct PointM { - multiple: bool, -} - -impl Interactive for PointM { - fn name(&self) -> &'static str { - "POINT" - } - fn prompt(&self, s: &Session) -> Prompt { - let d = s.doc().ok(); - let _ = d; - Prompt::new("Specify a point", Accept::POINT) - } - fn begin(&mut self, s: &mut Session) -> Result { - let (m, sz) = s.doc().map(|d| (d.header.i64("PDMODE", 0), d.header.f64("PDSIZE", 0.0))).unwrap_or((0, 0.0)); - s.echo(format!("Current point modes: PDMODE={m} PDSIZE={sz:.4}")); - Ok(Step::Continue) - } - fn input(&mut self, s: &mut Session, i: Input) -> Result { - match i { - Input::Point(p) => { - s.add_entity(EntityKind::Point(Point { p: v3(p), angle: 0.0 }))?; - Ok(if self.multiple { Step::Continue } else { Step::Done }) - } - Input::Enter => Ok(Step::Done), - _ => Ok(Step::Continue), - } - } -} - -/// RAY: a start point, then through points until Enter (XLINE is `draw2::XlineM2`). -#[derive(Default)] -struct RayM { - base: Option, -} - -impl Interactive for RayM { - fn name(&self) -> &'static str { - "RAY" - } - fn prompt(&self, _s: &Session) -> Prompt { - match self.base { - None => Prompt::new("Specify start point", Accept::POINT), - Some(b) => Prompt::new("Specify through point", Accept::POINT).base(b), - } - } - fn input(&mut self, s: &mut Session, i: Input) -> Result { - match i { - Input::Point(p) => { - match self.base { - None => self.base = Some(p), - Some(b) => { - let d = (p - b).normalized(); - if d != Vec2::ZERO { - s.add_entity(EntityKind::Ray(RayLine { base: v3(b), dir: v3(d) }))?; - } - } - } - Ok(Step::Continue) - } - Input::Enter => Ok(Step::Done), - _ => Ok(Step::Continue), - } - } - fn preview(&self, _s: &Session, c: Vec2) -> Vec { - match self.base { - Some(b) if !b.near(c, 1e-12) => vec![EntityKind::Ray(RayLine { base: v3(b), dir: v3((c - b).normalized()) })], - _ => Vec::new(), - } - } -} - -#[derive(Default)] -struct SplineM { - pts: Vec, - asking_method: bool, - /// `Method` > `CV` hands over to the control-vertex machine (`spline.cv`). - delegate: Option>, - /// Knots, end tangents and fit tolerance chosen so far. - opts: cadcraft_geom::FitOptions, - /// An option asking a further question (Knots, Tangency, toLerance, Object). - ask: Option, -} - -impl SplineM { - fn make(&self, closed: bool) -> Spline { - Spline::fit_with(&self.pts, closed, self.opts) - } -} - -impl Interactive for SplineM { - fn name(&self) -> &'static str { - "SPLINE" - } - fn begin(&mut self, s: &mut Session) -> Result { - self.opts.knots = super::spline_opts::current_knots(s); - s.echo(format!("Current settings: Method=Fit Knots={}", self.opts.knots.name())); - Ok(Step::Continue) - } - fn prompt(&self, s: &Session) -> Prompt { - if let Some(d) = &self.delegate { - return d.prompt(s); - } - if let Some(a) = &self.ask { - return a.prompt(&self.opts, &self.pts); - } - if self.asking_method { - return Prompt::new("Enter spline creation method", super::curves::KW).kw(&["Fit", "CV"]).default("Fit"); - } - match self.pts.len() { - 0 => Prompt::new("Specify first point", Accept::POINT).kw(&["Method", "Knots", "Object"]), - 1 => Prompt::new("Enter next point", Accept::POINT).kw(&["start Tangency", "toLerance"]).base_opt(self.pts.last().copied()), - 2 => Prompt::new("Enter next point", Accept::POINT).kw(&["end Tangency", "toLerance", "Undo"]).base_opt(self.pts.last().copied()), - _ => { - Prompt::new("Enter next point", Accept::POINT).kw(&["end Tangency", "toLerance", "Undo", "Close"]).base_opt(self.pts.last().copied()) - } - } - } - fn input(&mut self, s: &mut Session, i: Input) -> Result { - if let Some(d) = &mut self.delegate { - return d.input(s, i); - } - if let Some(a) = &mut self.ask { - use super::spline_opts::Outcome; - return match a.input(s, i, &mut self.opts, &self.pts)? { - Outcome::Stay => Ok(Step::Continue), - Outcome::Back => { - self.ask = None; - Ok(Step::Continue) - } - Outcome::Finish => { - self.ask = None; - if self.pts.len() >= 2 { - s.add_entity(EntityKind::Spline(self.make(false)))?; - } - Ok(Step::Done) - } - Outcome::Done => Ok(Step::Done), - }; - } - if self.asking_method { - match i { - Input::Keyword(k) if k == "CV" => { - let Some(factory) = find_command("spline.cv").and_then(|c| c.interactive) else { - return Err(crate::EngineError::Other("CV: not available yet".into())); - }; - let mut m = factory(s)?; - self.asking_method = false; - let step = m.begin(s)?; - self.delegate = Some(m); - return Ok(step); - } - Input::Keyword(_) | Input::Enter => self.asking_method = false, - Input::Text(_) => return Err(crate::EngineError::Other("Invalid option keyword.".into())), - _ => {} - } - return Ok(Step::Continue); - } - match i { - Input::Point(p) => { - self.pts.push(p); - Ok(Step::Continue) - } - Input::Keyword(k) if k == "Undo" => { - self.pts.pop(); - Ok(Step::Continue) - } - Input::Keyword(k) if k == "Close" => { - // A smooth closed (periodic) curve, not a repeat of the first point. - s.add_entity(EntityKind::Spline(self.make(true)))?; - Ok(Step::Done) - } - Input::Keyword(k) if k == "Method" => { - self.asking_method = true; - Ok(Step::Continue) - } - Input::Keyword(k) => { - // Knots, Object, start/end Tangency, toLerance ask their own question. - self.ask = - Some(super::spline_opts::SplineAsk::from_keyword(&k).ok_or_else(|| crate::EngineError::Other("Invalid option keyword.".into()))?); - Ok(Step::Continue) - } - Input::Enter => { - if self.pts.len() >= 2 { - s.add_entity(EntityKind::Spline(self.make(false)))?; - } - Ok(Step::Done) - } - Input::Text(_) => Err(crate::EngineError::Other("Point or option keyword required.".into())), - _ => Ok(Step::Continue), - } - } - fn preview(&self, s: &Session, c: Vec2) -> Vec { - if let Some(d) = &self.delegate { - return d.preview(s, c); - } - if self.pts.is_empty() { - return Vec::new(); - } - let mut opts = self.opts; - let mut pts = self.pts.clone(); - match self.ask { - // Rubber-band the tangent being picked. - Some(super::spline_opts::SplineAsk::StartTangent) => opts.start_tangent = self.pts.first().map(|f| c - *f).filter(|d| d.len() > 1e-12), - Some(super::spline_opts::SplineAsk::EndTangent) => opts.end_tangent = self.pts.last().map(|l| c - *l).filter(|d| d.len() > 1e-12), - Some(_) => {} - None => pts.push(c), - } - if pts.len() < 2 { - return Vec::new(); - } - vec![EntityKind::Spline(Spline::fit_with(&pts, false, opts))] - } -} - -#[derive(Default)] -struct DonutM { - inside: Option, - outside: Option, -} - -impl Interactive for DonutM { - fn name(&self) -> &'static str { - "DONUT" - } - fn prompt(&self, s: &Session) -> Prompt { - let d = s.doc().ok(); - let di = d.map(|d| d.header.f64("DONUTID", 0.5)).unwrap_or(0.5); - let dout = d.map(|d| d.header.f64("DONUTOD", 1.0)).unwrap_or(1.0); - match (self.inside, self.outside) { - (None, _) => Prompt::new("Specify inside diameter of donut", Accept::NUMBER).default(format!("{di:.4}")), - (Some(_), None) => Prompt::new("Specify outside diameter of donut", Accept::NUMBER).default(format!("{dout:.4}")), - _ => Prompt::new("Specify center of donut", Accept::POINT).kw(&[]), - } - } - fn input(&mut self, s: &mut Session, i: Input) -> Result { - let hdr = |s: &Session, k: &str, d: f64| s.doc().map(|x| x.header.f64(k, d)).unwrap_or(d); - match (self.inside, self.outside, i) { - (None, _, Input::Text(t)) => { - self.inside = Some(number(&t).ok_or_else(|| crate::EngineError::Other("Requires a distance.".into()))?.abs()) - } - (None, _, Input::Enter) => self.inside = Some(hdr(s, "DONUTID", 0.5)), - (Some(_), None, Input::Text(t)) => { - self.outside = Some(number(&t).ok_or_else(|| crate::EngineError::Other("Requires a distance.".into()))?.abs()) - } - (Some(_), None, Input::Enter) => self.outside = Some(hdr(s, "DONUTOD", 1.0)), - (Some(a), Some(b), Input::Point(p)) => { - s.add_entity(donut(p, a, b))?; - let d = s.doc_mut()?; - d.header.set_f64("DONUTID", a); - d.header.set_f64("DONUTOD", b); - } - (Some(_), Some(_), Input::Enter) => return Ok(Step::Done), - _ => {} - } - Ok(Step::Continue) - } - fn preview(&self, _s: &Session, c: Vec2) -> Vec { - match (self.inside, self.outside) { - (Some(a), Some(b)) => vec![donut(c, a, b)], - _ => Vec::new(), - } - } -} - -struct TextM { - at: Option, - height: Option, - rotation: Option, - justify: (HAlign, VAlign), - asking_justify: bool, - line: usize, - /// Run by a LISP routine (`command`): one line of text ends the command, as AutoLISP's - /// `command` runs TEXT; typed and in scripts each line asks for the next. - single_line: bool, -} - -impl TextM { - fn new(s: &Session) -> Self { - TextM { - at: None, - height: None, - rotation: None, - justify: (HAlign::Left, VAlign::Baseline), - asking_justify: false, - line: 0, - single_line: s.lisp_running(), - } - } -} - -impl Interactive for TextM { - fn name(&self) -> &'static str { - "TEXT" - } - fn prompt(&self, s: &Session) -> Prompt { - let th = s.doc().map(|d| d.header.f64("TEXTSIZE", 0.2)).unwrap_or(0.2); - if self.asking_justify { - return Prompt::new("Enter an option", Accept::TEXT) - .kw(&["Left", "Center", "Right", "Align", "Middle", "Fit", "TL", "TC", "TR", "ML", "MC", "MR", "BL", "BC", "BR"]); - } - match (self.at, self.height, self.rotation) { - (None, _, _) => Prompt::new("Specify start point of text", Accept::POINT).kw(&["Justify", "Style"]), - (Some(a), None, _) => Prompt::new("Specify height", Accept::POINT_OR_NUMBER).default(format!("{th:.4}")).base(a), - (Some(a), Some(_), None) => Prompt::new("Specify rotation angle of text", Accept::POINT_OR_NUMBER).default("0").base(a), - _ => Prompt::new("Enter text", Accept::TEXT), - } - } - fn begin(&mut self, s: &mut Session) -> Result { - let d = s.doc()?; - let style = d.header.str("TEXTSTYLE", "Standard"); - let anno = if d.text_style(&style).is_some_and(|st| st.annotative) { "Yes" } else { "No" }; - let msg = - format!("Current text style: \"{style}\" Text height: {:.4} Annotative: {anno} Justify: Left", d.header.f64("TEXTSIZE", 0.2)); - s.echo(msg); - Ok(Step::Continue) - } - fn input(&mut self, s: &mut Session, i: Input) -> Result { - if self.asking_justify { - let code = match &i { - Input::Keyword(k) | Input::Text(k) => k.clone(), - _ => return Ok(Step::Continue), - }; - self.justify = justify(&code).unwrap_or(self.justify); - self.asking_justify = false; - return Ok(Step::Continue); - } - let th = s.doc()?.header.f64("TEXTSIZE", 0.2); - match (self.at, self.height, self.rotation, i) { - (None, _, _, Input::Keyword(k)) if k == "Justify" => self.asking_justify = true, - (None, _, _, Input::Point(p)) => self.at = Some(p), - (Some(_), None, _, Input::Enter) => self.height = Some(th), - (Some(a), None, _, Input::Point(p)) => self.height = Some(a.dist(p).max(1e-9)), - (Some(_), None, _, Input::Text(t)) => { - let h = number(&t).filter(|h| *h > 0.0).ok_or_else(|| crate::EngineError::Other("Requires a positive height.".into()))?; - self.height = Some(h); - s.doc_mut()?.header.set_f64("TEXTSIZE", h); - } - (Some(_), Some(_), None, Input::Enter) => self.rotation = Some(0.0), - (Some(a), Some(_), None, Input::Point(p)) => self.rotation = Some(a.angle_to(p)), - (Some(_), Some(_), None, Input::Text(t)) => { - self.rotation = Some(s.angle_settings().direction(&t).ok_or_else(|| crate::EngineError::Other("Requires an angle.".into()))?) - } - (Some(a), Some(h), Some(r), Input::Text(t)) => { - let at = a + Vec2::from_angle(r - std::f64::consts::FRAC_PI_2) * (h * 5.0 / 3.0 * self.line as f64); - let k = make_text(s, at, &t, h, r, self.justify); - s.add_entity(k)?; - self.line += 1; - if self.single_line { - return Ok(Step::Done); - } - } - (Some(_), Some(_), Some(_), Input::Enter) => return Ok(Step::Done), - _ => {} - } - Ok(Step::Continue) - } -} - -#[derive(Default)] -struct MTextM { - first: Option, - second: Option, - /// Paragraphs typed so far: one per input line, an empty line ends the text (as on - /// AutoCAD's command line and in scripts). - lines: Vec, - /// Height, justification, line spacing, rotation, style and width set by the options. - opts: super::mtext_opts::MTextOpts, - /// An option asking a further question. - ask: Option, -} - -impl MTextM { - /// The box is known: the opposite corner was picked or the Width option answered. - fn boxed(&self) -> bool { - self.second.is_some() || self.opts.width.is_some() - } -} - -impl Interactive for MTextM { - fn name(&self) -> &'static str { - "MTEXT" - } - fn begin(&mut self, s: &mut Session) -> Result { - self.opts = super::mtext_opts::MTextOpts::new(s); - let anno = s.doc()?.text_style(&self.opts.style).is_some_and(|st| st.annotative); - s.echo(format!( - "Current text style: \"{}\" Text height: {:.4} Annotative: {}", - self.opts.style, - self.opts.height, - if anno { "Yes" } else { "No" } - )); - Ok(Step::Continue) - } - fn prompt(&self, _s: &Session) -> Prompt { - if let (Some(a), Some(q)) = (self.first, self.ask) { - return q.prompt(&self.opts, a); - } - match self.first { - None => Prompt::new("Specify first corner", Accept::POINT), - Some(a) if !self.boxed() => Prompt::new("Specify opposite corner", Accept::POINT) - .kw(&["Height", "Justify", "Line spacing", "Rotation", "Style", "Width", "Columns"]) - .base(a), - _ if self.lines.is_empty() => Prompt::new("Enter text (use \\P for new paragraphs)", Accept::TEXT), - _ => Prompt::new("Enter next line of text (empty line to finish)", Accept::TEXT), - } - } - fn input(&mut self, s: &mut Session, i: Input) -> Result { - if let (Some(a), Some(q)) = (self.first, self.ask) { - use super::mtext_opts::Outcome; - self.ask = match q.input(s, i, &mut self.opts, a)? { - Outcome::Ask(next) => Some(next), - Outcome::Back | Outcome::Text => None, - }; - return Ok(Step::Continue); - } - match (self.first, self.boxed(), i) { - (None, _, Input::Point(p)) => self.first = Some(p), - (Some(_), false, Input::Point(p)) => self.second = Some(p), - (Some(_), false, Input::Keyword(k)) => { - self.ask = - Some(super::mtext_opts::MTextAsk::from_keyword(&k).ok_or_else(|| crate::EngineError::Other("Invalid option keyword.".into()))?); - } - (Some(_), true, Input::Text(t)) if !t.is_empty() => self.lines.push(t), - (Some(a), true, Input::Text(_) | Input::Enter) => { - if self.lines.is_empty() { - return Ok(Step::Done); - } - s.add_entity(EntityKind::MText(self.opts.mtext(a, self.second, self.lines.join("\\P"))))?; - return Ok(Step::Done); - } - (_, _, Input::Enter) => return Ok(Step::Done), - _ => {} - } - Ok(Step::Continue) - } - fn editing_mtext(&self) -> Option { - let a = self.first?; - (self.boxed() && self.ask.is_none()).then(|| self.opts.mtext(a, self.second, String::new())) - } - fn preview(&self, _s: &Session, c: Vec2) -> Vec { - match (self.first, self.boxed(), self.ask) { - (Some(a), false, None) => { - // The box, turned by the rotation about the first corner. - let r = self.opts.rotation; - let l = (c - a).rotate(-r); - let corners = [Vec2::ZERO, Vec2::new(l.x, 0.0), l, Vec2::new(0.0, l.y)]; - vec![lwpoly(corners.iter().map(|q| PolyVertex::new(a + q.rotate(r))).collect(), true)] - } - _ => Vec::new(), - } - } -} - /// Re-export for tests. pub(crate) fn _unused(_: Polyline) {} diff --git a/crates/engine/src/cmd/draw/circle.rs b/crates/engine/src/cmd/draw/circle.rs new file mode 100644 index 000000000..146b4de50 --- /dev/null +++ b/crates/engine/src/cmd/draw/circle.rs @@ -0,0 +1,178 @@ +use super::*; + +pub(super) fn run_circle(s: &mut Session, p: &Value) -> Result { + let c = if let Some(center) = point_param(p, "center") { + // Checked before `Circle::new`, which would turn a negative radius positive. + let r = match size_param("circle", p, "radius", false)? { + Some(r) => r, + None => size_param("circle", p, "diameter", false)?.ok_or_else(|| bad("circle", "`radius` or `diameter` is required"))? / 2.0, + }; + Circle::new(center, r) + } else if let (Some(a), Some(b)) = (point_param(p, "p1"), point_param(p, "p2")) { + match point_param(p, "p3") { + Some(c3) => Circle::from_3_points(a, b, c3).ok_or_else(|| bad("circle", "points are collinear"))?, + None => Circle::from_2_points(a, b), + } + } else { + return Err(bad("circle", "give {center, radius} or {p1, p2[, p3]}")); + }; + if !(c.radius > 1e-12 && c.radius.is_finite()) { + return Err(bad("circle", "radius must be positive")); + } + added(s.add_entity(circle(c.center, c.radius))?) +} + +pub(super) fn run_arc(s: &mut Session, p: &Value) -> Result { + let a = if let (Some(a), Some(b), Some(c)) = (point_param(p, "p1"), point_param(p, "p2"), point_param(p, "p3")) { + Arc::from_3_points(a, b, c).ok_or_else(|| bad("arc", "points are collinear"))? + } else if let (Some(st), Some(c), Some(e)) = (point_param(p, "start"), point_param(p, "center"), point_param(p, "end")) { + Arc::from_start_center_end(st, c, e) + } else if let Some(c) = point_param(p, "center") { + let r = f64_req("arc", p, "radius")?; + // Checked before `Arc::new`, which would turn a negative radius positive (zero is + // refused below). + if r < 0.0 { + return Err(bad("arc", "`radius` must be positive")); + } + Arc::new(c, r, f64_req("arc", p, "start")?.to_radians(), f64_req("arc", p, "end")?.to_radians()) + } else { + return Err(bad("arc", "give {p1,p2,p3}, {start,center,end} or {center,radius,start,end}")); + }; + // Zero or non-finite radius (e.g. start == center): refuse, like the interactive ARC and the + // arc.* variants, before anything is added (#85). + let a = crate::cmd::curves::finite_arc(a).ok_or_else(|| bad("arc", "these values do not define an arc"))?; + added(s.add_entity(arc(&a))?) +} + +#[derive(Default)] +pub(super) struct CircleM { + mode: u8, // 0 center-radius, 1 = 3P, 2 = 2P, 3 = TTR + pts: Vec, + diameter: bool, + /// The `Ttr` option hands over to the tangent-circle machine. + delegate: Option>, +} + +impl Interactive for CircleM { + fn name(&self) -> &'static str { + "CIRCLE" + } + fn prompt(&self, s: &Session) -> Prompt { + if let Some(d) = &self.delegate { + return d.prompt(s); + } + let n = self.pts.len(); + let last_r = s.doc().map(|d| d.header.f64("CIRCLERAD", 0.0)).unwrap_or(0.0); + match (self.mode, n) { + (0, 0) => Prompt::new("Specify center point for circle", Accept::POINT).kw(&["3P", "2P", "Ttr (tan tan radius)"]), + (0, _) if self.diameter => { + Prompt::new("Specify diameter of circle", Accept::POINT_OR_NUMBER).base_opt(self.pts.first().copied()).distance() + } + (0, _) => { + let p = + Prompt::new("Specify radius of circle", Accept::POINT_OR_NUMBER).kw(&["Diameter"]).base_opt(self.pts.first().copied()).distance(); + if last_r > 0.0 { p.default(format!("{last_r:.4}")) } else { p } + } + (1, k) => Prompt::new( + ["Specify first point on circle", "Specify second point on circle", "Specify third point on circle"].get(k).copied().unwrap_or(""), + Accept::POINT, + ) + .base_opt(self.pts.last().copied()), + (2, k) => Prompt::new( + if k == 0 { "Specify first end point of circle's diameter" } else { "Specify second end point of circle's diameter" }, + Accept::POINT, + ) + .base_opt(self.pts.last().copied()), + _ => Prompt::new("Specify center point for circle", Accept::POINT), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + if let Some(d) = &mut self.delegate { + return d.input(s, i); + } + match i { + Input::Keyword(k) => { + self.mode = match k.as_str() { + "3P" => 1, + "2P" => 2, + "Diameter" => { + self.diameter = true; + self.mode + } + _ => { + self.delegate = Some(Box::new(crate::cmd::draw2::TanCircleM::new(true))); + 3 + } + }; + Ok(Step::Continue) + } + Input::Point(p) => { + if self.mode == 0 && self.pts.first().is_some_and(|c| c.near(p, 1e-12)) { + return Err(crate::EngineError::Other("Value must be positive and nonzero.".into())); + } + self.pts.push(p); + let done = match self.mode { + 0 if self.pts.len() == 2 => { + let (Some(c), Some(e)) = (self.pts.first(), self.pts.get(1)) else { return Ok(Step::Continue) }; + let r = if self.diameter { c.dist(*e) / 2.0 } else { c.dist(*e) }; + Some(Circle::new(*c, r)) + } + 1 if self.pts.len() == 3 => Circle::from_3_points(self.pts[0], self.pts[1], self.pts[2]), + 2 if self.pts.len() == 2 => Some(Circle::from_2_points(self.pts[0], self.pts[1])), + _ => None, + }; + if let Some(c) = done { + finish_circle(s, c)?; + return Ok(Step::Done); + } + Ok(Step::Continue) + } + Input::Text(t) if self.mode == 0 && self.pts.len() == 1 => { + let v = number(&t).ok_or_else(|| crate::EngineError::Other("Requires numeric distance or second point.".into()))?; + let r = if self.diameter { v / 2.0 } else { v }; + if r <= 0.0 { + return Err(crate::EngineError::Other("Value must be positive and nonzero.".into())); + } + finish_circle(s, Circle::new(self.pts[0], r))?; + Ok(Step::Done) + } + Input::Enter if self.mode == 0 && self.pts.len() == 1 => { + let r = s.doc()?.header.f64("CIRCLERAD", 0.0); + if r > 0.0 { + finish_circle(s, Circle::new(self.pts[0], r))?; + Ok(Step::Done) + } else { + Ok(Step::Continue) + } + } + Input::Enter => Ok(Step::Cancel), + // Garbage at a point prompt: report it and ask again. + Input::Text(_) if self.mode == 0 && self.pts.is_empty() => Err(crate::EngineError::Other("Point or option keyword required.".into())), + Input::Text(_) => Err(crate::EngineError::Other("Invalid point.".into())), + _ => Ok(Step::Continue), + } + } + fn preview(&self, s: &Session, c: Vec2) -> Vec { + if let Some(d) = &self.delegate { + return d.preview(s, c); + } + match (self.mode, self.pts.as_slice()) { + (0, [center]) => { + let r = if self.diameter { center.dist(c) / 2.0 } else { center.dist(c) }; + vec![circle(*center, r), line(*center, c)] + } + (1, [a, b]) => Circle::from_3_points(*a, *b, c).map(|ci| vec![circle(ci.center, ci.radius)]).unwrap_or_default(), + (1, [a]) | (2, [a]) => { + let ci = Circle::from_2_points(*a, c); + vec![circle(ci.center, ci.radius)] + } + _ => Vec::new(), + } + } +} + +fn finish_circle(s: &mut Session, c: Circle) -> Result<()> { + s.add_entity(circle(c.center, c.radius))?; + s.doc_mut()?.header.set_f64("CIRCLERAD", c.radius); + Ok(()) +} diff --git a/crates/engine/src/cmd/draw/ellipse.rs b/crates/engine/src/cmd/draw/ellipse.rs new file mode 100644 index 000000000..87391c263 --- /dev/null +++ b/crates/engine/src/cmd/draw/ellipse.rs @@ -0,0 +1,214 @@ +use super::*; + +pub(super) fn run_ellipse(s: &mut Session, p: &Value) -> Result { + if p.get("isocircle").is_some() { + return crate::cmd::settings::iso::run_isocircle(s, p); + } + let c = point_req("ellipse", p, "center")?; + let m = point_req("ellipse", p, "major")?; + let ratio = f64_req("ellipse", p, "ratio")?; + if !(ratio > 0.0 && ratio <= 1.0) || m.len() < 1e-12 { + return Err(bad("ellipse", "ratio must be in (0, 1] and major non-zero")); + } + let start = f64_or(p, "start", 0.0).to_radians(); + let end = f64_or(p, "end", 360.0).to_radians(); + added(s.add_entity(EntityKind::Ellipse(cadcraft_doc::Ellipse { center: v3(c), major: v3(m), ratio, start, end }))?) +} + +/// ELLIPSE prompt machine starting in axis-end or center mode, optionally for an elliptical arc. +pub(crate) fn ellipse_machine(center: bool, arc: bool) -> Box { + Box::new(EllipseM { center_mode: center, arc_mode: arc, ..Default::default() }) +} + +#[derive(Default)] +pub(super) struct EllipseM { + center_mode: bool, + arc_mode: bool, + pts: Vec, + /// The `Rotation` option: the next value is the rotation around the major axis. + rotation: bool, + ellipse: Option, + /// The `Parameter` option: arc start/end values are ellipse parameters, not true angles. + param: bool, + start: Option, + /// The Isocircle option (isometric snap style) in progress. + iso: Option, +} + +impl EllipseM { + fn axis(&self) -> Option<(Vec2, Vec2)> { + // (center, major vector) + match (self.center_mode, self.pts.as_slice()) { + (true, [c, e, ..]) => Some((*c, *e - *c)), + (false, [a, b, ..]) => Some((a.mid(*b), *b - a.mid(*b))), + _ => None, + } + } + fn build(&self, other: Vec2) -> Option { + let (c, m) = self.axis()?; + let ml = m.len(); + if ml < 1e-12 { + return None; + } + let u = m / ml; + let minor = ((other - c).dot(u.perp())).abs(); + let (major, ratio) = if minor > ml { (u.perp() * minor, ml / minor) } else { (m, minor / ml) }; + (ratio > 1e-9).then_some(Ellipse::full(c, major, ratio.min(1.0))) + } + /// The ellipse seen as a circle on the first axis rotated by `a` around it: ratio = cos(a), + /// for rotations up to 89.4°. + fn rotated(&self, a: f64) -> Option { + let (c, m) = self.axis()?; + let ratio = a.cos().abs(); + (m.len() >= 1e-12 && ratio >= 89.4f64.to_radians().cos()).then_some(Ellipse::full(c, m, ratio.min(1.0))) + } + /// The full ellipse is known: draw it, or go on to the arc angles. + fn place(&mut self, s: &mut Session, e: Ellipse) -> Result { + if self.arc_mode { + self.ellipse = Some(e); + return Ok(Step::Continue); + } + s.add_entity(EntityKind::Ellipse(cadcraft_doc::Ellipse { + center: v3(e.center), + major: v3(e.major), + ratio: e.ratio, + start: 0.0, + end: cadcraft_geom::TAU, + }))?; + Ok(Step::Done) + } +} + +impl Interactive for EllipseM { + fn name(&self) -> &'static str { + "ELLIPSE" + } + fn prompt(&self, s: &Session) -> Prompt { + if let Some(e) = &self.ellipse { + let msg = match (self.start.is_none(), self.param) { + (true, false) => "Specify start angle", + (false, false) => "Specify end angle", + (true, true) => "Specify start parameter", + (false, true) => "Specify end parameter", + }; + return Prompt::new(msg, Accept::POINT_OR_NUMBER).kw(if self.param { &["Angle"] } else { &["Parameter"] }).base(e.center); + } + if let Some(iso) = &self.iso { + return iso.prompt(); + } + match (self.center_mode, self.pts.len()) { + (false, 0) if s.settings.isodraft => Prompt::new("Specify axis endpoint of ellipse", Accept::POINT).kw(&["Arc", "Center", "Isocircle"]), + (false, 0) => Prompt::new("Specify axis endpoint of ellipse", Accept::POINT).kw(&["Arc", "Center"]), + (false, 1) => Prompt::new("Specify other endpoint of axis", Accept::POINT).base_opt(self.pts.first().copied()), + (true, 0) => Prompt::new("Specify center of ellipse", Accept::POINT), + (true, 1) => Prompt::new("Specify endpoint of axis", Accept::POINT).base_opt(self.pts.first().copied()), + _ if self.rotation => Prompt::new("Specify rotation around major axis", Accept::POINT_OR_NUMBER).base_opt(self.axis().map(|a| a.0)), + _ => Prompt::new("Specify distance to other axis", Accept::POINT_OR_NUMBER).kw(&["Rotation"]).base_opt(self.axis().map(|a| a.0)), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + if let Some(e) = self.ellipse { + let ge = Ellipse { start: 0.0, end: cadcraft_geom::TAU, ..e }; + let ang = match i { + Input::Keyword(k) => { + match k.as_str() { + "Parameter" => self.param = true, + "Angle" => self.param = false, + _ => {} + } + return Ok(Step::Continue); + } + // A parameter is the angle from the major axis taken as the parameter itself. + Input::Point(p) if self.param => cadcraft_geom::norm_angle((p - e.center).angle() - e.major.angle()), + Input::Point(p) => ge.param_of(p), + Input::Text(t) => { + let a = s.angle_settings().amount(&t).ok_or_else(|| crate::EngineError::Other("Requires an angle.".into()))?; + if self.param { + cadcraft_geom::norm_angle(a) + } else { + // A typed angle is a true angle from the major axis; convert it to an ellipse parameter like a picked point. + ge.param_of(e.center + Vec2::from_angle(a + e.major.angle())) + } + } + _ => return Ok(Step::Continue), + }; + match self.start { + None => { + self.start = Some(ang); + return Ok(Step::Continue); + } + Some(st) => { + s.add_entity(EntityKind::Ellipse(cadcraft_doc::Ellipse { + center: v3(e.center), + major: v3(e.major), + ratio: e.ratio, + start: st, + end: ang, + }))?; + return Ok(Step::Done); + } + } + } + if let Some(iso) = &mut self.iso { + return match iso.input(s, i)? { + Some(e) => self.place(s, e), + None => Ok(Step::Continue), + }; + } + match i { + Input::Keyword(k) if k == "Isocircle" && self.pts.is_empty() => self.iso = Some(Default::default()), + Input::Keyword(k) if k == "Center" => self.center_mode = true, + Input::Keyword(k) if k == "Arc" => self.arc_mode = true, + Input::Keyword(k) if k == "Rotation" && self.pts.len() == 2 => self.rotation = true, + Input::Point(p) if self.pts.len() < 2 => self.pts.push(p), + Input::Point(p) if self.rotation => { + if let Some(e) = self.axis().and_then(|(c, _)| self.rotated((p - c).angle())) { + return self.place(s, e); + } + } + Input::Point(p) => { + if let Some(e) = self.build(p) { + return self.place(s, e); + } + } + Input::Text(t) if self.pts.len() == 2 && self.rotation => { + let e = s + .angle_settings() + .amount(&t) + .and_then(|a| self.rotated(a)) + .ok_or_else(|| crate::EngineError::Other("Requires an angle between 0 and 89.4 degrees.".into()))?; + return self.place(s, e); + } + Input::Text(t) if self.pts.len() == 2 => { + let d = number(&t).ok_or_else(|| crate::EngineError::Other("Requires a distance.".into()))?; + if let Some((c, m)) = self.axis() + && let Some(e) = self.build(c + m.normalized().perp() * d) + { + return self.place(s, e); + } + } + Input::Enter => return Ok(Step::Cancel), + _ => {} + } + Ok(Step::Continue) + } + fn preview(&self, s: &Session, c: Vec2) -> Vec { + if let Some(iso) = &self.iso { + return iso.preview(s, c).map(|e| crate::cmd::settings::iso::ellipse_entity(&e)).into_iter().collect(); + } + if self.pts.len() < 2 { + return self.pts.first().map(|a| vec![line(*a, c)]).unwrap_or_default(); + } + let e = if self.rotation { self.axis().and_then(|(ctr, _)| self.rotated((c - ctr).angle())) } else { self.build(c) }; + e.map(|e| { + vec![EntityKind::Ellipse(cadcraft_doc::Ellipse { + center: v3(e.center), + major: v3(e.major), + ratio: e.ratio, + start: 0.0, + end: cadcraft_geom::TAU, + })] + }) + .unwrap_or_default() + } +} diff --git a/crates/engine/src/cmd/draw/line_pline.rs b/crates/engine/src/cmd/draw/line_pline.rs new file mode 100644 index 000000000..b7ad2455c --- /dev/null +++ b/crates/engine/src/cmd/draw/line_pline.rs @@ -0,0 +1,403 @@ +use super::*; + +// ---------------- JSON forms ---------------- + +pub(super) fn run_line(s: &mut Session, p: &Value) -> Result { + let pts = points_param(p, "points").ok_or_else(|| bad("line", "`points` [[x,y],...] (2 or more) is required"))?; + if pts.len() < 2 { + return Err(bad("line", "need at least 2 points")); + } + // A repeated point would make a zero-length line. + let pts = distinct_points(&pts); + if pts.len() < 2 { + return Err(bad("line", "need at least 2 different points")); + } + let mut hs = Vec::new(); + for w in pts.windows(2) { + if let [a, b] = w { + hs.push(s.add_entity(line(*a, *b))?.hex()); + } + } + if bool_or(p, "closed", false) + && pts.len() > 2 + && let (Some(a), Some(b)) = (pts.last(), pts.first()) + && !a.near(*b, 1e-12) + { + hs.push(s.add_entity(line(*a, *b))?.hex()); + } + Ok(json!({ "handles": hs })) +} + +fn vertex_value(v: &Value) -> Option { + if let Some(p) = point_value(v) { + return Some(PolyVertex::new(p)); + } + let p = point_value(v.get("p")?)?; + Some(PolyVertex { p, bulge: f64_or(v, "bulge", 0.0), start_width: f64_or(v, "startWidth", 0.0), end_width: f64_or(v, "endWidth", 0.0) }) +} + +pub(super) fn run_pline(s: &mut Session, p: &Value) -> Result { + let raw = p.get("vertices").or_else(|| p.get("points")).and_then(Value::as_array); + for v in raw.into_iter().flatten() { + size_param("pline", v, "startWidth", true)?; + size_param("pline", v, "endWidth", true)?; + } + let vs: Vec = raw.map(|a| a.iter().filter_map(vertex_value).collect()).unwrap_or_default(); + if vs.len() < 2 { + return Err(bad("pline", "`vertices` needs at least 2 points")); + } + let width = size_param("pline", p, "width", true)?.unwrap_or(0.0); + let mut k = lwpoly(vs, bool_or(p, "closed", false)); + if let EntityKind::LwPolyline(pl) = &mut k { + pl.const_width = width; + } + added(s.add_entity(k)?) +} + +// ---------------- interactive ---------------- + +#[derive(Default)] +pub(super) struct LineM { + pts: Vec, + handles: Vec, + /// A first point picked as a deferred tangent/perpendicular, resolved by the next point. + first: Option, +} + +impl LineM { + /// Draw the first segment from the deferred first point to `end`. + fn resolve_first(&mut self, s: &mut Session, first: snap::Deferred, end: snap::LineEnd) -> Result { + let Some((a, b)) = snap::resolve_deferred(snap::LineEnd::Deferred(first), end) else { + let what = if first.mode == snap::mode::TAN { "tangent" } else { "perpendicular" }; + return Err(crate::EngineError::Other(format!("No {what} line through that point; pick another point."))); + }; + self.handles.push(s.add_entity(line(a, b))?); + self.pts = vec![a, b]; + self.first = None; + s.last_point = b; + Ok(Step::Continue) + } +} + +impl Interactive for LineM { + fn name(&self) -> &'static str { + "LINE" + } + fn prompt(&self, _s: &Session) -> Prompt { + match self.pts.len() { + 0 if self.first.is_some() => Prompt::new("Specify next point", Accept::POINT).kw(&["Undo"]).deferred(), + 0 => Prompt::new("Specify first point", Accept::POINT).deferred(), + 1 | 2 => Prompt::new("Specify next point", Accept::POINT).kw(&["Undo"]).base_opt(self.pts.last().copied()), + _ => Prompt::new("Specify next point", Accept::POINT).kw(&["Close", "Undo"]).base_opt(self.pts.last().copied()), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + if self.pts.is_empty() + && let Some(first) = self.first + { + match i { + Input::Point(p) => { + // A deferred TAN first point with a typed `@d return self.resolve_first(s, first, snap::LineEnd::Deferred(d)), + _ => {} + } + } + match i { + Input::Deferred(d) if self.pts.is_empty() => { + self.first = Some(d); + Ok(Step::Continue) + } + // A deferred pick with a known last point (not offered by the prompt, but harmless). + Input::Deferred(d) => match self.pts.last().copied() { + Some(last) => match snap::resolve_deferred(snap::LineEnd::Point(last), snap::LineEnd::Deferred(d)) { + Some((_, p)) => self.input(s, Input::Point(p)), + None => Err(crate::EngineError::Other("No tangent or perpendicular from the last point.".into())), + }, + None => Ok(Step::Continue), + }, + Input::Point(p) => { + if let Some(last) = self.pts.last().copied() { + if last.near(p, 1e-12) { + return Ok(Step::Continue); + } + self.handles.push(s.add_entity(line(last, p))?); + } + self.pts.push(p); + Ok(Step::Continue) + } + Input::Keyword(k) if k == "Undo" => { + if self.first.take().is_some() { + return Ok(Step::Continue); + } + if let Some(h) = self.handles.pop() { + s.doc_mut()?.remove_entity(h); + } + self.pts.pop(); + Ok(Step::Continue) + } + Input::Keyword(k) if k == "Close" => { + if let (Some(a), Some(b)) = (self.pts.last().copied(), self.pts.first().copied()) { + s.add_entity(line(a, b))?; + } + Ok(Step::Done) + } + Input::Enter => Ok(Step::Done), + _ => Err(crate::EngineError::Other("Point or option keyword required.".into())), + } + } + fn preview(&self, s: &Session, c: Vec2) -> Vec { + if let Some(first) = self.first { + // Over a second curve, show the tangent-to-tangent line the click would draw. + let end = s.cursor_deferred.map_or(snap::LineEnd::Point(c), snap::LineEnd::Deferred); + return snap::resolve_deferred(snap::LineEnd::Deferred(first), end).map(|(a, b)| vec![line(a, b)]).unwrap_or_default(); + } + self.pts.last().map(|l| vec![line(*l, c)]).unwrap_or_default() + } +} + +#[derive(Default)] +pub(super) struct PlineM { + verts: Vec, + arc_mode: bool, + /// Total width at the start and end of the next segment (Width / Halfwidth answers). + start_w: f64, + end_w: f64, + handle: Option, + /// 1 = asking the starting width, 2 = the ending width. + asking_width: u8, + /// The widths are being asked as half-widths (centre line to edge). + half: bool, + /// An option asking further questions (arc mode's Angle, CEnter…, line mode's Length). + ask: Option, +} + +impl PlineM { + fn entity(&self, closed: bool) -> EntityKind { + let mut k = lwpoly(self.verts.clone(), closed); + if let EntityKind::LwPolyline(pl) = &mut k { + // Every segment the same uniform width: store it as the constant width, as before. + let segs = if closed { pl.vertices.len() } else { pl.vertices.len().saturating_sub(1) }; + let w = pl.vertices.first().map_or(0.0, |v| v.start_width); + if pl.vertices.iter().take(segs).all(|v| v.start_width == w && v.end_width == w) { + pl.const_width = w; + for v in &mut pl.vertices { + (v.start_width, v.end_width) = (0.0, 0.0); + } + } + } + k + } + /// The next segment, starting at the last vertex, takes the requested widths (#104); the + /// segments after it are uniform at the ending width, as in AutoCAD. + fn begin_segment(&mut self) { + if let Some(last) = self.verts.last_mut() { + (last.start_width, last.end_width) = (self.start_w, self.end_w); + self.start_w = self.end_w; + } + } + fn sync(&mut self, s: &mut Session, closed: bool) -> Result<()> { + if self.verts.len() < 2 { + if let Some(h) = self.handle.take() { + s.doc_mut()?.remove_entity(h); + } + return Ok(()); + } + let k = self.entity(closed); + match self.handle { + Some(h) => { + s.doc_mut()?.modify_entity(h, |e| e.kind = k)?; + } + None => self.handle = Some(s.add_entity(k)?), + } + Ok(()) + } + /// The direction the polyline runs in at its last vertex (+X before the second vertex). + fn tangent(&self) -> Vec2 { + let n = self.verts.len(); + let Some(last) = self.verts.last() else { return Vec2::X }; + if n >= 2 { + let prev = self.verts.get(n - 2).copied().unwrap_or_default(); + match bulge_to_arc(prev.p, last.p, prev.bulge) { + Some((a, ccw)) => { + let r = (last.p - a.center).perp().normalized(); + if ccw { r } else { -r } + } + None => (last.p - prev.p).normalized(), + } + } else { + Vec2::X + } + } + /// Bulge for a tangent-continuing arc from the last vertex to `p`. + fn tangent_bulge(&self, p: Vec2) -> f64 { + let Some(last) = self.verts.last() else { return 0.0 }; + let dir = self.tangent(); + let chord = p - last.p; + // Included angle = 2 × angle between tangent and chord. + let ang = dir.cross(chord).atan2(dir.dot(chord)); + arc_to_bulge(2.0 * ang) + } +} + +impl Interactive for PlineM { + fn name(&self) -> &'static str { + "PLINE" + } + fn prompt(&self, s: &Session) -> Prompt { + if self.asking_width > 0 { + let unit = if self.half { "half-width" } else { "width" }; + let (which, w) = if self.asking_width == 1 { ("starting", self.start_w) } else { ("ending", self.end_w) }; + let shown = if self.half { w / 2.0 } else { w }; + return Prompt::new(format!("Specify {which} {unit}"), Accept::NUMBER).default(format!("{shown:.4}")); + } + let base = self.verts.last().map(|v| v.p); + if let (Some(a), Some(st)) = (self.ask, base) { + return a.prompt(st, self.tangent(), s); + } + match (self.verts.len(), self.arc_mode) { + (0, _) => Prompt::new("Specify start point", Accept::POINT), + (n, false) => { + let mut kw = vec!["Arc", "Halfwidth", "Length", "Undo", "Width"]; + if n >= 2 { + kw.insert(1, "Close"); + } + Prompt::new("Specify next point", Accept::POINT).kw(&kw).base_opt(base) + } + (_, true) => Prompt::new("Specify endpoint of arc", Accept::POINT) + .kw(&["Angle", "CEnter", "CLose", "Direction", "Halfwidth", "Line", "Radius", "Second pt", "Undo", "Width"]) + .base_opt(base), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + if self.asking_width > 0 { + match i { + Input::Text(t) => { + let w = number(&t).filter(|w| w.is_finite()).ok_or_else(|| crate::EngineError::Other("Requires a distance.".into()))?; + // Halfwidth is centre line to edge: the total width is twice the answer (#83). + let w = w.max(0.0) * if self.half { 2.0 } else { 1.0 }; + if self.asking_width == 1 { + self.start_w = w; + } else { + self.end_w = w; + } + } + Input::Enter => {} + _ => return Ok(Step::Continue), + } + if self.asking_width == 1 { + // The ending width defaults to the starting width. + self.end_w = self.start_w; + } + self.asking_width = if self.asking_width == 1 { 2 } else { 0 }; + return Ok(Step::Continue); + } + if let (Some(a), Some(st)) = (self.ask, self.verts.last().map(|v| v.p)) { + match a.input(s, i, st, self.tangent())? { + Outcome::Ask(next) => self.ask = Some(next), + Outcome::Back => self.ask = None, + Outcome::Segment(end, bulge) => { + self.ask = None; + self.begin_segment(); + if let Some(last) = self.verts.last_mut() { + last.bulge = bulge; + } + self.verts.push(PolyVertex::new(end)); + self.sync(s, false)?; + } + } + return Ok(Step::Continue); + } + match i { + Input::Point(p) => { + if self.verts.is_empty() { + s.echo(format!("Current line-width is {:.4}", self.start_w)); + } + self.begin_segment(); + if self.arc_mode && !self.verts.is_empty() { + let b = self.tangent_bulge(p); + if let Some(last) = self.verts.last_mut() { + last.bulge = b; + } + } + self.verts.push(PolyVertex::new(p)); + self.sync(s, false)?; + Ok(Step::Continue) + } + Input::Keyword(k) => match k.as_str() { + "Arc" => { + self.arc_mode = true; + Ok(Step::Continue) + } + "Line" => { + self.arc_mode = false; + Ok(Step::Continue) + } + "Close" | "CLose" => { + if self.arc_mode + && let Some(first) = self.verts.first().map(|v| v.p) + { + let b = self.tangent_bulge(first); + if let Some(last) = self.verts.last_mut() { + last.bulge = b; + } + } + // The closing segment starts at the last vertex. + self.begin_segment(); + self.sync(s, true)?; + Ok(Step::Done) + } + "Undo" => { + self.verts.pop(); + if let Some(l) = self.verts.last_mut() { + l.bulge = 0.0; + (l.start_width, l.end_width) = (0.0, 0.0); + } + self.sync(s, false)?; + Ok(Step::Continue) + } + "Width" | "Halfwidth" => { + self.half = k == "Halfwidth"; + self.asking_width = 1; + Ok(Step::Continue) + } + k => { + match PlineAsk::from_keyword(k, self.arc_mode) { + Some(a) if !self.verts.is_empty() => self.ask = Some(a), + _ => return Err(crate::EngineError::Other("Point or option keyword required.".into())), + } + Ok(Step::Continue) + } + }, + Input::Enter => Ok(Step::Done), + _ => Err(crate::EngineError::Other("Point or option keyword required.".into())), + } + } + fn preview(&self, _s: &Session, c: Vec2) -> Vec { + let Some(last) = self.verts.last() else { return Vec::new() }; + if let Some(a) = self.ask { + return a + .preview(last.p, self.tangent(), c) + .map(|(e, b)| vec![lwpoly(vec![PolyVertex::with_bulge(last.p, b), PolyVertex::new(e)], false)]) + .unwrap_or_default(); + } + if self.arc_mode { + let b = self.tangent_bulge(c); + vec![lwpoly(vec![PolyVertex::with_bulge(last.p, b), PolyVertex::new(c)], false)] + } else { + vec![line(last.p, c)] + } + } +} diff --git a/crates/engine/src/cmd/draw/point.rs b/crates/engine/src/cmd/draw/point.rs new file mode 100644 index 000000000..507b0b10d --- /dev/null +++ b/crates/engine/src/cmd/draw/point.rs @@ -0,0 +1,165 @@ +use super::*; + +pub(super) fn run_point(s: &mut Session, p: &Value) -> Result { + let pts = match point_param(p, "at") { + Some(a) => vec![a], + None => points_param(p, "points").ok_or_else(|| bad("point", "`at` or `points` is required"))?, + }; + let mut hs = Vec::new(); + for q in pts { + hs.push(s.add_entity(EntityKind::Point(Point { p: v3(q), angle: 0.0 }))?.hex()); + } + Ok(json!({ "handles": hs })) +} + +pub(super) fn run_ray(s: &mut Session, p: &Value) -> Result { + let b = point_req("ray", p, "base")?; + let t = point_req("ray", p, "through")?; + let dir = (t - b).normalized(); + if dir == Vec2::ZERO { + return Err(bad("ray", "through point equals base")); + } + added(s.add_entity(EntityKind::Ray(RayLine { base: v3(b), dir: v3(dir) }))?) +} + +fn donut(center: Vec2, inside: f64, outside: f64) -> EntityKind { + let r = (inside.abs() + outside.abs()) / 4.0; + let w = (outside.abs() - inside.abs()).abs() / 2.0; + let mut k = lwpoly(vec![PolyVertex::with_bulge(center - Vec2::X * r, 1.0), PolyVertex::with_bulge(center + Vec2::X * r, 1.0)], true); + if let EntityKind::LwPolyline(pl) = &mut k { + pl.const_width = w; + } + k +} + +pub(super) fn run_donut(s: &mut Session, p: &Value) -> Result { + let c = point_req("donut", p, "center")?; + let inside = size_param("donut", p, "inside", true)?.unwrap_or(0.5); + let outside = size_param("donut", p, "outside", false)?.unwrap_or(1.0); + if inside > outside { + return Err(bad("donut", "`inside` must not be larger than `outside`")); + } + added(s.add_entity(donut(c, inside, outside))?) +} + +pub(super) struct PointM { + pub(super) multiple: bool, +} + +impl Interactive for PointM { + fn name(&self) -> &'static str { + "POINT" + } + fn prompt(&self, s: &Session) -> Prompt { + let d = s.doc().ok(); + let _ = d; + Prompt::new("Specify a point", Accept::POINT) + } + fn begin(&mut self, s: &mut Session) -> Result { + let (m, sz) = s.doc().map(|d| (d.header.i64("PDMODE", 0), d.header.f64("PDSIZE", 0.0))).unwrap_or((0, 0.0)); + s.echo(format!("Current point modes: PDMODE={m} PDSIZE={sz:.4}")); + Ok(Step::Continue) + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + match i { + Input::Point(p) => { + s.add_entity(EntityKind::Point(Point { p: v3(p), angle: 0.0 }))?; + Ok(if self.multiple { Step::Continue } else { Step::Done }) + } + Input::Enter => Ok(Step::Done), + _ => Ok(Step::Continue), + } + } +} + +/// RAY: a start point, then through points until Enter (XLINE is `draw2::XlineM2`). +#[derive(Default)] +pub(super) struct RayM { + base: Option, +} + +impl Interactive for RayM { + fn name(&self) -> &'static str { + "RAY" + } + fn prompt(&self, _s: &Session) -> Prompt { + match self.base { + None => Prompt::new("Specify start point", Accept::POINT), + Some(b) => Prompt::new("Specify through point", Accept::POINT).base(b), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + match i { + Input::Point(p) => { + match self.base { + None => self.base = Some(p), + Some(b) => { + let d = (p - b).normalized(); + if d != Vec2::ZERO { + s.add_entity(EntityKind::Ray(RayLine { base: v3(b), dir: v3(d) }))?; + } + } + } + Ok(Step::Continue) + } + Input::Enter => Ok(Step::Done), + _ => Ok(Step::Continue), + } + } + fn preview(&self, _s: &Session, c: Vec2) -> Vec { + match self.base { + Some(b) if !b.near(c, 1e-12) => vec![EntityKind::Ray(RayLine { base: v3(b), dir: v3((c - b).normalized()) })], + _ => Vec::new(), + } + } +} + +#[derive(Default)] +pub(super) struct DonutM { + inside: Option, + outside: Option, +} + +impl Interactive for DonutM { + fn name(&self) -> &'static str { + "DONUT" + } + fn prompt(&self, s: &Session) -> Prompt { + let d = s.doc().ok(); + let di = d.map(|d| d.header.f64("DONUTID", 0.5)).unwrap_or(0.5); + let dout = d.map(|d| d.header.f64("DONUTOD", 1.0)).unwrap_or(1.0); + match (self.inside, self.outside) { + (None, _) => Prompt::new("Specify inside diameter of donut", Accept::NUMBER).default(format!("{di:.4}")), + (Some(_), None) => Prompt::new("Specify outside diameter of donut", Accept::NUMBER).default(format!("{dout:.4}")), + _ => Prompt::new("Specify center of donut", Accept::POINT).kw(&[]), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + let hdr = |s: &Session, k: &str, d: f64| s.doc().map(|x| x.header.f64(k, d)).unwrap_or(d); + match (self.inside, self.outside, i) { + (None, _, Input::Text(t)) => { + self.inside = Some(number(&t).ok_or_else(|| crate::EngineError::Other("Requires a distance.".into()))?.abs()) + } + (None, _, Input::Enter) => self.inside = Some(hdr(s, "DONUTID", 0.5)), + (Some(_), None, Input::Text(t)) => { + self.outside = Some(number(&t).ok_or_else(|| crate::EngineError::Other("Requires a distance.".into()))?.abs()) + } + (Some(_), None, Input::Enter) => self.outside = Some(hdr(s, "DONUTOD", 1.0)), + (Some(a), Some(b), Input::Point(p)) => { + s.add_entity(donut(p, a, b))?; + let d = s.doc_mut()?; + d.header.set_f64("DONUTID", a); + d.header.set_f64("DONUTOD", b); + } + (Some(_), Some(_), Input::Enter) => return Ok(Step::Done), + _ => {} + } + Ok(Step::Continue) + } + fn preview(&self, _s: &Session, c: Vec2) -> Vec { + match (self.inside, self.outside) { + (Some(a), Some(b)) => vec![donut(c, a, b)], + _ => Vec::new(), + } + } +} diff --git a/crates/engine/src/cmd/draw/polygon.rs b/crates/engine/src/cmd/draw/polygon.rs new file mode 100644 index 000000000..d67aa62e6 --- /dev/null +++ b/crates/engine/src/cmd/draw/polygon.rs @@ -0,0 +1,134 @@ +use super::*; + +pub(super) fn run_polygon(s: &mut Session, p: &Value) -> Result { + // A `sides` that is not a whole number (e.g. -3) is refused, not replaced by the default 4. + let n = match p.get("sides") { + None | Some(Value::Null) => 4, + Some(v) => v.as_u64().and_then(|n| usize::try_from(n).ok()).unwrap_or(0), + }; + if !(3..=1024).contains(&n) { + return Err(bad("polygon", "sides must be 3..1024")); + } + let vs = if let Some(e) = points_param(p, "edge") { + let (Some(a), Some(b)) = (e.first(), e.get(1)) else { return Err(bad("polygon", "edge needs 2 points")) }; + if a.near(*b, 1e-12) { + return Err(bad("polygon", "edge endpoints must differ")); + } + polygon_from_edge(*a, *b, n) + } else { + let c = point_req("polygon", p, "center")?; + let r = size_param("polygon", p, "radius", false)?.ok_or_else(|| bad("polygon", "`radius` (number) is required"))?; + let inscribed = bool_or(p, "inscribed", true); + // Without an angle, the bottom edge is horizontal, as with a typed radius. + let rp = match p.get("angle").and_then(Value::as_f64).filter(|a| a.is_finite()) { + Some(a) => Vec2::polar(c, r, a.to_radians()), + None => polygon_typed_radius_point(c, n, r, inscribed), + }; + polygon_vertices(c, n, rp, inscribed) + }; + added(s.add_entity(lwpoly(vs, true))?) +} + +#[derive(Default)] +pub(super) struct PolygonM { + sides: Option, + center: Option, + edge: bool, + edge_pts: Vec, + inscribed: Option, +} + +impl Interactive for PolygonM { + fn name(&self) -> &'static str { + "POLYGON" + } + fn prompt(&self, s: &Session) -> Prompt { + let def = s.doc().map(|d| d.header.i64("POLYSIDES", 4)).unwrap_or(4); + match (self.sides, self.edge, self.center, self.inscribed) { + (None, _, _, _) => Prompt::new("Enter number of sides", Accept::NUMBER).default(def.to_string()), + (Some(_), true, _, _) => Prompt::new( + if self.edge_pts.is_empty() { "Specify first endpoint of edge" } else { "Specify second endpoint of edge" }, + Accept::POINT, + ) + .base_opt(self.edge_pts.first().copied()), + (Some(_), false, None, _) => Prompt::new("Specify center of polygon", Accept::POINT).kw(&["Edge"]), + (Some(_), false, Some(_), None) => Prompt::new("Enter an option", Accept::TEXT) + .kw(&["Inscribed in circle", "Circumscribed about circle"]) + .default(if s.last_used.polygon_circumscribed { "C" } else { "I" }), + (Some(_), false, Some(c), Some(_)) => Prompt::new("Specify radius of circle", Accept::POINT_OR_NUMBER).base(c).distance(), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + if self.sides.is_none() { + let n = match &i { + Input::Text(t) => t + .trim() + .parse::() + .ok() + .filter(|n| (3..=1024).contains(n)) + .ok_or_else(|| crate::EngineError::Other("Requires an integer between 3 and 1024.".into()))?, + Input::Enter => s.doc()?.header.i64("POLYSIDES", 4).clamp(3, 1024) as usize, + _ => return Ok(Step::Continue), + }; + self.sides = Some(n); + s.doc_mut()?.header.set_i64("POLYSIDES", n as i64); + return Ok(Step::Continue); + } + let n = self.sides.unwrap_or(4); + if self.edge { + if let Input::Point(p) = i { + if self.edge_pts.first().is_some_and(|a| a.near(p, 1e-12)) { + return Err(crate::EngineError::Other("Edge endpoints must differ.".into())); + } + self.edge_pts.push(p); + if self.edge_pts.len() == 2 { + s.add_entity(lwpoly(polygon_from_edge(self.edge_pts[0], self.edge_pts[1], n), true))?; + return Ok(Step::Done); + } + } + return Ok(Step::Continue); + } + match (self.center, self.inscribed, i) { + (None, _, Input::Keyword(k)) if k == "Edge" => self.edge = true, + (None, _, Input::Point(p)) => self.center = Some(p), + (Some(_), None, inp @ (Input::Keyword(_) | Input::Enter | Input::Text(_))) => { + // Enter takes the option used last time (the prompt's default). + let ins = match inp { + Input::Keyword(k) => k.starts_with('I'), + Input::Text(t) => !t.trim().to_ascii_lowercase().starts_with('c'), + _ => !s.last_used.polygon_circumscribed, + }; + self.inscribed = Some(ins); + s.last_used.polygon_circumscribed = !ins; + } + (Some(c), Some(ins), Input::Point(p)) => { + if c.near(p, 1e-12) { + return Err(crate::EngineError::Other("Value must be positive and nonzero.".into())); + } + s.add_entity(lwpoly(polygon_vertices(c, n, p, ins), true))?; + return Ok(Step::Done); + } + (Some(c), Some(ins), Input::Text(t)) => { + let r = number(&t).ok_or_else(|| crate::EngineError::Other("Requires numeric distance or point.".into()))?; + if !r.is_finite() || r <= 0.0 { + return Err(crate::EngineError::Other("Value must be positive and nonzero.".into())); + } + let rp = polygon_typed_radius_point(c, n, r, ins); + s.add_entity(lwpoly(polygon_vertices(c, n, rp, ins), true))?; + return Ok(Step::Done); + } + _ => {} + } + Ok(Step::Continue) + } + fn preview(&self, _s: &Session, c: Vec2) -> Vec { + let n = self.sides.unwrap_or(4); + if self.edge { + return self.edge_pts.first().map(|a| vec![lwpoly(polygon_from_edge(*a, c, n), true)]).unwrap_or_default(); + } + match (self.center, self.inscribed) { + (Some(ctr), Some(ins)) => vec![lwpoly(polygon_vertices(ctr, n, c, ins), true)], + _ => Vec::new(), + } + } +} diff --git a/crates/engine/src/cmd/draw/spline.rs b/crates/engine/src/cmd/draw/spline.rs new file mode 100644 index 000000000..f10a0dd80 --- /dev/null +++ b/crates/engine/src/cmd/draw/spline.rs @@ -0,0 +1,170 @@ +use super::*; + +pub(super) fn run_spline(s: &mut Session, p: &Value) -> Result { + if p.get("object").is_some() { + return crate::cmd::spline_opts::run_object(s, p); + } + let sp = if let Some(f) = points_param(p, "fit") { + if distinct_points(&f).len() < 2 { + return Err(bad("spline", "need 2+ different fit points")); + } + Spline::fit_with(&f, bool_or(p, "closed", false), crate::cmd::spline_opts::json_options(s, p)?) + } else if let Some(c) = points_param(p, "control") { + if c.len() < 2 || c.len() > crate::cmd::curves::MAX_GEN { + return Err(bad("spline", "need 2+ control points")); + } + // Degree 1..=10 as at the Degree prompt, `spline.cv` and DXF input (tessellation grows + // with its square). + Spline::from_control(c, p.get("degree").and_then(Value::as_u64).unwrap_or(3).clamp(1, 10) as usize) + } else { + return Err(bad("spline", "`fit` or `control` points are required")); + }; + added(s.add_entity(EntityKind::Spline(sp))?) +} + +#[derive(Default)] +pub(super) struct SplineM { + pts: Vec, + asking_method: bool, + /// `Method` > `CV` hands over to the control-vertex machine (`spline.cv`). + delegate: Option>, + /// Knots, end tangents and fit tolerance chosen so far. + opts: cadcraft_geom::FitOptions, + /// An option asking a further question (Knots, Tangency, toLerance, Object). + ask: Option, +} + +impl SplineM { + fn make(&self, closed: bool) -> Spline { + Spline::fit_with(&self.pts, closed, self.opts) + } +} + +impl Interactive for SplineM { + fn name(&self) -> &'static str { + "SPLINE" + } + fn begin(&mut self, s: &mut Session) -> Result { + self.opts.knots = crate::cmd::spline_opts::current_knots(s); + s.echo(format!("Current settings: Method=Fit Knots={}", self.opts.knots.name())); + Ok(Step::Continue) + } + fn prompt(&self, s: &Session) -> Prompt { + if let Some(d) = &self.delegate { + return d.prompt(s); + } + if let Some(a) = &self.ask { + return a.prompt(&self.opts, &self.pts); + } + if self.asking_method { + return Prompt::new("Enter spline creation method", crate::cmd::curves::KW).kw(&["Fit", "CV"]).default("Fit"); + } + match self.pts.len() { + 0 => Prompt::new("Specify first point", Accept::POINT).kw(&["Method", "Knots", "Object"]), + 1 => Prompt::new("Enter next point", Accept::POINT).kw(&["start Tangency", "toLerance"]).base_opt(self.pts.last().copied()), + 2 => Prompt::new("Enter next point", Accept::POINT).kw(&["end Tangency", "toLerance", "Undo"]).base_opt(self.pts.last().copied()), + _ => { + Prompt::new("Enter next point", Accept::POINT).kw(&["end Tangency", "toLerance", "Undo", "Close"]).base_opt(self.pts.last().copied()) + } + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + if let Some(d) = &mut self.delegate { + return d.input(s, i); + } + if let Some(a) = &mut self.ask { + use crate::cmd::spline_opts::Outcome; + return match a.input(s, i, &mut self.opts, &self.pts)? { + Outcome::Stay => Ok(Step::Continue), + Outcome::Back => { + self.ask = None; + Ok(Step::Continue) + } + Outcome::Finish => { + self.ask = None; + if self.pts.len() >= 2 { + s.add_entity(EntityKind::Spline(self.make(false)))?; + } + Ok(Step::Done) + } + Outcome::Done => Ok(Step::Done), + }; + } + if self.asking_method { + match i { + Input::Keyword(k) if k == "CV" => { + let Some(factory) = find_command("spline.cv").and_then(|c| c.interactive) else { + return Err(crate::EngineError::Other("CV: not available yet".into())); + }; + let mut m = factory(s)?; + self.asking_method = false; + let step = m.begin(s)?; + self.delegate = Some(m); + return Ok(step); + } + Input::Keyword(_) | Input::Enter => self.asking_method = false, + Input::Text(_) => return Err(crate::EngineError::Other("Invalid option keyword.".into())), + _ => {} + } + return Ok(Step::Continue); + } + match i { + Input::Point(p) => { + self.pts.push(p); + Ok(Step::Continue) + } + Input::Keyword(k) if k == "Undo" => { + self.pts.pop(); + Ok(Step::Continue) + } + Input::Keyword(k) if k == "Close" => { + // A smooth closed (periodic) curve, not a repeat of the first point. + s.add_entity(EntityKind::Spline(self.make(true)))?; + Ok(Step::Done) + } + Input::Keyword(k) if k == "Method" => { + self.asking_method = true; + Ok(Step::Continue) + } + Input::Keyword(k) => { + // Knots, Object, start/end Tangency, toLerance ask their own question. + self.ask = Some( + crate::cmd::spline_opts::SplineAsk::from_keyword(&k) + .ok_or_else(|| crate::EngineError::Other("Invalid option keyword.".into()))?, + ); + Ok(Step::Continue) + } + Input::Enter => { + if self.pts.len() >= 2 { + s.add_entity(EntityKind::Spline(self.make(false)))?; + } + Ok(Step::Done) + } + Input::Text(_) => Err(crate::EngineError::Other("Point or option keyword required.".into())), + _ => Ok(Step::Continue), + } + } + fn preview(&self, s: &Session, c: Vec2) -> Vec { + if let Some(d) = &self.delegate { + return d.preview(s, c); + } + if self.pts.is_empty() { + return Vec::new(); + } + let mut opts = self.opts; + let mut pts = self.pts.clone(); + match self.ask { + // Rubber-band the tangent being picked. + Some(crate::cmd::spline_opts::SplineAsk::StartTangent) => { + opts.start_tangent = self.pts.first().map(|f| c - *f).filter(|d| d.len() > 1e-12) + } + Some(crate::cmd::spline_opts::SplineAsk::EndTangent) => opts.end_tangent = self.pts.last().map(|l| c - *l).filter(|d| d.len() > 1e-12), + Some(_) => {} + None => pts.push(c), + } + if pts.len() < 2 { + return Vec::new(); + } + vec![EntityKind::Spline(Spline::fit_with(&pts, false, opts))] + } +} diff --git a/crates/engine/src/cmd/draw/text.rs b/crates/engine/src/cmd/draw/text.rs new file mode 100644 index 000000000..2ca220a9b --- /dev/null +++ b/crates/engine/src/cmd/draw/text.rs @@ -0,0 +1,274 @@ +use super::*; + +/// Justification code → (halign, valign). +pub(crate) fn justify(j: &str) -> Option<(HAlign, VAlign)> { + Some(match j.to_ascii_uppercase().as_str() { + "L" | "LEFT" => (HAlign::Left, VAlign::Baseline), + "C" | "CENTER" => (HAlign::Center, VAlign::Baseline), + "R" | "RIGHT" => (HAlign::Right, VAlign::Baseline), + "A" | "ALIGN" => (HAlign::Aligned, VAlign::Baseline), + "M" | "MIDDLE" => (HAlign::Middle, VAlign::Baseline), + "F" | "FIT" => (HAlign::Fit, VAlign::Baseline), + "TL" => (HAlign::Left, VAlign::Top), + "TC" => (HAlign::Center, VAlign::Top), + "TR" => (HAlign::Right, VAlign::Top), + "ML" => (HAlign::Left, VAlign::Middle), + "MC" => (HAlign::Center, VAlign::Middle), + "MR" => (HAlign::Right, VAlign::Middle), + "BL" => (HAlign::Left, VAlign::Bottom), + "BC" => (HAlign::Center, VAlign::Bottom), + "BR" => (HAlign::Right, VAlign::Bottom), + _ => return None, + }) +} + +pub(crate) fn make_text(s: &Session, at: Vec2, value: &str, height: f64, rotation: f64, j: (HAlign, VAlign)) -> EntityKind { + let style = s.doc().map(|d| d.header.str("TEXTSTYLE", "Standard")).unwrap_or_else(|_| "Standard".into()); + let (h, v) = j; + let left = h == HAlign::Left && v == VAlign::Baseline; + EntityKind::Text(Text { + insert: v3(at), + align_pt: if left { None } else { Some(v3(at)) }, + height, + value: value.into(), + rotation, + width_factor: 1.0, + oblique: 0.0, + style, + halign: h, + valign: v, + }) +} + +pub(super) fn run_text(s: &mut Session, p: &Value) -> Result { + let at = point_req("text", p, "at")?; + let text = str_param(p, "text").ok_or_else(|| bad("text", "`text` is required"))?; + let height = f64_or(p, "height", s.doc()?.header.f64("TEXTSIZE", 0.2)); + if height <= 0.0 { + return Err(bad("text", "height must be positive")); + } + let j = justify(str_param(p, "justify").unwrap_or("L")).ok_or_else(|| bad("text", "unknown justification"))?; + let k = make_text(s, at, text, height, f64_or(p, "rotation", 0.0).to_radians(), j); + added(s.add_entity(k)?) +} + +pub(super) fn run_mtext(s: &mut Session, p: &Value) -> Result { + let at = point_req("mtext", p, "at")?; + let text = str_param(p, "text").ok_or_else(|| bad("mtext", "`text` is required"))?; + let height = f64_or(p, "height", s.doc()?.header.f64("TEXTSIZE", 0.2)); + if height <= 0.0 { + return Err(bad("mtext", "height must be positive")); + } + let style = match str_param(p, "style") { + Some(n) => s.doc()?.text_style(n).map(|t| t.name.clone()).ok_or_else(|| bad("mtext", format!("no text style \"{n}\"")))?, + None => s.doc()?.header.str("TEXTSTYLE", "Standard"), + }; + let attach = match str_param(p, "justify") { + Some(j) => crate::cmd::mtext_opts::attach_code(j).ok_or_else(|| bad("mtext", "`justify` must be TL, TC, TR, ML, MC, MR, BL, BC or BR"))?, + None => p.get("attach").and_then(Value::as_u64).unwrap_or(1).clamp(1, 9) as u8, + }; + let width = f64_or(p, "width", 0.0).max(0.0); + let k = EntityKind::MText(MText { + insert: v3(at), + height, + width, + attach, + rotation: f64_or(p, "rotation", 0.0).to_radians(), + style, + contents: crate::cmd::mtext_opts::json_paragraphs(p, text.replace('\n', "\\P"), height)?, + line_spacing: f64_or(p, "lineSpacing", 1.0), + line_spacing_exact: bool_or(p, "lineSpacingExact", false), + columns: crate::cmd::mtext_opts::json_columns(p, height, width)?, + }); + added(s.add_entity(k)?) +} + +pub(super) struct TextM { + at: Option, + height: Option, + rotation: Option, + justify: (HAlign, VAlign), + asking_justify: bool, + line: usize, + /// Run by a LISP routine (`command`): one line of text ends the command, as AutoLISP's + /// `command` runs TEXT; typed and in scripts each line asks for the next. + single_line: bool, +} + +impl TextM { + pub(super) fn new(s: &Session) -> Self { + TextM { + at: None, + height: None, + rotation: None, + justify: (HAlign::Left, VAlign::Baseline), + asking_justify: false, + line: 0, + single_line: s.lisp_running(), + } + } +} + +impl Interactive for TextM { + fn name(&self) -> &'static str { + "TEXT" + } + fn prompt(&self, s: &Session) -> Prompt { + let th = s.doc().map(|d| d.header.f64("TEXTSIZE", 0.2)).unwrap_or(0.2); + if self.asking_justify { + return Prompt::new("Enter an option", Accept::TEXT) + .kw(&["Left", "Center", "Right", "Align", "Middle", "Fit", "TL", "TC", "TR", "ML", "MC", "MR", "BL", "BC", "BR"]); + } + match (self.at, self.height, self.rotation) { + (None, _, _) => Prompt::new("Specify start point of text", Accept::POINT).kw(&["Justify", "Style"]), + (Some(a), None, _) => Prompt::new("Specify height", Accept::POINT_OR_NUMBER).default(format!("{th:.4}")).base(a), + (Some(a), Some(_), None) => Prompt::new("Specify rotation angle of text", Accept::POINT_OR_NUMBER).default("0").base(a), + _ => Prompt::new("Enter text", Accept::TEXT), + } + } + fn begin(&mut self, s: &mut Session) -> Result { + let d = s.doc()?; + let style = d.header.str("TEXTSTYLE", "Standard"); + let anno = if d.text_style(&style).is_some_and(|st| st.annotative) { "Yes" } else { "No" }; + let msg = + format!("Current text style: \"{style}\" Text height: {:.4} Annotative: {anno} Justify: Left", d.header.f64("TEXTSIZE", 0.2)); + s.echo(msg); + Ok(Step::Continue) + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + if self.asking_justify { + let code = match &i { + Input::Keyword(k) | Input::Text(k) => k.clone(), + _ => return Ok(Step::Continue), + }; + self.justify = justify(&code).unwrap_or(self.justify); + self.asking_justify = false; + return Ok(Step::Continue); + } + let th = s.doc()?.header.f64("TEXTSIZE", 0.2); + match (self.at, self.height, self.rotation, i) { + (None, _, _, Input::Keyword(k)) if k == "Justify" => self.asking_justify = true, + (None, _, _, Input::Point(p)) => self.at = Some(p), + (Some(_), None, _, Input::Enter) => self.height = Some(th), + (Some(a), None, _, Input::Point(p)) => self.height = Some(a.dist(p).max(1e-9)), + (Some(_), None, _, Input::Text(t)) => { + let h = number(&t).filter(|h| *h > 0.0).ok_or_else(|| crate::EngineError::Other("Requires a positive height.".into()))?; + self.height = Some(h); + s.doc_mut()?.header.set_f64("TEXTSIZE", h); + } + (Some(_), Some(_), None, Input::Enter) => self.rotation = Some(0.0), + (Some(a), Some(_), None, Input::Point(p)) => self.rotation = Some(a.angle_to(p)), + (Some(_), Some(_), None, Input::Text(t)) => { + self.rotation = Some(s.angle_settings().direction(&t).ok_or_else(|| crate::EngineError::Other("Requires an angle.".into()))?) + } + (Some(a), Some(h), Some(r), Input::Text(t)) => { + let at = a + Vec2::from_angle(r - std::f64::consts::FRAC_PI_2) * (h * 5.0 / 3.0 * self.line as f64); + let k = make_text(s, at, &t, h, r, self.justify); + s.add_entity(k)?; + self.line += 1; + if self.single_line { + return Ok(Step::Done); + } + } + (Some(_), Some(_), Some(_), Input::Enter) => return Ok(Step::Done), + _ => {} + } + Ok(Step::Continue) + } +} + +#[derive(Default)] +pub(super) struct MTextM { + first: Option, + second: Option, + /// Paragraphs typed so far: one per input line, an empty line ends the text (as on + /// AutoCAD's command line and in scripts). + lines: Vec, + /// Height, justification, line spacing, rotation, style and width set by the options. + opts: crate::cmd::mtext_opts::MTextOpts, + /// An option asking a further question. + ask: Option, +} + +impl MTextM { + /// The box is known: the opposite corner was picked or the Width option answered. + fn boxed(&self) -> bool { + self.second.is_some() || self.opts.width.is_some() + } +} + +impl Interactive for MTextM { + fn name(&self) -> &'static str { + "MTEXT" + } + fn begin(&mut self, s: &mut Session) -> Result { + self.opts = crate::cmd::mtext_opts::MTextOpts::new(s); + let anno = s.doc()?.text_style(&self.opts.style).is_some_and(|st| st.annotative); + s.echo(format!( + "Current text style: \"{}\" Text height: {:.4} Annotative: {}", + self.opts.style, + self.opts.height, + if anno { "Yes" } else { "No" } + )); + Ok(Step::Continue) + } + fn prompt(&self, _s: &Session) -> Prompt { + if let (Some(a), Some(q)) = (self.first, self.ask) { + return q.prompt(&self.opts, a); + } + match self.first { + None => Prompt::new("Specify first corner", Accept::POINT), + Some(a) if !self.boxed() => Prompt::new("Specify opposite corner", Accept::POINT) + .kw(&["Height", "Justify", "Line spacing", "Rotation", "Style", "Width", "Columns"]) + .base(a), + _ if self.lines.is_empty() => Prompt::new("Enter text (use \\P for new paragraphs)", Accept::TEXT), + _ => Prompt::new("Enter next line of text (empty line to finish)", Accept::TEXT), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + if let (Some(a), Some(q)) = (self.first, self.ask) { + use crate::cmd::mtext_opts::Outcome; + self.ask = match q.input(s, i, &mut self.opts, a)? { + Outcome::Ask(next) => Some(next), + Outcome::Back | Outcome::Text => None, + }; + return Ok(Step::Continue); + } + match (self.first, self.boxed(), i) { + (None, _, Input::Point(p)) => self.first = Some(p), + (Some(_), false, Input::Point(p)) => self.second = Some(p), + (Some(_), false, Input::Keyword(k)) => { + self.ask = Some( + crate::cmd::mtext_opts::MTextAsk::from_keyword(&k).ok_or_else(|| crate::EngineError::Other("Invalid option keyword.".into()))?, + ); + } + (Some(_), true, Input::Text(t)) if !t.is_empty() => self.lines.push(t), + (Some(a), true, Input::Text(_) | Input::Enter) => { + if self.lines.is_empty() { + return Ok(Step::Done); + } + s.add_entity(EntityKind::MText(self.opts.mtext(a, self.second, self.lines.join("\\P"))))?; + return Ok(Step::Done); + } + (_, _, Input::Enter) => return Ok(Step::Done), + _ => {} + } + Ok(Step::Continue) + } + fn editing_mtext(&self) -> Option { + let a = self.first?; + (self.boxed() && self.ask.is_none()).then(|| self.opts.mtext(a, self.second, String::new())) + } + fn preview(&self, _s: &Session, c: Vec2) -> Vec { + match (self.first, self.boxed(), self.ask) { + (Some(a), false, None) => { + // The box, turned by the rotation about the first corner. + let r = self.opts.rotation; + let l = (c - a).rotate(-r); + let corners = [Vec2::ZERO, Vec2::new(l.x, 0.0), l, Vec2::new(0.0, l.y)]; + vec![lwpoly(corners.iter().map(|q| PolyVertex::new(a + q.rotate(r))).collect(), true)] + } + _ => Vec::new(), + } + } +} diff --git a/crates/engine/src/cmd/draw2.rs b/crates/engine/src/cmd/draw2.rs index c174994c1..b37ebbc68 100644 --- a/crates/engine/src/cmd/draw2.rs +++ b/crates/engine/src/cmd/draw2.rs @@ -14,6 +14,30 @@ use super::{CommandSpec, bad, bool_or, f64_or, f64_req, point_param, point_req, use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step}; +mod arc; +mod centermark; +mod curves3d; +mod divide; +mod ellipse; +mod rect; +mod revcloud; +mod tancircle; +mod wipeout; +mod xline; + +pub(crate) use arc::arc_machine; +use arc::{ArcContinueM, ArcV, run_arc_continue, run_arc_variant, seq}; +use centermark::{CenterM, run_centerline, run_centermark}; +use curves3d::{HelixM, Poly3dM, SplineCvM, run_3dpoly, run_helix, run_spline_cv}; +use divide::{DivideM, run_divide, run_measure}; +use ellipse::{run_ellipse_arc, run_ellipse_axis}; +pub(crate) use rect::{RectM2, run_rectang2}; +use revcloud::{CloudKind, RevcloudM, run_revcloud}; +pub(crate) use tancircle::TanCircleM; +use tancircle::{run_circle_ttr, run_circle_ttt, run_circle_variant}; +use wipeout::{WipeoutM, run_wipeout}; +pub(crate) use xline::{XlineM2, run_xline2}; + pub(crate) mod breakline; pub(crate) mod mline; @@ -148,2214 +172,5 @@ fn other(m: impl Into) -> EngineError { EngineError::Other(m.into()) } -// ===================================================================================== -// Generic point/value sequences (arc and circle variants) -// ===================================================================================== - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum ArcV { - /// The ARC command itself: three points, with options switching to the other variants. - ThreeP, - /// Start, end, then center. - Sec, - Sce, - Sca, - Scl, - Sea, - Sed, - Ser, - Cse, - Csa, - Csl, - CircleCd, - Circle2p, - Circle3p, -} - -/// What a step asks for. -#[derive(Clone, Copy, Debug)] -enum Ask { - Point, - /// Absolute angle from value `base` to the picked point, or a typed angle. - Angle(usize), - /// CCW angle at value `center` from the ray to value `from` to the ray to the picked point. - Sweep(usize, usize), - /// Distance from value `base`, or a typed distance (may be negative). - Dist(usize), -} - -#[derive(Clone, Copy, Debug)] -enum Val { - P(Vec2), - N(f64), -} - -impl Val { - fn p(self) -> Option { - if let Val::P(p) = self { Some(p) } else { None } - } - fn n(self) -> Option { - if let Val::N(n) = self { Some(n) } else { None } - } -} - -impl ArcV { - fn name(self) -> &'static str { - match self { - ArcV::CircleCd | ArcV::Circle2p | ArcV::Circle3p => "CIRCLE", - _ => "ARC", - } - } - fn steps(self) -> &'static [(&'static str, Ask)] { - const S: &str = "Specify start point of arc"; - const C: &str = "Specify center point of arc"; - const E: &str = "Specify end point of arc"; - const A: &str = "Specify included angle"; - const L: &str = "Specify length of chord"; - match self { - ArcV::ThreeP => &[(S, Ask::Point), ("Specify second point of arc", Ask::Point), (E, Ask::Point)], - ArcV::Sec => &[(S, Ask::Point), (E, Ask::Point), (C, Ask::Point)], - ArcV::Sce => &[(S, Ask::Point), (C, Ask::Point), (E, Ask::Point)], - ArcV::Sca => &[(S, Ask::Point), (C, Ask::Point), (A, Ask::Sweep(1, 0))], - ArcV::Scl => &[(S, Ask::Point), (C, Ask::Point), (L, Ask::Dist(0))], - ArcV::Sea => &[(S, Ask::Point), (E, Ask::Point), (A, Ask::Angle(0))], - ArcV::Sed => &[(S, Ask::Point), (E, Ask::Point), ("Specify tangent direction for the start point of arc", Ask::Angle(0))], - ArcV::Ser => &[(S, Ask::Point), (E, Ask::Point), ("Specify radius of arc", Ask::Dist(1))], - ArcV::Cse => &[(C, Ask::Point), (S, Ask::Point), ("Specify end point of arc (hold Ctrl to switch direction)", Ask::Point)], - ArcV::Csa => &[(C, Ask::Point), (S, Ask::Point), (A, Ask::Sweep(0, 1))], - ArcV::Csl => &[(C, Ask::Point), (S, Ask::Point), (L, Ask::Dist(1))], - ArcV::CircleCd => &[("Specify center point for circle", Ask::Point), ("Specify diameter of circle", Ask::Dist(0))], - ArcV::Circle2p => { - &[("Specify first end point of circle's diameter", Ask::Point), ("Specify second end point of circle's diameter", Ask::Point)] - } - ArcV::Circle3p => &[ - ("Specify first point on circle", Ask::Point), - ("Specify second point on circle", Ask::Point), - ("Specify third point on circle", Ask::Point), - ], - } - } - fn build(self, v: &[Val], ctrl: bool) -> Option { - let p = |i: usize| v.get(i).and_then(|x| x.p()); - let n = |i: usize| v.get(i).and_then(|x| x.n()); - let a = match self { - ArcV::ThreeP => Arc::from_3_points(p(0)?, p(1)?, p(2)?), - ArcV::Sec => Some(Arc::from_start_center_end(p(0)?, p(2)?, p(1)?)), - ArcV::Sce => Some(Arc::from_start_center_end(p(0)?, p(1)?, p(2)?)), - ArcV::Sca => curves::arc_sca(p(0)?, p(1)?, n(2)?), - ArcV::Scl => curves::arc_scl(p(0)?, p(1)?, n(2)?), - ArcV::Sea => curves::arc_sea(p(0)?, p(1)?, n(2)?), - ArcV::Sed => curves::arc_sed(p(0)?, p(1)?, Vec2::from_angle(n(2)?)), - ArcV::Ser => curves::arc_ser(p(0)?, p(1)?, n(2)?), - // Center, Start, End. Ctrl keeps the two endpoints on the circle and walks the - // other way around: swapping the start/end angles is the mirror arc through the - // same two endpoints. (Continue below flips differently, by negating the start - // tangent; both are "reverse", but only here is the chord fixed by the picks.) - ArcV::Cse => { - let a = Arc::from_start_center_end(p(1)?, p(0)?, p(2)?); - Some(if ctrl { Arc::new(a.center, a.radius, a.end, a.start) } else { a }) - } - ArcV::Csa => curves::arc_sca(p(1)?, p(0)?, n(2)?), - ArcV::Csl => curves::arc_scl(p(1)?, p(0)?, n(2)?), - ArcV::CircleCd => { - let d = n(1)?.abs(); - let c = p(0)?; - return (d > 1e-12 && d.is_finite()).then(|| circle(c, d / 2.0)); - } - ArcV::Circle2p => { - let c = Circle::from_2_points(p(0)?, p(1)?); - return (c.radius > 1e-12).then(|| circle(c.center, c.radius)); - } - ArcV::Circle3p => { - let c = Circle::from_3_points(p(0)?, p(1)?, p(2)?)?; - return Some(circle(c.center, c.radius)); - } - }?; - (a.radius > 1e-12 && a.radius.is_finite() && a.center.is_finite()).then(|| arc(&a)) - } -} - -impl ArcV { - /// Options at step `i` and the variant each one switches to (the values so far carry over). - fn alts(self, i: usize) -> &'static [(&'static str, ArcV)] { - match (self, i) { - (ArcV::ThreeP, 0) => &[("Center", ArcV::Cse)], - (ArcV::ThreeP, 1) => &[("Center", ArcV::Sce), ("End", ArcV::Sec)], - (ArcV::Sce, 2) => &[("Angle", ArcV::Sca), ("chord Length", ArcV::Scl)], - (ArcV::Cse, 2) => &[("Angle", ArcV::Csa), ("chord Length", ArcV::Csl)], - (ArcV::Sec, 2) => &[("Angle", ArcV::Sea), ("Direction", ArcV::Sed), ("Radius", ArcV::Ser)], - _ => &[], - } - } -} - -fn seq(v: ArcV) -> Box { - Box::new(SeqM { v, vals: Vec::new(), cont: None }) -} - -/// The full ARC prompt sequence (all options; Enter at the first prompt continues the last -/// line or arc). -pub(crate) fn arc_machine() -> Box { - seq(ArcV::ThreeP) -} - -struct SeqM { - v: ArcV, - vals: Vec, - /// ARC continuation: start point and tangent. - cont: Option<(Vec2, Vec2)>, -} - -impl SeqM { - fn ask(&self) -> Option<(&'static str, Ask)> { - self.v.steps().get(self.vals.len()).copied() - } - fn value_from_point(&self, ask: Ask, q: Vec2) -> Option { - let pt = |i: usize| self.vals.get(i).and_then(|x| x.p()); - Some(match ask { - Ask::Point => Val::P(q), - Ask::Angle(b) => Val::N(pt(b)?.angle_to(q)), - Ask::Sweep(c, f) => { - let c = pt(c)?; - Val::N(norm_angle(c.angle_to(q) - c.angle_to(pt(f)?))) - } - Ask::Dist(b) => Val::N(pt(b)?.dist(q)), - }) - } - fn base(&self) -> Option { - match self.ask()?.1 { - Ask::Point => self.vals.last().and_then(|v| v.p()), - Ask::Angle(b) | Ask::Dist(b) => self.vals.get(b).and_then(|v| v.p()), - Ask::Sweep(c, _) => self.vals.get(c).and_then(|v| v.p()), - } - } -} - -impl Interactive for SeqM { - fn name(&self) -> &'static str { - self.v.name() - } - fn prompt(&self, _s: &Session) -> Prompt { - if let Some((st, _)) = self.cont { - return Prompt::new("Specify end point of arc (hold Ctrl to switch direction)", Accept::POINT).base(st); - } - let kws: Vec<&str> = self.v.alts(self.vals.len()).iter().map(|a| a.0).collect(); - let p = match self.ask() { - Some((m, Ask::Point)) => Prompt::new(m, Accept::POINT).base_opt(self.base()), - Some((m, _)) => Prompt::new(m, Accept::POINT_OR_NUMBER).base_opt(self.base()), - None => Prompt::new("", Accept::default()), - }; - p.kw(&kws) - } - fn input(&mut self, s: &mut Session, i: Input) -> Result { - if let Some((st, dir)) = self.cont { - // Ctrl flips the arc: negating the start tangent makes `arc_sed` pick the mirror - // arc (same two points and center, opposite sweep). `ctrl_down` comes from the - // UI's per-frame write on `Session`; the `Input` enum itself stays free of - // modifiers so it remains UI-agnostic. - let dir = if s.ctrl_down { -dir } else { dir }; - return match i { - Input::Point(e) => { - let a = curves::arc_sed(st, e, dir).ok_or_else(|| other("Invalid end point."))?; - s.add_entity(arc(&a))?; - Ok(Step::Done) - } - Input::Enter => Ok(Step::Cancel), - _ => Err(other("Point required.")), - }; - } - if let Input::Keyword(k) = &i - && let Some((_, v)) = self.v.alts(self.vals.len()).iter().find(|a| a.0 == k) - { - self.v = *v; - return Ok(Step::Continue); - } - if i == Input::Enter && self.v == ArcV::ThreeP && self.vals.is_empty() { - self.cont = curves::last_curve_end(s); - if self.cont.is_none() { - s.echo("No line or arc to continue."); - return Ok(Step::Cancel); - } - return Ok(Step::Continue); - } - let Some((_, ask)) = self.ask() else { return Ok(Step::Done) }; - let val = match (&i, ask) { - (Input::Point(q), _) => self.value_from_point(ask, *q), - (Input::Text(t), Ask::Angle(_) | Ask::Sweep(..)) => { - // The tangent direction is a direction; included angles are sizes. - let au = s.angle_settings(); - let a = if self.v == ArcV::Sed { au.direction(t) } else { au.amount(t) }; - Some(Val::N(a.ok_or_else(|| other("Requires a valid angle."))?)) - } - (Input::Text(t), Ask::Dist(_)) => Some(Val::N(number(t).ok_or_else(|| other("Requires numeric distance or a point."))?)), - (Input::Enter, _) => return Ok(Step::Cancel), - _ => return Err(other("Point or value required.")), - }; - let Some(val) = val else { return Ok(Step::Continue) }; - self.vals.push(val); - if self.vals.len() < self.v.steps().len() { - return Ok(Step::Continue); - } - match self.v.build(&self.vals, s.ctrl_down) { - Some(k) => { - if let EntityKind::Circle(c) = &k { - let r = c.radius; - s.add_entity(k)?; - s.doc_mut()?.header.set_f64("CIRCLERAD", r); - } else { - s.add_entity(k)?; - } - Ok(Step::Done) - } - None => { - self.vals.pop(); - Err(other("Invalid arc or circle for these values.")) - } - } - } - fn preview(&self, s: &Session, c: Vec2) -> Vec { - if let Some((st, d)) = self.cont { - let d = if s.ctrl_down { -d } else { d }; - return curves::arc_sed(st, c, d).map(|a| vec![arc(&a)]).unwrap_or_default(); - } - let Some((_, ask)) = self.ask() else { return Vec::new() }; - let Some(v) = self.value_from_point(ask, c) else { return Vec::new() }; - let mut vals = self.vals.clone(); - vals.push(v); - let mut out: Vec = self.base().map(|b| vec![line(b, c)]).unwrap_or_default(); - if vals.len() == self.v.steps().len() { - out.extend(self.v.build(&vals, s.ctrl_down)); - } - out - } -} - -fn direction_param(p: &Value) -> Option { - match p.get("direction") { - Some(v) if v.is_array() || v.is_object() => point_value(v).filter(|d| d.len() > 1e-12).map(Vec2::angle), - Some(v) => v.as_f64().filter(|x| x.is_finite()).map(f64::to_radians), - None => None, - } -} - -fn arc_from_params(p: &Value) -> Result { - let st = point_param(p, "start"); - let c = point_param(p, "center"); - let e = point_param(p, "end"); - let ang = p.get("angle").and_then(Value::as_f64).filter(|v| v.is_finite()).map(f64::to_radians); - let len = p.get("length").and_then(Value::as_f64).filter(|v| v.is_finite()); - let rad = p.get("radius").and_then(Value::as_f64).filter(|v| v.is_finite()); - let dir = direction_param(p); - let a = match (st, c, e) { - (Some(st), Some(c), Some(e)) => Some(Arc::from_start_center_end(st, c, e)), - (Some(st), Some(c), None) => match (ang, len) { - (Some(a), _) => curves::arc_sca(st, c, a), - (None, Some(l)) => curves::arc_scl(st, c, l), - _ => return Err(bad("arc", "start+center needs `end`, `angle` or `length`")), - }, - (Some(st), None, Some(e)) => match (ang, dir, rad) { - (Some(a), _, _) => curves::arc_sea(st, e, a), - (None, Some(d), _) => curves::arc_sed(st, e, Vec2::from_angle(d)), - (None, None, Some(r)) => curves::arc_ser(st, e, r), - _ => return Err(bad("arc", "start+end needs `angle`, `direction` or `radius`")), - }, - _ => return Err(bad("arc", "give start with center and/or end (see params)")), - }; - let a = a.filter(|a| a.radius > 1e-12 && a.radius.is_finite()).ok_or_else(|| bad("arc", "these values do not define an arc"))?; - Ok(arc(&a)) -} - -fn run_arc_variant(s: &mut Session, p: &Value) -> Result { - let k = arc_from_params(p)?; - added(s.add_entity(k)?) -} - -fn run_arc_continue(s: &mut Session, p: &Value) -> Result { - let e = point_req("arc.continue", p, "end")?; - let (st, dir) = match (point_param(p, "start"), direction_param(p)) { - (Some(st), Some(d)) => (st, Vec2::from_angle(d)), - _ => curves::last_curve_end(s).ok_or_else(|| bad("arc.continue", "no line or arc to continue from"))?, - }; - let a = curves::arc_sed(st, e, dir).ok_or_else(|| bad("arc.continue", "end point is on the tangent line"))?; - let h = s.add_entity(arc(&a))?; - // The next continuation starts where this arc was drawn to, not at its stored CCW end. - s.last_point = e; - added(h) -} - -#[derive(Default)] -struct ArcContinueM { - from: Option<(Vec2, Vec2)>, -} - -impl Interactive for ArcContinueM { - fn name(&self) -> &'static str { - "ARC" - } - fn begin(&mut self, s: &mut Session) -> Result { - self.from = curves::last_curve_end(s); - if self.from.is_none() { - s.echo("No line or arc to continue."); - return Ok(Step::Cancel); - } - Ok(Step::Continue) - } - fn prompt(&self, _s: &Session) -> Prompt { - Prompt::new("Specify end point of arc (hold Ctrl to switch direction)", Accept::POINT).base_opt(self.from.map(|f| f.0)) - } - fn input(&mut self, s: &mut Session, i: Input) -> Result { - let Some((st, dir)) = self.from else { return Ok(Step::Cancel) }; - // Ctrl flips the direction, exactly as in `SeqM::input`. - let dir = if s.ctrl_down { -dir } else { dir }; - match i { - Input::Point(e) => { - let a = curves::arc_sed(st, e, dir).ok_or_else(|| other("Invalid end point."))?; - s.add_entity(arc(&a))?; - Ok(Step::Done) - } - Input::Enter => Ok(Step::Cancel), - _ => Err(other("Point required.")), - } - } - fn preview(&self, s: &Session, c: Vec2) -> Vec { - self.from - .and_then(|(st, dir)| { - let dir = if s.ctrl_down { -dir } else { dir }; - curves::arc_sed(st, c, dir) - }) - .map(|a| vec![arc(&a)]) - .unwrap_or_default() - } -} - -fn run_circle_variant(s: &mut Session, p: &Value) -> Result { - let c = if let (Some(c), Some(d)) = (point_param(p, "center"), p.get("diameter").and_then(Value::as_f64)) { - Circle::new(c, d / 2.0) - } else if let (Some(a), Some(b)) = (point_param(p, "p1"), point_param(p, "p2")) { - match point_param(p, "p3") { - Some(c3) => Circle::from_3_points(a, b, c3).ok_or_else(|| bad("circle", "points are collinear"))?, - None => Circle::from_2_points(a, b), - } - } else { - return Err(bad("circle", "give {center, diameter}, {p1, p2} or {p1, p2, p3}")); - }; - if !(c.radius > 1e-12 && c.radius.is_finite()) { - return Err(bad("circle", "radius must be positive")); - } - let h = s.add_entity(circle(c.center, c.radius))?; - s.doc_mut()?.header.set_f64("CIRCLERAD", c.radius); - added(h) -} - -// ---------------- tangent circles ---------------- - -fn tan_obj(s: &Session, h: Handle, pick: Vec2) -> Result { - let e = curves::entity(s, h)?; - TanObj::from_kind(&e.kind, pick).ok_or_else(|| other("Select a line, circle, arc or polyline segment.")) -} - -fn pick_pair(cmd: &str, p: &Value, hk: &str, pk: &str) -> Result<(Handle, Vec2)> { - let h = curves::handle_param(p, hk).ok_or_else(|| bad(cmd, format!("`{hk}` is required")))?; - let q = point_req(cmd, p, pk)?; - Ok((h, q)) -} - -fn run_circle_ttr(s: &mut Session, p: &Value) -> Result { - let (h1, p1) = pick_pair("circle.ttr", p, "h1", "p1")?; - let (h2, p2) = pick_pair("circle.ttr", p, "h2", "p2")?; - let r = f64_req("circle.ttr", p, "radius")?; - let c = curves::ttr(tan_obj(s, h1, p1)?, p1, tan_obj(s, h2, p2)?, p2, r).ok_or_else(|| other("Circle does not exist."))?; - let h = s.add_entity(circle(c.center, c.radius))?; - s.doc_mut()?.header.set_f64("CIRCLERAD", c.radius); - added(h) -} - -fn run_circle_ttt(s: &mut Session, p: &Value) -> Result { - let (h1, p1) = pick_pair("circle.ttt", p, "h1", "p1")?; - let (h2, p2) = pick_pair("circle.ttt", p, "h2", "p2")?; - let (h3, p3) = pick_pair("circle.ttt", p, "h3", "p3")?; - let objs = [(tan_obj(s, h1, p1)?, p1), (tan_obj(s, h2, p2)?, p2), (tan_obj(s, h3, p3)?, p3)]; - let c = curves::ttt(objs).ok_or_else(|| other("Circle does not exist."))?; - added(s.add_entity(circle(c.center, c.radius))?) -} - -/// TTR (two picks + radius) or TTT (three picks). Also used by CIRCLE's `Ttr` option. -pub(crate) struct TanCircleM { - radius_mode: bool, - picks: Vec<(TanObj, Vec2)>, -} - -impl TanCircleM { - pub(crate) fn new(radius_mode: bool) -> Self { - TanCircleM { radius_mode, picks: Vec::new() } - } -} - -impl Interactive for TanCircleM { - fn name(&self) -> &'static str { - "CIRCLE" - } - fn prompt(&self, s: &Session) -> Prompt { - let n = self.picks.len(); - if self.radius_mode { - match n { - 0 => Prompt::new("Specify point on object for first tangent of circle", Accept::POINT), - 1 => Prompt::new("Specify point on object for second tangent of circle", Accept::POINT), - _ => { - let r = s.doc().map(|d| d.header.f64("CIRCLERAD", 0.0)).unwrap_or(0.0); - let p = Prompt::new("Specify radius of circle", Accept::POINT_OR_NUMBER).base_opt(self.picks.last().map(|x| x.1)).distance(); - if r > 0.0 { p.default(format!("{r:.4}")) } else { p } - } - } - } else { - let m = ["Specify first point on circle: _tan to", "Specify second point on circle: _tan to", "Specify third point on circle: _tan to"]; - Prompt::new(m.get(n).copied().unwrap_or(""), Accept::POINT) - } - } - fn input(&mut self, s: &mut Session, i: Input) -> Result { - let need = if self.radius_mode { 2 } else { 3 }; - if self.picks.len() < need { - return match i { - Input::Point(p) => { - let h = curves::pick_at(s, p).ok_or_else(|| other("*Invalid selection*"))?; - let o = tan_obj(s, h, p)?; - self.picks.push((o, p)); - if !self.radius_mode && self.picks.len() == 3 { - let (Some(a), Some(b), Some(c)) = (self.picks.first(), self.picks.get(1), self.picks.get(2)) else { - return Ok(Step::Cancel); - }; - let c = curves::ttt([*a, *b, *c]).ok_or_else(|| { - self.picks.clear(); - other("Circle does not exist.") - })?; - s.add_entity(circle(c.center, c.radius))?; - return Ok(Step::Done); - } - Ok(Step::Continue) - } - Input::Enter => Ok(Step::Cancel), - _ => Err(other("Select an object.")), - }; - } - let r = match i { - Input::Point(p) => self.picks.last().map(|x| x.1.dist(p)).unwrap_or(0.0), - Input::Text(t) => number(&t).ok_or_else(|| other("Requires numeric distance or second point."))?, - Input::Enter => s.doc()?.header.f64("CIRCLERAD", 0.0), - _ => return Ok(Step::Continue), - }; - if r.is_nan() || r <= 0.0 { - return Err(other("Value must be positive and nonzero.")); - } - let ((o1, p1), (o2, p2)) = match (self.picks.first(), self.picks.get(1)) { - (Some(a), Some(b)) => (*a, *b), - _ => return Ok(Step::Cancel), - }; - match curves::ttr(o1, p1, o2, p2, r) { - Some(c) => { - s.add_entity(circle(c.center, c.radius))?; - s.doc_mut()?.header.set_f64("CIRCLERAD", r); - Ok(Step::Done) - } - None => { - s.echo("Circle does not exist."); - Ok(Step::Done) - } - } - } -} - -// ---------------- ellipse entries ---------------- - -fn ellipse_from_axis(p1: Vec2, p2: Vec2, half: f64) -> Option { - let c = p1.mid(p2); - let m = p2 - c; - let ml = m.len(); - let half = half.abs(); - if ml < 1e-12 || half < 1e-12 || !half.is_finite() { - return None; - } - Some(if half > ml { Ellipse::full(c, m.normalized().perp() * half, ml / half) } else { Ellipse::full(c, m, half / ml) }) -} - -fn axis_ellipse_params(cmd: &str, p: &Value) -> Result { - let p1 = point_req(cmd, p, "p1")?; - let p2 = point_req(cmd, p, "p2")?; - let half = match point_param(p, "p3") { - Some(p3) => { - let ax = Line::new(p1, p2); - ax.project(p3).dist(p3) - } - None => f64_req(cmd, p, "distance")?, - }; - ellipse_from_axis(p1, p2, half).ok_or_else(|| bad(cmd, "degenerate ellipse")) -} - -fn ellipse_kind(e: &Ellipse) -> EntityKind { - EntityKind::Ellipse(cadcraft_doc::Ellipse { center: v3(e.center), major: v3(e.major), ratio: e.ratio, start: e.start, end: e.end }) -} - -fn run_ellipse_axis(s: &mut Session, p: &Value) -> Result { - let e = axis_ellipse_params("ellipse.axis", p)?; - added(s.add_entity(ellipse_kind(&e))?) -} - -fn run_ellipse_arc(s: &mut Session, p: &Value) -> Result { - let mut e = if let Some(c) = point_param(p, "center") { - let m = point_req("ellipse.arc", p, "major")?; - let ratio = f64_req("ellipse.arc", p, "ratio")?; - if !(ratio > 0.0 && ratio <= 1.0) || m.len() < 1e-12 { - return Err(bad("ellipse.arc", "ratio must be in (0, 1] and major non-zero")); - } - Ellipse::full(c, m, ratio) - } else { - axis_ellipse_params("ellipse.arc", p)? - }; - // Angles are measured from the major axis (like the prompts); convert to parameters. - let to_param = |deg: f64| { - let a = deg.to_radians(); - let ge = Ellipse { start: 0.0, end: TAU, ..e }; - ge.param_of(e.center + Vec2::from_angle(a + e.major.angle())) - }; - let st = to_param(f64_req("ellipse.arc", p, "start")?); - let en = to_param(f64_req("ellipse.arc", p, "end")?); - e.start = st; - e.end = en; - added(s.add_entity(ellipse_kind(&e))?) -} - -// ===================================================================================== -// DIVIDE / MEASURE -// ===================================================================================== - -fn chain_of(s: &Session, h: Handle) -> Result { - let e = curves::entity(s, h)?; - Chain::of(&e.kind).ok_or_else(|| other("Cannot divide/measure that object.")) -} - -fn place_markers(s: &mut Session, at: &[(Vec2, Vec2)], block: Option<&str>, align: bool) -> Result> { - let mut out = Vec::new(); - for (p, t) in at { - let h = match block { - Some(b) => super::blocks::insert(s, b, *p, 1.0, if align { t.angle() } else { 0.0 }, &serde_json::Map::new())?, - None => s.add_entity(EntityKind::Point(Point { p: v3(*p), angle: 0.0 }))?, - }; - out.push(h.hex()); - } - Ok(out) -} - -fn check_block(s: &Session, b: Option<&str>) -> Result<()> { - if let Some(b) = b - && s.doc()?.block(b).is_none() - { - return Err(other(format!("Block \"{b}\" not found."))); - } - Ok(()) -} - -pub(crate) fn divide_points(c: &Chain, n: usize) -> Vec<(Vec2, Vec2)> { - let l = c.len(); - let range = if c.closed { 0..n } else { 1..n }; - range.filter_map(|i| c.at_length(l * i as f64 / n as f64)).collect() -} - -pub(crate) fn measure_points(c: &Chain, step: f64) -> Vec<(Vec2, Vec2)> { - let l = c.len(); - let mut out = Vec::new(); - let mut k = 1usize; - while (k as f64) * step < l - 1e-9 && out.len() < MAX_GEN { - let d = k as f64 * step; - out.extend(c.at_length(d)); - k += 1; - } - out -} - -fn run_divide(s: &mut Session, p: &Value) -> Result { - let h = targets(s, p)?.first().copied().ok_or_else(|| bad("divide", "`handle` is required"))?; - let n = p.get("segments").and_then(Value::as_u64).ok_or_else(|| bad("divide", "`segments` (2..32767) is required"))?; - if !(2..=32767).contains(&n) { - return Err(bad("divide", "segments must be 2..32767")); - } - let block = str_param(p, "block").map(str::to_string); - check_block(s, block.as_deref())?; - let c = chain_of(s, h)?; - let pts = divide_points(&c, n as usize); - let hs = place_markers(s, &pts, block.as_deref(), bool_or(p, "align", true))?; - Ok(json!({ "handles": hs })) -} - -fn run_measure(s: &mut Session, p: &Value) -> Result { - let h = targets(s, p)?.first().copied().ok_or_else(|| bad("measure", "`handle` is required"))?; - let step = f64_req("measure", p, "length")?; - let mut c = chain_of(s, h)?; - if step.is_nan() || step <= 0.0 || c.len() / step > MAX_GEN as f64 { - return Err(bad("measure", "length must be positive (and not tiny)")); - } - let block = str_param(p, "block").map(str::to_string); - check_block(s, block.as_deref())?; - if let Some(from) = point_param(p, "from") - && !c.closed - && let (Some(a), Some(b)) = (c.start(), c.end()) - && from.dist(b) < from.dist(a) - { - c = c.reversed(); - } - let pts = measure_points(&c, step); - let hs = place_markers(s, &pts, block.as_deref(), bool_or(p, "align", true))?; - Ok(json!({ "handles": hs })) -} - -#[derive(Clone, Copy, PartialEq, Eq)] -enum DivPhase { - Object, - Value, - BlockName, - Align, -} - -struct DivideM { - measure: bool, - obj: Option<(Handle, Vec2)>, - phase: DivPhase, - block: Option, - align: bool, -} - -impl DivideM { - fn new(measure: bool) -> Self { - DivideM { measure, obj: None, phase: DivPhase::Object, block: None, align: true } - } -} - -impl Interactive for DivideM { - fn name(&self) -> &'static str { - if self.measure { "MEASURE" } else { "DIVIDE" } - } - fn prompt(&self, _s: &Session) -> Prompt { - match self.phase { - DivPhase::Object => Prompt::new(if self.measure { "Select object to measure" } else { "Select object to divide" }, Accept::POINT), - DivPhase::Value => { - let m = if self.measure { "Specify length of segment" } else { "Enter the number of segments" }; - let p = Prompt::new(m, if self.measure { Accept::POINT_OR_NUMBER } else { Accept::NUMBER }); - let p = if self.measure { p.base_opt(self.obj.map(|o| o.1)) } else { p }; - if self.block.is_some() { p } else { p.kw(&["Block"]) } - } - DivPhase::BlockName => Prompt::new("Enter name of block to insert", Accept::TEXT), - DivPhase::Align => Prompt::new("Align block with object?", curves::KW).kw(&["Yes", "No"]).default("Y"), - } - } - fn input(&mut self, s: &mut Session, i: Input) -> Result { - match (self.phase, i) { - (DivPhase::Object, Input::Point(p)) => { - let h = curves::pick_at(s, p).ok_or_else(|| other("*Invalid selection*"))?; - chain_of(s, h)?; - self.obj = Some((h, p)); - self.phase = DivPhase::Value; - Ok(Step::Continue) - } - (DivPhase::Value, Input::Keyword(k)) if k == "Block" => { - self.phase = DivPhase::BlockName; - Ok(Step::Continue) - } - (DivPhase::BlockName, Input::Text(t) | Input::Keyword(t)) => { - let name = t.trim().to_string(); - check_block(s, Some(&name))?; - self.block = Some(name); - self.phase = DivPhase::Align; - Ok(Step::Continue) - } - (DivPhase::Align, Input::Keyword(k) | Input::Text(k)) => { - self.align = !k.trim().to_ascii_lowercase().starts_with('n'); - self.phase = DivPhase::Value; - Ok(Step::Continue) - } - (DivPhase::Align, Input::Enter) => { - self.phase = DivPhase::Value; - Ok(Step::Continue) - } - (DivPhase::Value, inp @ (Input::Text(_) | Input::Point(_))) => { - let Some((h, pick)) = self.obj else { return Ok(Step::Cancel) }; - let v = match inp { - Input::Text(t) => number(&t).ok_or_else(|| other("Requires a number."))?, - Input::Point(q) => pick.dist(q), - _ => return Ok(Step::Continue), - }; - let mut params = json!({ "handle": h.hex(), "align": self.align, "from": [pick.x, pick.y] }); - if let Some(b) = &self.block { - params["block"] = json!(b); - } - if self.measure { - params["length"] = json!(v); - run_measure(s, ¶ms)?; - } else { - if !(2.0..=32767.0).contains(&v) || v.fract() != 0.0 { - return Err(other("Requires an integer between 2 and 32767.")); - } - params["segments"] = json!(v as u64); - run_divide(s, ¶ms)?; - } - Ok(Step::Done) - } - (_, Input::Enter) => Ok(Step::Cancel), - _ => Err(other("Invalid input.")), - } - } -} - -// ===================================================================================== -// REVCLOUD -// ===================================================================================== - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum CloudKind { - Rect, - Poly, - Free, -} - -fn default_arc_len(s: &Session) -> f64 { - let d = s.doc().ok(); - let metric = d.is_some_and(|d| d.header.i64("MEASUREMENT", 0) == 1); - d.map(|d| d.header.f64("REVCLOUDARCLEN", if metric { 15.0 } else { 0.5 })).unwrap_or(0.5).max(1e-6) -} - -/// Bulged closed polyline vertices for a revision cloud around `boundary`. -pub(crate) fn cloud_vertices(boundary: &[Vec2], arc_len: f64, per_edge: bool, reverse: bool) -> Option> { - let mut pts: Vec = Vec::new(); - for p in boundary { - if p.is_finite() && pts.last().is_none_or(|l| !l.near(*p, 1e-9)) { - pts.push(*p); - } - } - while pts.len() > 1 && pts.first().zip(pts.last()).is_some_and(|(a, b)| a.near(*b, 1e-9)) { - pts.pop(); - } - if pts.len() < 3 || shoelace(&pts).abs() < 1e-12 { - return None; - } - if shoelace(&pts) < 0.0 { - pts.reverse(); - } - let n = pts.len(); - let perim: f64 = (0..n).filter_map(|i| Some(pts.get(i)?.dist(*pts.get((i + 1) % n)?))).sum(); - if !(arc_len.is_finite() && arc_len > 0.0) { - return None; - } - let arc_len = arc_len.max(perim / MAX_GEN as f64); - let mut out: Vec = Vec::new(); - if per_edge { - for i in 0..n { - let (Some(a), Some(b)) = (pts.get(i).copied(), pts.get((i + 1) % n).copied()) else { continue }; - let k = ((a.dist(b) / arc_len).round() as usize).clamp(1, MAX_GEN); - for j in 0..k { - out.push(a.lerp(b, j as f64 / k as f64)); - } - } - } else { - let chain = Chain { - segs: (0..n).filter_map(|i| Some(cadcraft_geom::Segment::Line(Line::new(*pts.get(i)?, *pts.get((i + 1) % n)?)))).collect(), - closed: true, - }; - let k = ((perim / arc_len).round() as usize).clamp(3, MAX_GEN); - for j in 0..k { - out.extend(chain.at_length(perim * j as f64 / k as f64).map(|x| x.0)); - } - } - let b = if reverse { -0.5 } else { 0.5 }; - Some(out.into_iter().map(|p| PolyVertex::with_bulge(p, b)).collect()) -} - -fn object_outline(s: &Session, h: Handle) -> Result> { - let e = curves::entity(s, h)?; - let tol = 1e-3; - let pts = match &e.kind { - EntityKind::LwPolyline(p) if p.closed => Polyline { vertices: p.vertices.clone(), closed: true }.tessellate(tol), - EntityKind::Circle(c) => { - let mut v = Vec::new(); - Circle::new(c.center.xy(), c.radius).tessellate(c.radius * 1e-3, &mut v); - v - } - EntityKind::Ellipse(el) => { - let mut v = Vec::new(); - Ellipse { center: el.center.xy(), major: el.major.xy(), ratio: el.ratio, start: el.start, end: el.end } - .tessellate(el.major.xy().len() * 1e-3, &mut v); - v - } - EntityKind::Spline(sp) if sp.closed => sp.tessellate(tol), - _ => return Err(other("Select a closed polyline, circle, ellipse or closed spline.")), - }; - Ok(pts) -} - -fn cloud_entity(vs: Vec, calligraphy: bool, arc_len: f64) -> EntityKind { - let vs = if calligraphy { vs.into_iter().map(|v| PolyVertex { start_width: 0.0, end_width: arc_len * 0.15, ..v }).collect() } else { vs }; - lwpoly(vs, true) -} - -fn run_revcloud(s: &mut Session, p: &Value) -> Result { - let arc_len = p.get("arcLength").and_then(Value::as_f64).filter(|v| v.is_finite() && *v > 0.0).unwrap_or_else(|| default_arc_len(s)); - let reverse = bool_or(p, "reverse", false); - let calligraphy = str_param(p, "style").is_some_and(|x| x.eq_ignore_ascii_case("calligraphy")); - let (pts, per_edge, src) = if let (Some(a), Some(b)) = (point_param(p, "p1"), point_param(p, "p2")) { - (rect_vertices(a, b).into_iter().map(|v| v.p).collect(), true, None) - } else if let Some(pts) = points_param(p, "points") { - (pts, true, None) - } else if let Some(pts) = points_param(p, "freehand") { - (pts, false, None) - } else if let Some(h) = curves::handle_param(p, "handle") { - (object_outline(s, h)?, false, Some(h)) - } else { - return Err(bad("revcloud", "give {p1, p2}, {points}, {freehand} or {handle}")); - }; - if pts.len() > MAX_GEN { - return Err(bad("revcloud", "too many points")); - } - let vs = cloud_vertices(&pts, arc_len, per_edge, reverse).ok_or_else(|| bad("revcloud", "the boundary needs an area"))?; - let h = s.add_entity(cloud_entity(vs, calligraphy, arc_len))?; - if let Some(src) = src { - let common = curves::entity(s, src)?.common; - let d = s.doc_mut()?; - d.modify_entity(h, |e| e.common = common)?; - d.remove_entity(src); - } - added(h) -} - -struct RevcloudM { - kind: CloudKind, - pts: Vec, - arc_len: f64, - asking_arc: bool, - object: bool, - calligraphy: bool, - asking_style: bool, -} - -impl RevcloudM { - fn new(s: &Session, kind: CloudKind) -> Self { - RevcloudM { kind, pts: Vec::new(), arc_len: default_arc_len(s), asking_arc: false, object: false, calligraphy: false, asking_style: false } - } - fn boundary(&self, extra: Option) -> Vec { - let mut pts = self.pts.clone(); - pts.extend(extra); - if self.kind == CloudKind::Rect - && let (Some(a), Some(b)) = (pts.first().copied(), pts.get(1).copied()) - { - return rect_vertices(a, b).into_iter().map(|v| v.p).collect(); - } - pts - } - fn finish(&mut self, s: &mut Session) -> Result { - let b = self.boundary(None); - match cloud_vertices(&b, self.arc_len, self.kind != CloudKind::Free, false) { - Some(vs) => { - s.add_entity(cloud_entity(vs, self.calligraphy, self.arc_len))?; - s.echo("Revision cloud finished."); - Ok(Step::Done) - } - None => Err(other("The revision cloud needs at least three points enclosing an area.")), - } - } -} - -impl Interactive for RevcloudM { - fn name(&self) -> &'static str { - "REVCLOUD" - } - fn begin(&mut self, s: &mut Session) -> Result { - let t = match self.kind { - CloudKind::Rect => "Rectangular", - CloudKind::Poly => "Polygonal", - CloudKind::Free => "Freehand", - }; - s.echo(format!("Minimum arc length: {0:.4} Maximum arc length: {0:.4} Style: Normal Type: {t}", self.arc_len)); - Ok(Step::Continue) - } - fn prompt(&self, _s: &Session) -> Prompt { - if self.asking_arc { - return Prompt::new("Specify minimum length of arc", Accept::NUMBER).default(format!("{:.4}", self.arc_len)); - } - if self.asking_style { - return Prompt::new("Select arc style", curves::KW).kw(&["Normal", "Calligraphy"]).default("Normal"); - } - if self.object { - return Prompt::new("Select object", Accept::POINT); - } - 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 { 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()), - (CloudKind::Free, 0) => Prompt::new("Specify first point", Accept::POINT).kw(first_kw).default("Object"), - (CloudKind::Free, _) => Prompt::new("Guide crosshairs along cloud path", Accept::POINT).base_opt(self.pts.last().copied()), - } - } - fn input(&mut self, s: &mut Session, i: Input) -> Result { - if self.asking_arc { - if let Input::Text(t) = &i { - let v = number(t).filter(|v| *v > 0.0).ok_or_else(|| other("Requires a positive value."))?; - self.arc_len = v; - s.doc_mut()?.header.set_f64("REVCLOUDARCLEN", v); - } - self.asking_arc = false; - return Ok(Step::Continue); - } - if self.asking_style { - if let Input::Keyword(k) | Input::Text(k) = &i { - self.calligraphy = k.to_ascii_lowercase().starts_with('c'); - } - self.asking_style = false; - return Ok(Step::Continue); - } - if self.object { - let Input::Point(p) = i else { return if i == Input::Enter { Ok(Step::Cancel) } else { Ok(Step::Continue) } }; - let h = curves::pick_at(s, p).ok_or_else(|| other("*Invalid selection*"))?; - let pts = object_outline(s, h)?; - let vs = cloud_vertices(&pts, self.arc_len, false, false).ok_or_else(|| other("The object has no area."))?; - let nh = s.add_entity(cloud_entity(vs, self.calligraphy, self.arc_len))?; - let common = curves::entity(s, h)?.common; - let d = s.doc_mut()?; - d.modify_entity(nh, |e| e.common = common)?; - d.remove_entity(h); - s.echo("Revision cloud finished."); - return Ok(Step::Done); - } - match i { - Input::Keyword(k) => { - match k.as_str() { - "Arc length" => self.asking_arc = true, - "Object" => self.object = true, - "Rectangular" => self.kind = CloudKind::Rect, - "Polygonal" => self.kind = CloudKind::Poly, - "Freehand" => self.kind = CloudKind::Free, - "Style" => self.asking_style = true, - "Undo" => { - self.pts.pop(); - } - _ => {} - } - Ok(Step::Continue) - } - Input::Point(p) => { - self.pts.push(p); - if self.kind == CloudKind::Rect && self.pts.len() == 2 { - return self.finish(s); - } - if self.pts.len() > MAX_GEN { - return self.finish(s); - } - Ok(Step::Continue) - } - Input::Enter if self.pts.is_empty() => { - self.object = true; - Ok(Step::Continue) - } - Input::Enter => self.finish(s), - _ => Ok(Step::Continue), - } - } - fn preview(&self, _s: &Session, c: Vec2) -> Vec { - if self.pts.is_empty() || self.object { - return Vec::new(); - } - let b = self.boundary(Some(c)); - match cloud_vertices(&b, self.arc_len, self.kind != CloudKind::Free, false) { - Some(vs) if vs.len() < 2000 => vec![lwpoly(vs, true)], - _ => { - let mut pts = self.pts.clone(); - pts.push(c); - vec![lwpoly(pts.into_iter().map(PolyVertex::new).collect(), false)] - } - } - } -} - -// ===================================================================================== -// WIPEOUT -// ===================================================================================== - -fn set_frames(s: &mut Session, mode: &str) -> Result { - let v = match mode.trim().to_ascii_lowercase().as_str() { - "on" | "1" => 1, - "off" | "0" => 0, - "display" | "d" | "2" | "display but not plot" => 2, - _ => return Err(other("Frames mode must be ON, OFF or Display.")), - }; - s.doc_mut()?.header.set_i64("WIPEOUTFRAME", v); - Ok(v) -} - -fn wipeout_from_polyline(s: &mut Session, h: Handle, erase: bool) -> Result { - let e = curves::entity(s, h)?; - let EntityKind::LwPolyline(p) = &e.kind else { return Err(other("Select a closed polyline.")) }; - if !p.closed || p.vertices.len() < 3 { - return Err(other("The polyline must be closed with at least three vertices.")); - } - if p.vertices.iter().any(|v| v.bulge.abs() > 1e-12) { - return Err(other("Polylines with arc segments cannot be used for wipeouts.")); - } - let nh = s.add_entity(EntityKind::Wipeout(Wipeout { boundary: p.vertices.iter().map(|v| v.p).collect() }))?; - if erase { - s.doc_mut()?.remove_entity(h); - } - Ok(nh) -} - -fn run_wipeout(s: &mut Session, p: &Value) -> Result { - if let Some(f) = str_param(p, "frames") { - let v = set_frames(s, f)?; - return Ok(json!({ "WIPEOUTFRAME": v })); - } - if let Some(h) = curves::handle_param(p, "handle") { - return added(wipeout_from_polyline(s, h, bool_or(p, "erase", false))?); - } - let pts = points_param(p, "points").ok_or_else(|| bad("wipeout", "`points` (3 or more) or `handle` is required"))?; - if pts.len() < 3 || pts.len() > MAX_GEN || shoelace(&pts).abs() < 1e-12 { - return Err(bad("wipeout", "need 3+ points enclosing an area")); - } - added(s.add_entity(EntityKind::Wipeout(Wipeout { boundary: pts }))?) -} - -#[derive(Default)] -struct WipeoutM { - pts: Vec, - phase: u8, // 0 points, 1 select polyline, 2 erase?, 3 frames - poly: Option, -} - -impl Interactive for WipeoutM { - fn name(&self) -> &'static str { - "WIPEOUT" - } - fn prompt(&self, _s: &Session) -> Prompt { - match (self.phase, self.pts.len()) { - (1, _) => Prompt::new("Select a closed polyline", Accept::POINT), - (2, _) => Prompt::new("Erase polyline?", curves::KW).kw(&["Yes", "No"]).default("No"), - (3, _) => Prompt::new("Enter mode", curves::KW).kw(&["ON", "OFF", "Display but not plot"]).default("ON"), - (_, 0) => Prompt::new("Specify first point", Accept::POINT).kw(&["Frames", "Polyline"]).default("Polyline"), - (_, 1) => Prompt::new("Specify next point", Accept::POINT).base_opt(self.pts.last().copied()), - (_, _) => Prompt::new("Specify next point", Accept::POINT).kw(&["Undo"]).base_opt(self.pts.last().copied()), - } - } - fn input(&mut self, s: &mut Session, i: Input) -> Result { - match (self.phase, i) { - (1, Input::Point(p)) => { - let h = curves::pick_at(s, p).ok_or_else(|| other("*Invalid selection*"))?; - let e = curves::entity(s, h)?; - if !matches!(&e.kind, EntityKind::LwPolyline(pl) if pl.closed) { - return Err(other("Select a closed polyline.")); - } - self.poly = Some(h); - self.phase = 2; - Ok(Step::Continue) - } - (2, inp @ (Input::Keyword(_) | Input::Text(_) | Input::Enter)) => { - let erase = matches!(&inp, Input::Keyword(k) | Input::Text(k) if k.to_ascii_lowercase().starts_with('y')); - let h = self.poly.ok_or_else(|| other("No polyline."))?; - wipeout_from_polyline(s, h, erase)?; - Ok(Step::Done) - } - (3, inp @ (Input::Keyword(_) | Input::Text(_) | Input::Enter)) => { - let m = match &inp { - Input::Keyword(k) | Input::Text(k) => k.clone(), - _ => "on".into(), - }; - let m = if m.to_ascii_lowercase().starts_with('d') { "display".to_string() } else { m }; - set_frames(s, &m)?; - Ok(Step::Done) - } - (0, Input::Keyword(k)) => { - match k.as_str() { - "Frames" => self.phase = 3, - "Polyline" => self.phase = 1, - "Undo" => { - self.pts.pop(); - } - _ => {} - } - Ok(Step::Continue) - } - (0, Input::Point(p)) => { - if self.pts.len() < MAX_GEN { - self.pts.push(p); - } - Ok(Step::Continue) - } - (0, Input::Enter) if self.pts.is_empty() => { - self.phase = 1; - Ok(Step::Continue) - } - (0, Input::Enter) => { - if self.pts.len() < 3 || shoelace(&self.pts).abs() < 1e-12 { - return Err(other("A wipeout needs three or more points enclosing an area.")); - } - s.add_entity(EntityKind::Wipeout(Wipeout { boundary: self.pts.clone() }))?; - Ok(Step::Done) - } - (_, Input::Enter) => Ok(Step::Cancel), - _ => Ok(Step::Continue), - } - } - fn preview(&self, _s: &Session, c: Vec2) -> Vec { - if self.phase != 0 || self.pts.is_empty() { - return Vec::new(); - } - let mut pts = self.pts.clone(); - pts.push(c); - vec![lwpoly(pts.into_iter().map(PolyVertex::new).collect(), true)] - } -} - -// ===================================================================================== -// XLINE with Hor/Ver/Ang/Bisect/Offset -// ===================================================================================== - -fn add_xline(s: &mut Session, base: Vec2, dir: Vec2) -> Result { - let d = dir.normalized(); - if d == Vec2::ZERO || !base.is_finite() { - return Err(other("Invalid direction.")); - } - s.add_entity(EntityKind::XLine(RayLine { base: v3(base), dir: v3(d) })) -} - -/// A line-like object for XLINE Offset/Reference: (base, direction). For a polyline, the -/// segment nearest `pick` (the first without one), which must be straight. -fn linear_of(s: &Session, h: Handle, pick: Option) -> Result<(Vec2, Vec2)> { - let e = curves::entity(s, h)?; - match &e.kind { - EntityKind::Line(l) => Ok((l.a.xy(), (l.b.xy() - l.a.xy()).normalized())), - EntityKind::XLine(r) | EntityKind::Ray(r) => Ok((r.base.xy(), r.dir.xy().normalized())), - EntityKind::LwPolyline(p) => { - let segs = Polyline { vertices: p.vertices.clone(), closed: p.closed }.segments(); - let seg = match pick { - Some(q) => segs.iter().min_by(|a, b| a.closest(q).dist(q).total_cmp(&b.closest(q).dist(q))), - None => segs.first(), - }; - match seg { - Some(cadcraft_geom::Segment::Line(l)) => Ok((l.a, l.dir())), - _ => Err(other("Select a line object.")), - } - } - _ => Err(other("Select a line object.")), - } -} - -fn bisect_dir(v: Vec2, a: Vec2, b: Vec2) -> Option { - let u1 = (a - v).normalized(); - let u2 = (b - v).normalized(); - if u1 == Vec2::ZERO || u2 == Vec2::ZERO { - return None; - } - let s = u1 + u2; - if s.len() < 1e-12 { Some(u1.perp()) } else { Some(s.normalized()) } -} - -fn offset_xline(base: Vec2, dir: Vec2, dist: f64, side: Vec2) -> (Vec2, Vec2) { - let n = dir.perp(); - let sgn = if n.dot(side - base) >= 0.0 { 1.0 } else { -1.0 }; - (base + n * (dist * sgn), dir) -} - -pub(crate) fn run_xline2(s: &mut Session, p: &Value) -> Result { - if let (Some(v), Some(a), Some(b)) = (point_param(p, "vertex"), point_param(p, "start"), point_param(p, "end")) { - let d = bisect_dir(v, a, b).ok_or_else(|| bad("xline", "degenerate angle"))?; - return added(add_xline(s, v, d)?); - } - if let Some(h) = curves::handle_param(p, "handle") { - let (base, dir) = linear_of(s, h, None)?; - let (nb, nd) = if let Some(t) = point_param(p, "through") { - (t, dir) - } else { - let dist = f64_req("xline", p, "distance")?; - let side = point_req("xline", p, "side")?; - offset_xline(base, dir, dist, side) - }; - return added(add_xline(s, nb, nd)?); - } - let b = point_param(p, "base").or_else(|| point_param(p, "through")).ok_or_else(|| bad("xline", "`base` is required"))?; - let dir = if bool_or(p, "hor", false) { - Vec2::X - } else if bool_or(p, "ver", false) { - Vec2::Y - } else if let (Some(t), Some(_)) = (point_param(p, "through"), point_param(p, "base")) { - t - b - } else { - Vec2::from_angle(f64_req("xline", p, "angle")?.to_radians()) - }; - if dir.len() < 1e-12 { - return Err(bad("xline", "through point equals base")); - } - added(add_xline(s, b, dir)?) -} - -#[derive(Clone, Copy, Debug, Default, PartialEq)] -enum XMode { - #[default] - Two, - Fixed(f64), - AngAsk, - RefSelect, - RefAngle(f64), - Bisect, - OffDist, - OffSelect, - OffSide, -} - -#[derive(Default)] -pub(crate) struct XlineM2 { - mode: XMode, - base: Option, - bis: Vec, - offset: Option, // None = Through - line: Option<(Vec2, Vec2)>, -} - -impl Interactive for XlineM2 { - fn name(&self) -> &'static str { - "XLINE" - } - fn prompt(&self, s: &Session) -> Prompt { - match self.mode { - XMode::Two => match self.base { - None => Prompt::new("Specify a point", Accept::POINT).kw(&["Hor", "Ver", "Ang", "Bisect", "Offset"]), - Some(b) => Prompt::new("Specify through point", Accept::POINT).base(b), - }, - XMode::Fixed(_) => Prompt::new("Specify through point", Accept::POINT), - XMode::AngAsk => Prompt::new("Enter angle of xline (0) or", Accept::NUMBER).kw(&["Reference"]), - XMode::RefSelect => Prompt::new("Select a line object", Accept::POINT), - XMode::RefAngle(_) => Prompt::new("Enter angle of xline", Accept::NUMBER).default("0"), - XMode::Bisect => match self.bis.len() { - 0 => Prompt::new("Specify angle vertex point", Accept::POINT), - 1 => Prompt::new("Specify angle start point", Accept::POINT).base_opt(self.bis.first().copied()), - _ => Prompt::new("Specify angle end point", Accept::POINT).base_opt(self.bis.first().copied()), - }, - XMode::OffDist => { - let d = s.doc().map(|d| d.header.f64("OFFSETDIST", 1.0)).unwrap_or(1.0); - Prompt::new("Specify offset distance or", Accept::NUMBER).kw(&["Through"]).default(if d < 0.0 { - "Through".into() - } else { - format!("{d:.4}") - }) - } - XMode::OffSelect => Prompt::new("Select a line object", Accept::POINT), - XMode::OffSide => Prompt::new(if self.offset.is_some() { "Specify side to offset" } else { "Specify through point" }, Accept::POINT), - } - } - fn input(&mut self, s: &mut Session, i: Input) -> Result { - match (self.mode, i) { - (_, Input::Enter) if matches!(self.mode, XMode::OffDist) => { - let d = s.doc()?.header.f64("OFFSETDIST", 1.0); - self.offset = (d >= 0.0).then_some(d); - self.mode = XMode::OffSelect; - Ok(Step::Continue) - } - (_, Input::Enter) => Ok(Step::Done), - (XMode::Two, Input::Keyword(k)) => { - self.mode = match k.as_str() { - "Hor" => XMode::Fixed(0.0), - "Ver" => XMode::Fixed(std::f64::consts::FRAC_PI_2), - "Ang" => XMode::AngAsk, - "Bisect" => XMode::Bisect, - _ => XMode::OffDist, - }; - Ok(Step::Continue) - } - (XMode::Two, Input::Point(p)) => { - match self.base { - None => self.base = Some(p), - Some(b) => { - if !b.near(p, 1e-12) { - add_xline(s, b, p - b)?; - } - } - } - Ok(Step::Continue) - } - (XMode::Fixed(a), Input::Point(p)) => { - add_xline(s, p, Vec2::from_angle(a))?; - Ok(Step::Continue) - } - (XMode::AngAsk, Input::Keyword(_)) => { - self.mode = XMode::RefSelect; - Ok(Step::Continue) - } - (XMode::AngAsk, Input::Text(t)) => { - let a = s.angle_settings().direction(&t).ok_or_else(|| other("Requires a valid angle."))?; - self.mode = XMode::Fixed(a); - Ok(Step::Continue) - } - (XMode::RefSelect, Input::Point(p)) => { - let h = curves::pick_at(s, p).ok_or_else(|| other("*Invalid selection*"))?; - let (_, d) = linear_of(s, h, Some(p))?; - self.mode = XMode::RefAngle(d.angle()); - Ok(Step::Continue) - } - (XMode::RefAngle(base), Input::Text(t)) => { - let a = s.angle_settings().rotation(&t).ok_or_else(|| other("Requires a valid angle."))?; - self.mode = XMode::Fixed(base + a); - Ok(Step::Continue) - } - (XMode::Bisect, Input::Point(p)) => { - if self.bis.len() < 2 { - self.bis.push(p); - } else if let (Some(v), Some(a)) = (self.bis.first().copied(), self.bis.get(1).copied()) { - let d = bisect_dir(v, a, p).ok_or_else(|| other("Invalid angle."))?; - add_xline(s, v, d)?; - } - Ok(Step::Continue) - } - (XMode::OffDist, Input::Keyword(_)) => { - self.offset = None; - s.doc_mut()?.header.set_f64("OFFSETDIST", -1.0); - self.mode = XMode::OffSelect; - Ok(Step::Continue) - } - (XMode::OffDist, Input::Text(t)) => { - let d = number(&t).filter(|d| *d >= 0.0).ok_or_else(|| other("Requires a non-negative distance."))?; - self.offset = Some(d); - s.doc_mut()?.header.set_f64("OFFSETDIST", d); - self.mode = XMode::OffSelect; - Ok(Step::Continue) - } - (XMode::OffSelect, Input::Point(p)) => { - let h = curves::pick_at(s, p).ok_or_else(|| other("*Invalid selection*"))?; - self.line = Some(linear_of(s, h, Some(p))?); - self.mode = XMode::OffSide; - Ok(Step::Continue) - } - (XMode::OffSide, Input::Point(p)) => { - let (b, d) = self.line.ok_or_else(|| other("No line selected."))?; - let (nb, nd) = match self.offset { - Some(dist) => offset_xline(b, d, dist, p), - None => (p, d), - }; - add_xline(s, nb, nd)?; - self.mode = XMode::OffSelect; - Ok(Step::Continue) - } - _ => Err(other("Invalid input.")), - } - } - fn preview(&self, _s: &Session, c: Vec2) -> Vec { - let xl = |b: Vec2, d: Vec2| { - let d = d.normalized(); - (d != Vec2::ZERO).then(|| EntityKind::XLine(RayLine { base: v3(b), dir: v3(d) })) - }; - match self.mode { - XMode::Two => self.base.and_then(|b| xl(b, c - b)).into_iter().collect(), - XMode::Fixed(a) => xl(c, Vec2::from_angle(a)).into_iter().collect(), - XMode::Bisect => match self.bis.as_slice() { - [v, a] => bisect_dir(*v, *a, c).and_then(|d| xl(*v, d)).into_iter().collect(), - _ => Vec::new(), - }, - XMode::OffSide => self - .line - .and_then(|(b, d)| { - let (nb, nd) = match self.offset { - Some(dist) => offset_xline(b, d, dist, c), - None => (c, d), - }; - xl(nb, nd) - }) - .into_iter() - .collect(), - _ => Vec::new(), - } - } -} - -// ===================================================================================== -// RECTANG with Area / Dimensions / Rotation / Width / Elevation / Thickness -// ===================================================================================== - -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); - if let EntityKind::LwPolyline(pl) = &mut k { - pl.const_width = width.max(0.0); - pl.elevation = elevation; - } - 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) { - 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") { - Some(q) => { - let loc = (q - a).rotate(-rot); - (if loc.x < 0.0 { -1.0 } else { 1.0 }, if loc.y < 0.0 { -1.0 } else { 1.0 }) - } - None => (1.0, 1.0), - }; - (l.abs() * sx, wd.abs() * sy) - } else if let Some(area) = p.get("area").and_then(Value::as_f64).filter(|v| v.is_finite()) { - if area <= 0.0 { - return Err(bad("rectang", "area must be positive")); - } - match (p.get("length").and_then(Value::as_f64), p.get("breadth").and_then(Value::as_f64)) { - (Some(l), _) if l.is_finite() && l > 0.0 => (l, area / l), - (_, Some(b)) if b.is_finite() && b > 0.0 => (area / b, b), - _ => return Err(bad("rectang", "area needs `length` or `breadth`")), - } - } else { - let b = point_req("rectang", p, "p2")?; - 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) } - }; - 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 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); - s.doc_mut()?.modify_entity(hd, |e| e.common.thickness = th)?; - added(hd) -} - -#[derive(Clone, Copy, Debug, PartialEq)] -enum RAsk { - Chamfer1, - Chamfer2, - Fillet, - Width, - Elevation, - Thickness, - Area, - AreaBasis, - AreaLength, - AreaWidth, - DimL, - DimW, - Rotation, - RotPick1, - RotPick2(Vec2), -} - -#[derive(Default)] -pub(crate) struct RectM2 { - first: Option, - /// The first point is the rectangle's centre (the `Center` option), not a corner. - center: bool, - fillet: f64, - chamfer: (f64, f64), - width: f64, - elevation: f64, - thickness: f64, - rotation: f64, - dims: Option<(f64, f64)>, - area: f64, - asking: Option, -} - -impl RectM2 { - /// Adds the rectangle of full size `w` × `h` with the collected options, drawing it from - /// the centre when centre mode is on, then applies the requested thickness. - fn make(&self, s: &mut Session, w: f64, h: f64) -> Result { - let Some(a) = self.first else { return Ok(Step::Cancel) }; - if w.abs() < 1e-12 || h.abs() < 1e-12 { - 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))?; - // 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 - /// point itself as a corner, or the first point less half the size around the centre. - fn origin(&self, w: f64, h: f64) -> Option { - let a = self.first?; - Some(if self.center { a - Vec2::new(w / 2.0, h / 2.0).rotate(self.rotation) } else { a }) - } - /// The rectangle's signed full size implied by the picked point `p`: explicit `dims` only - /// take their quadrant from `p`, otherwise `p` is a corner, doubled about the centre in - /// centre mode. - fn other_corner(&self, p: Vec2) -> Option<(f64, f64)> { - let a = self.first?; - let loc = (p - a).rotate(-self.rotation); - Some(match self.dims { - Some((l, w)) => (if loc.x < 0.0 { -l } else { l }, if loc.y < 0.0 { -w } else { w }), - // In centre mode the picked point is one corner, mirrored across the centre. - None if self.center => (2.0 * loc.x, 2.0 * loc.y), - None => (loc.x, loc.y), - }) - } -} - -impl Interactive for RectM2 { - fn name(&self) -> &'static str { - "RECTANG" - } - /// Asks for the pending option's value, or for the first and second points; in centre mode - /// the first prompt asks for the centre instead of a corner. - fn prompt(&self, _s: &Session) -> Prompt { - let num = |m: &str, d: f64| Prompt::new(m, Accept::NUMBER).default(format!("{d:.4}")); - if let Some(a) = self.asking { - return match a { - RAsk::Chamfer1 => num("Specify first chamfer distance for rectangles", self.chamfer.0), - RAsk::Chamfer2 => num("Specify second chamfer distance for rectangles", self.chamfer.1), - RAsk::Fillet => num("Specify fillet radius for rectangles", self.fillet), - RAsk::Width => num("Specify line width for rectangles", self.width), - RAsk::Elevation => num("Specify the elevation for rectangles", self.elevation), - RAsk::Thickness => num("Specify thickness for rectangles", self.thickness), - RAsk::Area => num("Enter area of rectangle in current units", if self.area > 0.0 { self.area } else { 100.0 }), - RAsk::AreaBasis => Prompt::new("Calculate rectangle dimensions based on", curves::KW).kw(&["Length", "Width"]).default("Length"), - RAsk::AreaLength => num("Enter rectangle length", 10.0), - RAsk::AreaWidth => num("Enter rectangle width", 10.0), - RAsk::DimL => num("Specify length for rectangles", self.dims.map(|d| d.0).unwrap_or(10.0)), - RAsk::DimW => num("Specify width for rectangles", self.dims.map(|d| d.1).unwrap_or(10.0)), - RAsk::Rotation => Prompt::new("Specify rotation angle or", Accept::POINT_OR_NUMBER) - .kw(&["Pick points"]) - .default(format!("{:.0}", self.rotation.to_degrees())) - .base_opt(self.first), - RAsk::RotPick1 => Prompt::new("Specify first point", Accept::POINT), - RAsk::RotPick2(b) => Prompt::new("Specify second point", Accept::POINT).base(b), - }; - } - match self.first { - None => { - let msg = if self.center { "Specify center point for rectangle" } else { "Specify first corner point" }; - Prompt::new(msg, Accept::POINT).kw(&["Chamfer", "CEnter", "Elevation", "Fillet", "Thickness", "Width"]) - } - Some(p) => Prompt::new("Specify other corner point", Accept::POINT).kw(&["Area", "Dimensions", "Rotation"]).corner(p), - } - } - /// Consumes one input: option keywords (`Center` only switches the first point's meaning - /// to the centre and asks nothing), option values, and the points that size the rectangle. - fn input(&mut self, s: &mut Session, i: Input) -> Result { - if let Some(a) = self.asking { - let val = |i: &Input, default: f64| -> Result { - match i { - Input::Text(t) => number(t).ok_or_else(|| other("Requires a number.")), - Input::Enter => Ok(default), - _ => Err(other("Requires a number.")), - } - }; - self.asking = None; - match a { - RAsk::Chamfer1 => { - self.chamfer.0 = val(&i, self.chamfer.0)?.max(0.0); - self.chamfer.1 = self.chamfer.0; - self.asking = Some(RAsk::Chamfer2); - } - RAsk::Chamfer2 => self.chamfer.1 = val(&i, self.chamfer.1)?.max(0.0), - RAsk::Fillet => self.fillet = val(&i, self.fillet)?.max(0.0), - RAsk::Width => self.width = val(&i, self.width)?.max(0.0), - RAsk::Elevation => self.elevation = val(&i, self.elevation)?, - RAsk::Thickness => self.thickness = val(&i, self.thickness)?, - RAsk::Area => { - let v = val(&i, if self.area > 0.0 { self.area } else { 100.0 })?; - if v <= 0.0 { - self.asking = Some(RAsk::Area); - return Err(other("Value must be positive.")); - } - self.area = v; - self.asking = Some(RAsk::AreaBasis); - } - RAsk::AreaBasis => { - let w = matches!(&i, Input::Keyword(k) | Input::Text(k) if k.to_ascii_lowercase().starts_with('w')); - self.asking = Some(if w { RAsk::AreaWidth } else { RAsk::AreaLength }); - } - RAsk::AreaLength | RAsk::AreaWidth => { - let v = val(&i, 10.0)?; - if v <= 0.0 { - self.asking = Some(a); - return Err(other("Value must be positive.")); - } - let (w, h) = if a == RAsk::AreaLength { (v, self.area / v) } else { (self.area / v, v) }; - return self.make(s, w, h); - } - RAsk::DimL => { - let l = val(&i, self.dims.map(|d| d.0).unwrap_or(10.0))?.abs(); - self.dims = Some((l, self.dims.map(|d| d.1).unwrap_or(10.0))); - self.asking = Some(RAsk::DimW); - } - RAsk::DimW => { - let w = val(&i, self.dims.map(|d| d.1).unwrap_or(10.0))?.abs(); - self.dims = Some((self.dims.map(|d| d.0).unwrap_or(10.0), w)); - } - RAsk::Rotation => match i { - Input::Keyword(_) => self.asking = Some(RAsk::RotPick1), - Input::Point(p) => self.rotation = self.first.map(|f| f.angle_to(p)).unwrap_or(0.0), - Input::Text(t) => self.rotation = s.angle_settings().direction(&t).ok_or_else(|| other("Requires a valid angle."))?, - _ => {} - }, - RAsk::RotPick1 => { - if let Input::Point(p) = i { - self.asking = Some(RAsk::RotPick2(p)); - } - } - RAsk::RotPick2(b) => { - if let Input::Point(p) = i { - self.rotation = b.angle_to(p); - } - } - } - return Ok(Step::Continue); - } - match i { - Input::Keyword(k) => { - // `Center` only switches the first point's meaning; it asks nothing. - if k == "CEnter" { - self.center = true; - return Ok(Step::Continue); - } - self.asking = Some(match k.as_str() { - "Chamfer" => RAsk::Chamfer1, - "Elevation" => RAsk::Elevation, - "Fillet" => RAsk::Fillet, - "Thickness" => RAsk::Thickness, - "Width" => RAsk::Width, - "Area" => RAsk::Area, - "Dimensions" => RAsk::DimL, - _ => RAsk::Rotation, - }); - Ok(Step::Continue) - } - Input::Point(p) => match self.first { - None => { - self.first = Some(p); - Ok(Step::Continue) - } - Some(_) => { - let (w, h) = self.other_corner(p).ok_or_else(|| other("No first corner."))?; - self.make(s, w, h) - } - }, - Input::Enter => Ok(Step::Cancel), - _ => Ok(Step::Continue), - } - } - /// The rubber-band rectangle for the cursor at `c`, mapped through the same size-and-origin - /// rules as the committed entity so centre mode previews where it will land. - fn preview(&self, _s: &Session, c: Vec2) -> Vec { - if self.asking.is_some() { - return Vec::new(); - } - 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::new(), - } - } -} - -// ===================================================================================== -// 3DPOLY -// ===================================================================================== - -fn point3_value(v: &Value) -> Option { - let a = v.as_array()?; - let x = a.first()?.as_f64()?; - let y = a.get(1)?.as_f64()?; - let z = a.get(2).and_then(Value::as_f64).unwrap_or(0.0); - let p = Vec3::new(x, y, z); - p.is_finite().then_some(p) -} - -fn run_3dpoly(s: &mut Session, p: &Value) -> Result { - let pts: Vec = p.get("points").and_then(Value::as_array).map(|a| a.iter().filter_map(point3_value).collect()).unwrap_or_default(); - if pts.len() < 2 || pts.len() > MAX_GEN { - return Err(bad("3dpoly", "`points` needs 2 or more [x,y,z] points")); - } - added(s.add_entity(EntityKind::Polyline3d(Polyline3d { points: pts, closed: bool_or(p, "closed", false) }))?) -} - -#[derive(Default)] -struct Poly3dM { - pts: Vec, - handle: Option, -} - -impl Poly3dM { - fn sync(&mut self, s: &mut Session, closed: bool) -> Result<()> { - let k = EntityKind::Polyline3d(Polyline3d { points: self.pts.iter().map(|p| v3(*p)).collect(), closed }); - match (self.handle, self.pts.len() >= 2) { - (Some(h), true) => s.doc_mut()?.modify_entity(h, |e| e.kind = k)?, - (None, true) => self.handle = Some(s.add_entity(k)?), - (Some(h), false) => { - s.doc_mut()?.remove_entity(h); - self.handle = None; - } - (None, false) => {} - } - Ok(()) - } -} - -impl Interactive for Poly3dM { - fn name(&self) -> &'static str { - "3DPOLY" - } - fn prompt(&self, _s: &Session) -> Prompt { - match self.pts.len() { - 0 => Prompt::new("Specify start point of polyline", Accept::POINT), - 1 | 2 => Prompt::new("Specify endpoint of line", Accept::POINT).kw(&["Undo"]).base_opt(self.pts.last().copied()), - _ => Prompt::new("Specify endpoint of line", Accept::POINT).kw(&["Close", "Undo"]).base_opt(self.pts.last().copied()), - } - } - fn input(&mut self, s: &mut Session, i: Input) -> Result { - match i { - Input::Point(p) => { - if self.pts.last().is_none_or(|l| !l.near(p, 1e-12)) && self.pts.len() < MAX_GEN { - self.pts.push(p); - self.sync(s, false)?; - } - Ok(Step::Continue) - } - Input::Keyword(k) if k == "Undo" => { - self.pts.pop(); - self.sync(s, false)?; - Ok(Step::Continue) - } - Input::Keyword(k) if k == "Close" => { - self.sync(s, true)?; - Ok(Step::Done) - } - Input::Enter => Ok(Step::Done), - _ => Ok(Step::Continue), - } - } - fn preview(&self, _s: &Session, c: Vec2) -> Vec { - self.pts.last().map(|l| vec![line(*l, c)]).unwrap_or_default() - } -} - -// ===================================================================================== -// HELIX (3D polyline approximation) -// ===================================================================================== - -/// A helix entity (base centre `c`, radii, height, turns, twist, start angle). -fn helix_kind(c: Vec2, rb: f64, rt: f64, height: f64, turns: f64, ccw: bool, a0: f64) -> EntityKind { - let h = Helix { center: c.to3(0.0), base_radius: rb, top_radius: rt, height, turns, start_angle: a0, ccw, constrain: HelixConstrain::Height }; - EntityKind::Helix(h.sanitized()) -} - -fn run_helix(s: &mut Session, p: &Value) -> Result { - let c = point_req("helix", p, "center")?; - let rb = f64_req("helix", p, "baseRadius")?; - let rt = f64_or(p, "topRadius", rb); - let turns = f64_or(p, "turns", 3.0); - if !(rb > 0.0 && rt >= 0.0 && turns > 0.0 && turns <= 500.0) { - return Err(bad("helix", "radii must be positive and turns in (0, 500]")); - } - let height = f64_or(p, "height", 1.0); - if height.abs() > 1e12 || rb > 1e12 || rt > 1e12 { - return Err(bad("helix", "sizes are out of range")); - } - added(s.add_entity(helix_kind(c, rb, rt, height, turns, bool_or(p, "ccw", true), f64_or(p, "startAngle", 0.0).to_radians()))?) -} - -#[derive(Default)] -struct HelixM { - center: Option, - rb: Option, - rt: Option, - turns: Option, - cw: bool, - diameter: bool, - asking: u8, // 0 main, 1 turns, 2 twist, 3 turn height - start: Option, -} - -impl HelixM { - fn turns(&self) -> f64 { - self.turns.unwrap_or(3.0) - } - fn create(&self, s: &mut Session, height: f64) -> Result { - let (Some(c), Some(rb)) = (self.center, self.rb) else { return Ok(Step::Cancel) }; - let rt = self.rt.unwrap_or(rb); - if !height.is_finite() || height.abs() > 1e12 { - return Err(other("Requires a distance.")); - } - s.add_entity(helix_kind(c, rb, rt, height, self.turns(), !self.cw, self.start.unwrap_or(0.0)))?; - Ok(Step::Done) - } -} - -impl Interactive for HelixM { - fn name(&self) -> &'static str { - "HELIX" - } - fn begin(&mut self, s: &mut Session) -> Result { - s.echo(format!("Number of turns = {:.4} Twist=CCW", self.turns())); - Ok(Step::Continue) - } - fn prompt(&self, _s: &Session) -> Prompt { - match self.asking { - 1 => return Prompt::new("Enter number of turns", Accept::NUMBER).default(format!("{:.4}", self.turns())), - 2 => return Prompt::new("Enter twist direction of helix", curves::KW).kw(&["CW", "CCW"]).default("CCW"), - 3 => return Prompt::new("Specify distance between turns", Accept::NUMBER), - _ => {} - } - match (self.center, self.rb, self.rt) { - (None, ..) => Prompt::new("Specify center point of base", Accept::POINT), - (Some(c), None, _) => { - Prompt::new(if self.diameter { "Specify base diameter" } else { "Specify base radius or" }, Accept::POINT_OR_NUMBER) - .kw(if self.diameter { &[] } else { &["Diameter"] }) - .default("1.0000") - .base(c) - } - (Some(c), Some(rb), None) => { - Prompt::new(if self.diameter { "Specify top diameter" } else { "Specify top radius or" }, Accept::POINT_OR_NUMBER) - .kw(if self.diameter { &[] } else { &["Diameter"] }) - .default(format!("{rb:.4}")) - .base(c) - } - (Some(c), Some(_), Some(_)) => Prompt::new("Specify helix height or", Accept::POINT_OR_NUMBER) - .kw(&["Axis endpoint", "Turns", "turn Height", "tWist"]) - .default("1.0000") - .base(c), - } - } - fn input(&mut self, s: &mut Session, i: Input) -> Result { - match self.asking { - 1 => { - if let Input::Text(t) = &i { - let v = number(t).filter(|v| *v > 0.0 && *v <= 500.0).ok_or_else(|| other("Turns must be in (0, 500]."))?; - self.turns = Some(v); - } - self.asking = 0; - return Ok(Step::Continue); - } - 2 => { - if let Input::Keyword(k) | Input::Text(k) = &i { - self.cw = k.eq_ignore_ascii_case("cw"); - } - self.asking = 0; - return Ok(Step::Continue); - } - 3 => { - self.asking = 0; - if let Input::Text(t) = &i { - let v = number(t).ok_or_else(|| other("Requires a distance."))?; - return self.create(s, v * self.turns()); - } - return Ok(Step::Continue); - } - _ => {} - } - let num = |i: &Input, base: Option| -> Option { - match i { - Input::Point(p) => base.map(|b| b.dist(*p)), - Input::Text(t) => number(t), - _ => None, - } - }; - match (self.center, self.rb, self.rt, &i) { - (None, _, _, Input::Point(p)) => { - self.center = Some(*p); - Ok(Step::Continue) - } - (Some(_), None | Some(_), _, Input::Keyword(k)) if k == "Diameter" => { - self.diameter = true; - Ok(Step::Continue) - } - (Some(c), None, _, _) => { - let v = match &i { - Input::Enter => Some(1.0), - _ => num(&i, Some(c)), - }; - let v = v.filter(|v| *v > 0.0).ok_or_else(|| other("Requires a positive distance."))?; - if let Input::Point(p) = &i { - self.start = Some(c.angle_to(*p)); - } - self.rb = Some(if self.diameter { v / 2.0 } else { v }); - self.diameter = false; - Ok(Step::Continue) - } - (Some(c), Some(rb), None, _) => { - let v = match &i { - Input::Enter => Some(if self.diameter { rb * 2.0 } else { rb }), - _ => num(&i, Some(c)), - }; - let v = v.filter(|v| *v >= 0.0).ok_or_else(|| other("Requires a distance."))?; - self.rt = Some(if self.diameter { v / 2.0 } else { v }); - Ok(Step::Continue) - } - (Some(c), Some(_), Some(_), _) => match &i { - Input::Keyword(k) => { - self.asking = match k.as_str() { - "Turns" => 1, - "tWist" => 2, - "turn Height" => 3, - _ => 0, - }; - Ok(Step::Continue) - } - Input::Enter => self.create(s, 1.0), - _ => { - let h = num(&i, Some(c)).ok_or_else(|| other("Requires a distance."))?; - self.create(s, h) - } - }, - _ => Ok(Step::Continue), - } - } - fn preview(&self, _s: &Session, c: Vec2) -> Vec { - match (self.center, self.rb, self.rt) { - (Some(ct), None, _) => vec![circle(ct, ct.dist(c))], - (Some(ct), Some(rb), None) => { - vec![helix_kind(ct, rb, ct.dist(c), 0.0, self.turns(), !self.cw, self.start.unwrap_or(0.0))] - } - _ => Vec::new(), - } - } -} - -// ===================================================================================== -// SPLINE by control vertices -// ===================================================================================== - -fn run_spline_cv(s: &mut Session, p: &Value) -> Result { - let mut c = points_param(p, "control").ok_or_else(|| bad("spline.cv", "`control` points are required"))?; - if c.len() < 2 || c.len() > MAX_GEN { - return Err(bad("spline.cv", "need 2+ control points")); - } - let closed = bool_or(p, "closed", false); - if closed && let Some(f) = c.first().copied() { - c.push(f); - } - let deg = p.get("degree").and_then(Value::as_u64).unwrap_or(3).clamp(1, 10) as usize; - let mut sp = Spline::from_control(c, deg); - sp.closed = closed; - added(s.add_entity(EntityKind::Spline(sp))?) -} - -#[derive(Default)] -struct SplineCvM { - pts: Vec, - degree: Option, - asking_degree: bool, -} - -impl Interactive for SplineCvM { - fn name(&self) -> &'static str { - "SPLINE" - } - fn begin(&mut self, s: &mut Session) -> Result { - s.echo(format!("Current settings: Method=CV Degree={}", self.degree.unwrap_or(3))); - Ok(Step::Continue) - } - fn prompt(&self, _s: &Session) -> Prompt { - if self.asking_degree { - return Prompt::new("Enter degree of spline", Accept::NUMBER).default(self.degree.unwrap_or(3).to_string()); - } - match self.pts.len() { - 0 => Prompt::new("Specify first point", Accept::POINT).kw(&["Method", "Degree", "Object"]), - 1 => Prompt::new("Enter next point", Accept::POINT).base_opt(self.pts.last().copied()), - 2 => Prompt::new("Enter next point", Accept::POINT).kw(&["Undo"]).base_opt(self.pts.last().copied()), - _ => Prompt::new("Enter next point", Accept::POINT).kw(&["Close", "Undo"]).base_opt(self.pts.last().copied()), - } - } - fn input(&mut self, s: &mut Session, i: Input) -> Result { - if self.asking_degree { - if let Input::Text(t) = &i { - let d = number(t).filter(|d| (1.0..=10.0).contains(d)).ok_or_else(|| other("Degree must be 1..10."))?; - self.degree = Some(d as usize); - } - self.asking_degree = false; - return Ok(Step::Continue); - } - let deg = self.degree.unwrap_or(3); - match i { - Input::Point(p) => { - if self.pts.len() < MAX_GEN { - self.pts.push(p); - } - Ok(Step::Continue) - } - Input::Keyword(k) => { - match k.as_str() { - "Degree" => self.asking_degree = true, - "Undo" => { - self.pts.pop(); - } - "Close" => { - let mut c = self.pts.clone(); - c.extend(self.pts.first().copied()); - let mut sp = Spline::from_control(c, deg); - sp.closed = true; - s.add_entity(EntityKind::Spline(sp))?; - return Ok(Step::Done); - } - _ => s.echo(format!("{k}: use SPLINE for fit points.")), - } - Ok(Step::Continue) - } - Input::Enter => { - if self.pts.len() >= 2 { - s.add_entity(EntityKind::Spline(Spline::from_control(self.pts.clone(), deg)))?; - } - Ok(Step::Done) - } - _ => Ok(Step::Continue), - } - } - fn preview(&self, _s: &Session, c: Vec2) -> Vec { - if self.pts.is_empty() { - return Vec::new(); - } - let mut pts = self.pts.clone(); - pts.push(c); - vec![EntityKind::Spline(Spline::from_control(pts, self.degree.unwrap_or(3)))] - } -} - -// ===================================================================================== -// CENTERMARK / CENTERLINE -// ===================================================================================== - -/// Add a line on the CENTER linetype (loading it from the standard library when missing). -fn add_center_line(s: &mut Session, a: Vec2, b: Vec2) -> Result { - let d = s.doc_mut()?; - if d.linetype("CENTER").is_none() - && let Some(lt) = cadcraft_doc::library::standard_linetypes_for(d).into_iter().find(|l| l.name == "CENTER") - { - d.linetypes.push(lt); - } - let h = s.add_entity(line(a, b))?; - s.doc_mut()?.modify_entity(h, |e| e.common.linetype = "CENTER".into())?; - Ok(h) -} - -fn center_ext(s: &Session) -> f64 { - let d = s.doc().ok(); - let metric = d.is_some_and(|d| d.header.i64("MEASUREMENT", 0) == 1); - d.map(|d| d.header.f64("CENTEREXE", if metric { 3.5 } else { 0.12 })).unwrap_or(0.12) -} - -fn center_mark(s: &mut Session, h: Handle) -> Result> { - let e = curves::entity(s, h)?; - let (c, r) = match &e.kind { - EntityKind::Circle(c) => (c.center.xy(), c.radius), - EntityKind::Arc(a) => (a.center.xy(), a.radius), - _ => return Err(other("Select a circle or arc.")), - }; - let ext = r + center_ext(s); - let h1 = add_center_line(s, c - Vec2::X * ext, c + Vec2::X * ext)?; - let h2 = add_center_line(s, c - Vec2::Y * ext, c + Vec2::Y * ext)?; - Ok(vec![h1.hex(), h2.hex()]) -} - -fn center_line(s: &mut Session, h1: Handle, h2: Handle) -> Result { - let l = |h: Handle| -> Result<(Vec2, Vec2)> { - match curves::entity(s, h)?.kind { - EntityKind::Line(l) => Ok((l.a.xy(), l.b.xy())), - _ => Err(other("Select a line.")), - } - }; - let (a1, b1) = l(h1)?; - let (mut a2, mut b2) = l(h2)?; - if (b1 - a1).dot(b2 - a2) < 0.0 { - std::mem::swap(&mut a2, &mut b2); - } - let (st, en) = (a1.mid(a2), b1.mid(b2)); - let dir = (en - st).normalized(); - if dir == Vec2::ZERO { - return Err(other("The lines do not define a center line.")); - } - let ext = center_ext(s); - add_center_line(s, st - dir * ext, en + dir * ext) -} - -fn run_centermark(s: &mut Session, p: &Value) -> Result { - let hs = targets(s, p)?; - if hs.is_empty() { - return Err(bad("centermark", "`handle` or `handles` is required")); - } - let mut out = Vec::new(); - for h in hs { - out.extend(center_mark(s, h)?); - } - Ok(json!({ "handles": out })) -} - -fn run_centerline(s: &mut Session, p: &Value) -> Result { - let h1 = curves::handle_param(p, "h1").ok_or_else(|| bad("centerline", "`h1` is required"))?; - let h2 = curves::handle_param(p, "h2").ok_or_else(|| bad("centerline", "`h2` is required"))?; - added(center_line(s, h1, h2)?) -} - -struct CenterM { - first: Option, - mark: bool, -} - -impl Interactive for CenterM { - fn name(&self) -> &'static str { - if self.mark { "CENTERMARK" } else { "CENTERLINE" } - } - fn begin(&mut self, s: &mut Session) -> Result { - s.set_selection(Vec::new()); - Ok(Step::Continue) - } - fn prompt(&self, _s: &Session) -> Prompt { - match (self.mark, self.first) { - (true, _) => Prompt::new("Select circle or arc to add center mark", Accept::POINT), - (false, None) => Prompt::new("Select first line", Accept::POINT), - (false, Some(_)) => Prompt::new("Select second line", Accept::POINT), - } - } - fn input(&mut self, s: &mut Session, i: Input) -> Result { - match i { - Input::Point(p) => { - let h = curves::pick_at(s, p).ok_or_else(|| other("*Invalid selection*"))?; - if self.mark { - center_mark(s, h)?; - return Ok(Step::Continue); - } - match self.first { - None => { - if !matches!(curves::entity(s, h)?.kind, EntityKind::Line(_)) { - return Err(other("Select a line.")); - } - self.first = Some(h); - Ok(Step::Continue) - } - Some(h1) => { - center_line(s, h1, h)?; - Ok(Step::Done) - } - } - } - Input::Enter => Ok(Step::Done), - _ => Ok(Step::Continue), - } - } -} - #[cfg(test)] -#[path = "draw2_tests.rs"] mod tests; diff --git a/crates/engine/src/cmd/draw2/arc.rs b/crates/engine/src/cmd/draw2/arc.rs new file mode 100644 index 000000000..f219348df --- /dev/null +++ b/crates/engine/src/cmd/draw2/arc.rs @@ -0,0 +1,381 @@ +use super::*; + +// ===================================================================================== +// Generic point/value sequences (arc and circle variants) +// ===================================================================================== + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum ArcV { + /// The ARC command itself: three points, with options switching to the other variants. + ThreeP, + /// Start, end, then center. + Sec, + Sce, + Sca, + Scl, + Sea, + Sed, + Ser, + Cse, + Csa, + Csl, + CircleCd, + Circle2p, + Circle3p, +} + +/// What a step asks for. +#[derive(Clone, Copy, Debug)] +enum Ask { + Point, + /// Absolute angle from value `base` to the picked point, or a typed angle. + Angle(usize), + /// CCW angle at value `center` from the ray to value `from` to the ray to the picked point. + Sweep(usize, usize), + /// Distance from value `base`, or a typed distance (may be negative). + Dist(usize), +} + +#[derive(Clone, Copy, Debug)] +enum Val { + P(Vec2), + N(f64), +} + +impl Val { + fn p(self) -> Option { + if let Val::P(p) = self { Some(p) } else { None } + } + fn n(self) -> Option { + if let Val::N(n) = self { Some(n) } else { None } + } +} + +impl ArcV { + fn name(self) -> &'static str { + match self { + ArcV::CircleCd | ArcV::Circle2p | ArcV::Circle3p => "CIRCLE", + _ => "ARC", + } + } + fn steps(self) -> &'static [(&'static str, Ask)] { + const S: &str = "Specify start point of arc"; + const C: &str = "Specify center point of arc"; + const E: &str = "Specify end point of arc"; + const A: &str = "Specify included angle"; + const L: &str = "Specify length of chord"; + match self { + ArcV::ThreeP => &[(S, Ask::Point), ("Specify second point of arc", Ask::Point), (E, Ask::Point)], + ArcV::Sec => &[(S, Ask::Point), (E, Ask::Point), (C, Ask::Point)], + ArcV::Sce => &[(S, Ask::Point), (C, Ask::Point), (E, Ask::Point)], + ArcV::Sca => &[(S, Ask::Point), (C, Ask::Point), (A, Ask::Sweep(1, 0))], + ArcV::Scl => &[(S, Ask::Point), (C, Ask::Point), (L, Ask::Dist(0))], + ArcV::Sea => &[(S, Ask::Point), (E, Ask::Point), (A, Ask::Angle(0))], + ArcV::Sed => &[(S, Ask::Point), (E, Ask::Point), ("Specify tangent direction for the start point of arc", Ask::Angle(0))], + ArcV::Ser => &[(S, Ask::Point), (E, Ask::Point), ("Specify radius of arc", Ask::Dist(1))], + ArcV::Cse => &[(C, Ask::Point), (S, Ask::Point), ("Specify end point of arc (hold Ctrl to switch direction)", Ask::Point)], + ArcV::Csa => &[(C, Ask::Point), (S, Ask::Point), (A, Ask::Sweep(0, 1))], + ArcV::Csl => &[(C, Ask::Point), (S, Ask::Point), (L, Ask::Dist(1))], + ArcV::CircleCd => &[("Specify center point for circle", Ask::Point), ("Specify diameter of circle", Ask::Dist(0))], + ArcV::Circle2p => { + &[("Specify first end point of circle's diameter", Ask::Point), ("Specify second end point of circle's diameter", Ask::Point)] + } + ArcV::Circle3p => &[ + ("Specify first point on circle", Ask::Point), + ("Specify second point on circle", Ask::Point), + ("Specify third point on circle", Ask::Point), + ], + } + } + fn build(self, v: &[Val], ctrl: bool) -> Option { + let p = |i: usize| v.get(i).and_then(|x| x.p()); + let n = |i: usize| v.get(i).and_then(|x| x.n()); + let a = match self { + ArcV::ThreeP => Arc::from_3_points(p(0)?, p(1)?, p(2)?), + ArcV::Sec => Some(Arc::from_start_center_end(p(0)?, p(2)?, p(1)?)), + ArcV::Sce => Some(Arc::from_start_center_end(p(0)?, p(1)?, p(2)?)), + ArcV::Sca => curves::arc_sca(p(0)?, p(1)?, n(2)?), + ArcV::Scl => curves::arc_scl(p(0)?, p(1)?, n(2)?), + ArcV::Sea => curves::arc_sea(p(0)?, p(1)?, n(2)?), + ArcV::Sed => curves::arc_sed(p(0)?, p(1)?, Vec2::from_angle(n(2)?)), + ArcV::Ser => curves::arc_ser(p(0)?, p(1)?, n(2)?), + // Center, Start, End. Ctrl keeps the two endpoints on the circle and walks the + // other way around: swapping the start/end angles is the mirror arc through the + // same two endpoints. (Continue below flips differently, by negating the start + // tangent; both are "reverse", but only here is the chord fixed by the picks.) + ArcV::Cse => { + let a = Arc::from_start_center_end(p(1)?, p(0)?, p(2)?); + Some(if ctrl { Arc::new(a.center, a.radius, a.end, a.start) } else { a }) + } + ArcV::Csa => curves::arc_sca(p(1)?, p(0)?, n(2)?), + ArcV::Csl => curves::arc_scl(p(1)?, p(0)?, n(2)?), + ArcV::CircleCd => { + let d = n(1)?.abs(); + let c = p(0)?; + return (d > 1e-12 && d.is_finite()).then(|| circle(c, d / 2.0)); + } + ArcV::Circle2p => { + let c = Circle::from_2_points(p(0)?, p(1)?); + return (c.radius > 1e-12).then(|| circle(c.center, c.radius)); + } + ArcV::Circle3p => { + let c = Circle::from_3_points(p(0)?, p(1)?, p(2)?)?; + return Some(circle(c.center, c.radius)); + } + }?; + (a.radius > 1e-12 && a.radius.is_finite() && a.center.is_finite()).then(|| arc(&a)) + } +} + +impl ArcV { + /// Options at step `i` and the variant each one switches to (the values so far carry over). + fn alts(self, i: usize) -> &'static [(&'static str, ArcV)] { + match (self, i) { + (ArcV::ThreeP, 0) => &[("Center", ArcV::Cse)], + (ArcV::ThreeP, 1) => &[("Center", ArcV::Sce), ("End", ArcV::Sec)], + (ArcV::Sce, 2) => &[("Angle", ArcV::Sca), ("chord Length", ArcV::Scl)], + (ArcV::Cse, 2) => &[("Angle", ArcV::Csa), ("chord Length", ArcV::Csl)], + (ArcV::Sec, 2) => &[("Angle", ArcV::Sea), ("Direction", ArcV::Sed), ("Radius", ArcV::Ser)], + _ => &[], + } + } +} + +pub(super) fn seq(v: ArcV) -> Box { + Box::new(SeqM { v, vals: Vec::new(), cont: None }) +} + +/// The full ARC prompt sequence (all options; Enter at the first prompt continues the last +/// line or arc). +pub(crate) fn arc_machine() -> Box { + seq(ArcV::ThreeP) +} + +struct SeqM { + v: ArcV, + vals: Vec, + /// ARC continuation: start point and tangent. + cont: Option<(Vec2, Vec2)>, +} + +impl SeqM { + fn ask(&self) -> Option<(&'static str, Ask)> { + self.v.steps().get(self.vals.len()).copied() + } + fn value_from_point(&self, ask: Ask, q: Vec2) -> Option { + let pt = |i: usize| self.vals.get(i).and_then(|x| x.p()); + Some(match ask { + Ask::Point => Val::P(q), + Ask::Angle(b) => Val::N(pt(b)?.angle_to(q)), + Ask::Sweep(c, f) => { + let c = pt(c)?; + Val::N(norm_angle(c.angle_to(q) - c.angle_to(pt(f)?))) + } + Ask::Dist(b) => Val::N(pt(b)?.dist(q)), + }) + } + fn base(&self) -> Option { + match self.ask()?.1 { + Ask::Point => self.vals.last().and_then(|v| v.p()), + Ask::Angle(b) | Ask::Dist(b) => self.vals.get(b).and_then(|v| v.p()), + Ask::Sweep(c, _) => self.vals.get(c).and_then(|v| v.p()), + } + } +} + +impl Interactive for SeqM { + fn name(&self) -> &'static str { + self.v.name() + } + fn prompt(&self, _s: &Session) -> Prompt { + if let Some((st, _)) = self.cont { + return Prompt::new("Specify end point of arc (hold Ctrl to switch direction)", Accept::POINT).base(st); + } + let kws: Vec<&str> = self.v.alts(self.vals.len()).iter().map(|a| a.0).collect(); + let p = match self.ask() { + Some((m, Ask::Point)) => Prompt::new(m, Accept::POINT).base_opt(self.base()), + Some((m, _)) => Prompt::new(m, Accept::POINT_OR_NUMBER).base_opt(self.base()), + None => Prompt::new("", Accept::default()), + }; + p.kw(&kws) + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + if let Some((st, dir)) = self.cont { + // Ctrl flips the arc: negating the start tangent makes `arc_sed` pick the mirror + // arc (same two points and center, opposite sweep). `ctrl_down` comes from the + // UI's per-frame write on `Session`; the `Input` enum itself stays free of + // modifiers so it remains UI-agnostic. + let dir = if s.ctrl_down { -dir } else { dir }; + return match i { + Input::Point(e) => { + let a = curves::arc_sed(st, e, dir).ok_or_else(|| other("Invalid end point."))?; + s.add_entity(arc(&a))?; + Ok(Step::Done) + } + Input::Enter => Ok(Step::Cancel), + _ => Err(other("Point required.")), + }; + } + if let Input::Keyword(k) = &i + && let Some((_, v)) = self.v.alts(self.vals.len()).iter().find(|a| a.0 == k) + { + self.v = *v; + return Ok(Step::Continue); + } + if i == Input::Enter && self.v == ArcV::ThreeP && self.vals.is_empty() { + self.cont = curves::last_curve_end(s); + if self.cont.is_none() { + s.echo("No line or arc to continue."); + return Ok(Step::Cancel); + } + return Ok(Step::Continue); + } + let Some((_, ask)) = self.ask() else { return Ok(Step::Done) }; + let val = match (&i, ask) { + (Input::Point(q), _) => self.value_from_point(ask, *q), + (Input::Text(t), Ask::Angle(_) | Ask::Sweep(..)) => { + // The tangent direction is a direction; included angles are sizes. + let au = s.angle_settings(); + let a = if self.v == ArcV::Sed { au.direction(t) } else { au.amount(t) }; + Some(Val::N(a.ok_or_else(|| other("Requires a valid angle."))?)) + } + (Input::Text(t), Ask::Dist(_)) => Some(Val::N(number(t).ok_or_else(|| other("Requires numeric distance or a point."))?)), + (Input::Enter, _) => return Ok(Step::Cancel), + _ => return Err(other("Point or value required.")), + }; + let Some(val) = val else { return Ok(Step::Continue) }; + self.vals.push(val); + if self.vals.len() < self.v.steps().len() { + return Ok(Step::Continue); + } + match self.v.build(&self.vals, s.ctrl_down) { + Some(k) => { + if let EntityKind::Circle(c) = &k { + let r = c.radius; + s.add_entity(k)?; + s.doc_mut()?.header.set_f64("CIRCLERAD", r); + } else { + s.add_entity(k)?; + } + Ok(Step::Done) + } + None => { + self.vals.pop(); + Err(other("Invalid arc or circle for these values.")) + } + } + } + fn preview(&self, s: &Session, c: Vec2) -> Vec { + if let Some((st, d)) = self.cont { + let d = if s.ctrl_down { -d } else { d }; + return curves::arc_sed(st, c, d).map(|a| vec![arc(&a)]).unwrap_or_default(); + } + let Some((_, ask)) = self.ask() else { return Vec::new() }; + let Some(v) = self.value_from_point(ask, c) else { return Vec::new() }; + let mut vals = self.vals.clone(); + vals.push(v); + let mut out: Vec = self.base().map(|b| vec![line(b, c)]).unwrap_or_default(); + if vals.len() == self.v.steps().len() { + out.extend(self.v.build(&vals, s.ctrl_down)); + } + out + } +} + +fn direction_param(p: &Value) -> Option { + match p.get("direction") { + Some(v) if v.is_array() || v.is_object() => point_value(v).filter(|d| d.len() > 1e-12).map(Vec2::angle), + Some(v) => v.as_f64().filter(|x| x.is_finite()).map(f64::to_radians), + None => None, + } +} + +fn arc_from_params(p: &Value) -> Result { + let st = point_param(p, "start"); + let c = point_param(p, "center"); + let e = point_param(p, "end"); + let ang = p.get("angle").and_then(Value::as_f64).filter(|v| v.is_finite()).map(f64::to_radians); + let len = p.get("length").and_then(Value::as_f64).filter(|v| v.is_finite()); + let rad = p.get("radius").and_then(Value::as_f64).filter(|v| v.is_finite()); + let dir = direction_param(p); + let a = match (st, c, e) { + (Some(st), Some(c), Some(e)) => Some(Arc::from_start_center_end(st, c, e)), + (Some(st), Some(c), None) => match (ang, len) { + (Some(a), _) => curves::arc_sca(st, c, a), + (None, Some(l)) => curves::arc_scl(st, c, l), + _ => return Err(bad("arc", "start+center needs `end`, `angle` or `length`")), + }, + (Some(st), None, Some(e)) => match (ang, dir, rad) { + (Some(a), _, _) => curves::arc_sea(st, e, a), + (None, Some(d), _) => curves::arc_sed(st, e, Vec2::from_angle(d)), + (None, None, Some(r)) => curves::arc_ser(st, e, r), + _ => return Err(bad("arc", "start+end needs `angle`, `direction` or `radius`")), + }, + _ => return Err(bad("arc", "give start with center and/or end (see params)")), + }; + let a = a.filter(|a| a.radius > 1e-12 && a.radius.is_finite()).ok_or_else(|| bad("arc", "these values do not define an arc"))?; + Ok(arc(&a)) +} + +pub(super) fn run_arc_variant(s: &mut Session, p: &Value) -> Result { + let k = arc_from_params(p)?; + added(s.add_entity(k)?) +} + +pub(super) fn run_arc_continue(s: &mut Session, p: &Value) -> Result { + let e = point_req("arc.continue", p, "end")?; + let (st, dir) = match (point_param(p, "start"), direction_param(p)) { + (Some(st), Some(d)) => (st, Vec2::from_angle(d)), + _ => curves::last_curve_end(s).ok_or_else(|| bad("arc.continue", "no line or arc to continue from"))?, + }; + let a = curves::arc_sed(st, e, dir).ok_or_else(|| bad("arc.continue", "end point is on the tangent line"))?; + let h = s.add_entity(arc(&a))?; + // The next continuation starts where this arc was drawn to, not at its stored CCW end. + s.last_point = e; + added(h) +} + +#[derive(Default)] +pub(super) struct ArcContinueM { + from: Option<(Vec2, Vec2)>, +} + +impl Interactive for ArcContinueM { + fn name(&self) -> &'static str { + "ARC" + } + fn begin(&mut self, s: &mut Session) -> Result { + self.from = curves::last_curve_end(s); + if self.from.is_none() { + s.echo("No line or arc to continue."); + return Ok(Step::Cancel); + } + Ok(Step::Continue) + } + fn prompt(&self, _s: &Session) -> Prompt { + Prompt::new("Specify end point of arc (hold Ctrl to switch direction)", Accept::POINT).base_opt(self.from.map(|f| f.0)) + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + let Some((st, dir)) = self.from else { return Ok(Step::Cancel) }; + // Ctrl flips the direction, exactly as in `SeqM::input`. + let dir = if s.ctrl_down { -dir } else { dir }; + match i { + Input::Point(e) => { + let a = curves::arc_sed(st, e, dir).ok_or_else(|| other("Invalid end point."))?; + s.add_entity(arc(&a))?; + Ok(Step::Done) + } + Input::Enter => Ok(Step::Cancel), + _ => Err(other("Point required.")), + } + } + fn preview(&self, s: &Session, c: Vec2) -> Vec { + self.from + .and_then(|(st, dir)| { + let dir = if s.ctrl_down { -dir } else { dir }; + curves::arc_sed(st, c, dir) + }) + .map(|a| vec![arc(&a)]) + .unwrap_or_default() + } +} diff --git a/crates/engine/src/cmd/draw2/centermark.rs b/crates/engine/src/cmd/draw2/centermark.rs new file mode 100644 index 000000000..00edfbcc3 --- /dev/null +++ b/crates/engine/src/cmd/draw2/centermark.rs @@ -0,0 +1,124 @@ +use super::*; + +// ===================================================================================== +// CENTERMARK / CENTERLINE +// ===================================================================================== + +/// Add a line on the CENTER linetype (loading it from the standard library when missing). +fn add_center_line(s: &mut Session, a: Vec2, b: Vec2) -> Result { + let d = s.doc_mut()?; + if d.linetype("CENTER").is_none() + && let Some(lt) = cadcraft_doc::library::standard_linetypes_for(d).into_iter().find(|l| l.name == "CENTER") + { + d.linetypes.push(lt); + } + let h = s.add_entity(line(a, b))?; + s.doc_mut()?.modify_entity(h, |e| e.common.linetype = "CENTER".into())?; + Ok(h) +} + +fn center_ext(s: &Session) -> f64 { + let d = s.doc().ok(); + let metric = d.is_some_and(|d| d.header.i64("MEASUREMENT", 0) == 1); + d.map(|d| d.header.f64("CENTEREXE", if metric { 3.5 } else { 0.12 })).unwrap_or(0.12) +} + +fn center_mark(s: &mut Session, h: Handle) -> Result> { + let e = curves::entity(s, h)?; + let (c, r) = match &e.kind { + EntityKind::Circle(c) => (c.center.xy(), c.radius), + EntityKind::Arc(a) => (a.center.xy(), a.radius), + _ => return Err(other("Select a circle or arc.")), + }; + let ext = r + center_ext(s); + let h1 = add_center_line(s, c - Vec2::X * ext, c + Vec2::X * ext)?; + let h2 = add_center_line(s, c - Vec2::Y * ext, c + Vec2::Y * ext)?; + Ok(vec![h1.hex(), h2.hex()]) +} + +fn center_line(s: &mut Session, h1: Handle, h2: Handle) -> Result { + let l = |h: Handle| -> Result<(Vec2, Vec2)> { + match curves::entity(s, h)?.kind { + EntityKind::Line(l) => Ok((l.a.xy(), l.b.xy())), + _ => Err(other("Select a line.")), + } + }; + let (a1, b1) = l(h1)?; + let (mut a2, mut b2) = l(h2)?; + if (b1 - a1).dot(b2 - a2) < 0.0 { + std::mem::swap(&mut a2, &mut b2); + } + let (st, en) = (a1.mid(a2), b1.mid(b2)); + let dir = (en - st).normalized(); + if dir == Vec2::ZERO { + return Err(other("The lines do not define a center line.")); + } + let ext = center_ext(s); + add_center_line(s, st - dir * ext, en + dir * ext) +} + +pub(super) fn run_centermark(s: &mut Session, p: &Value) -> Result { + let hs = targets(s, p)?; + if hs.is_empty() { + return Err(bad("centermark", "`handle` or `handles` is required")); + } + let mut out = Vec::new(); + for h in hs { + out.extend(center_mark(s, h)?); + } + Ok(json!({ "handles": out })) +} + +pub(super) fn run_centerline(s: &mut Session, p: &Value) -> Result { + let h1 = curves::handle_param(p, "h1").ok_or_else(|| bad("centerline", "`h1` is required"))?; + let h2 = curves::handle_param(p, "h2").ok_or_else(|| bad("centerline", "`h2` is required"))?; + added(center_line(s, h1, h2)?) +} + +pub(super) struct CenterM { + pub(super) first: Option, + pub(super) mark: bool, +} + +impl Interactive for CenterM { + fn name(&self) -> &'static str { + if self.mark { "CENTERMARK" } else { "CENTERLINE" } + } + fn begin(&mut self, s: &mut Session) -> Result { + s.set_selection(Vec::new()); + Ok(Step::Continue) + } + fn prompt(&self, _s: &Session) -> Prompt { + match (self.mark, self.first) { + (true, _) => Prompt::new("Select circle or arc to add center mark", Accept::POINT), + (false, None) => Prompt::new("Select first line", Accept::POINT), + (false, Some(_)) => Prompt::new("Select second line", Accept::POINT), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + match i { + Input::Point(p) => { + let h = curves::pick_at(s, p).ok_or_else(|| other("*Invalid selection*"))?; + if self.mark { + center_mark(s, h)?; + return Ok(Step::Continue); + } + match self.first { + None => { + if !matches!(curves::entity(s, h)?.kind, EntityKind::Line(_)) { + return Err(other("Select a line.")); + } + self.first = Some(h); + Ok(Step::Continue) + } + Some(h1) => { + center_line(s, h1, h)?; + Ok(Step::Done) + } + } + } + Input::Enter => Ok(Step::Done), + _ => Ok(Step::Continue), + } + } +} diff --git a/crates/engine/src/cmd/draw2/curves3d.rs b/crates/engine/src/cmd/draw2/curves3d.rs new file mode 100644 index 000000000..70af51c53 --- /dev/null +++ b/crates/engine/src/cmd/draw2/curves3d.rs @@ -0,0 +1,363 @@ +use super::*; + +// ===================================================================================== +// 3DPOLY +// ===================================================================================== + +fn point3_value(v: &Value) -> Option { + let a = v.as_array()?; + let x = a.first()?.as_f64()?; + let y = a.get(1)?.as_f64()?; + let z = a.get(2).and_then(Value::as_f64).unwrap_or(0.0); + let p = Vec3::new(x, y, z); + p.is_finite().then_some(p) +} + +pub(super) fn run_3dpoly(s: &mut Session, p: &Value) -> Result { + let pts: Vec = p.get("points").and_then(Value::as_array).map(|a| a.iter().filter_map(point3_value).collect()).unwrap_or_default(); + if pts.len() < 2 || pts.len() > MAX_GEN { + return Err(bad("3dpoly", "`points` needs 2 or more [x,y,z] points")); + } + added(s.add_entity(EntityKind::Polyline3d(Polyline3d { points: pts, closed: bool_or(p, "closed", false) }))?) +} + +#[derive(Default)] +pub(super) struct Poly3dM { + pts: Vec, + handle: Option, +} + +impl Poly3dM { + fn sync(&mut self, s: &mut Session, closed: bool) -> Result<()> { + let k = EntityKind::Polyline3d(Polyline3d { points: self.pts.iter().map(|p| v3(*p)).collect(), closed }); + match (self.handle, self.pts.len() >= 2) { + (Some(h), true) => s.doc_mut()?.modify_entity(h, |e| e.kind = k)?, + (None, true) => self.handle = Some(s.add_entity(k)?), + (Some(h), false) => { + s.doc_mut()?.remove_entity(h); + self.handle = None; + } + (None, false) => {} + } + Ok(()) + } +} + +impl Interactive for Poly3dM { + fn name(&self) -> &'static str { + "3DPOLY" + } + fn prompt(&self, _s: &Session) -> Prompt { + match self.pts.len() { + 0 => Prompt::new("Specify start point of polyline", Accept::POINT), + 1 | 2 => Prompt::new("Specify endpoint of line", Accept::POINT).kw(&["Undo"]).base_opt(self.pts.last().copied()), + _ => Prompt::new("Specify endpoint of line", Accept::POINT).kw(&["Close", "Undo"]).base_opt(self.pts.last().copied()), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + match i { + Input::Point(p) => { + if self.pts.last().is_none_or(|l| !l.near(p, 1e-12)) && self.pts.len() < MAX_GEN { + self.pts.push(p); + self.sync(s, false)?; + } + Ok(Step::Continue) + } + Input::Keyword(k) if k == "Undo" => { + self.pts.pop(); + self.sync(s, false)?; + Ok(Step::Continue) + } + Input::Keyword(k) if k == "Close" => { + self.sync(s, true)?; + Ok(Step::Done) + } + Input::Enter => Ok(Step::Done), + _ => Ok(Step::Continue), + } + } + fn preview(&self, _s: &Session, c: Vec2) -> Vec { + self.pts.last().map(|l| vec![line(*l, c)]).unwrap_or_default() + } +} + +// ===================================================================================== +// HELIX (3D polyline approximation) +// ===================================================================================== + +/// A helix entity (base centre `c`, radii, height, turns, twist, start angle). +fn helix_kind(c: Vec2, rb: f64, rt: f64, height: f64, turns: f64, ccw: bool, a0: f64) -> EntityKind { + let h = Helix { center: c.to3(0.0), base_radius: rb, top_radius: rt, height, turns, start_angle: a0, ccw, constrain: HelixConstrain::Height }; + EntityKind::Helix(h.sanitized()) +} + +pub(super) fn run_helix(s: &mut Session, p: &Value) -> Result { + let c = point_req("helix", p, "center")?; + let rb = f64_req("helix", p, "baseRadius")?; + let rt = f64_or(p, "topRadius", rb); + let turns = f64_or(p, "turns", 3.0); + if !(rb > 0.0 && rt >= 0.0 && turns > 0.0 && turns <= 500.0) { + return Err(bad("helix", "radii must be positive and turns in (0, 500]")); + } + let height = f64_or(p, "height", 1.0); + if height.abs() > 1e12 || rb > 1e12 || rt > 1e12 { + return Err(bad("helix", "sizes are out of range")); + } + added(s.add_entity(helix_kind(c, rb, rt, height, turns, bool_or(p, "ccw", true), f64_or(p, "startAngle", 0.0).to_radians()))?) +} + +#[derive(Default)] +pub(super) struct HelixM { + center: Option, + rb: Option, + rt: Option, + turns: Option, + cw: bool, + diameter: bool, + asking: u8, // 0 main, 1 turns, 2 twist, 3 turn height + start: Option, +} + +impl HelixM { + fn turns(&self) -> f64 { + self.turns.unwrap_or(3.0) + } + fn create(&self, s: &mut Session, height: f64) -> Result { + let (Some(c), Some(rb)) = (self.center, self.rb) else { return Ok(Step::Cancel) }; + let rt = self.rt.unwrap_or(rb); + if !height.is_finite() || height.abs() > 1e12 { + return Err(other("Requires a distance.")); + } + s.add_entity(helix_kind(c, rb, rt, height, self.turns(), !self.cw, self.start.unwrap_or(0.0)))?; + Ok(Step::Done) + } +} + +impl Interactive for HelixM { + fn name(&self) -> &'static str { + "HELIX" + } + fn begin(&mut self, s: &mut Session) -> Result { + s.echo(format!("Number of turns = {:.4} Twist=CCW", self.turns())); + Ok(Step::Continue) + } + fn prompt(&self, _s: &Session) -> Prompt { + match self.asking { + 1 => return Prompt::new("Enter number of turns", Accept::NUMBER).default(format!("{:.4}", self.turns())), + 2 => return Prompt::new("Enter twist direction of helix", curves::KW).kw(&["CW", "CCW"]).default("CCW"), + 3 => return Prompt::new("Specify distance between turns", Accept::NUMBER), + _ => {} + } + match (self.center, self.rb, self.rt) { + (None, ..) => Prompt::new("Specify center point of base", Accept::POINT), + (Some(c), None, _) => { + Prompt::new(if self.diameter { "Specify base diameter" } else { "Specify base radius or" }, Accept::POINT_OR_NUMBER) + .kw(if self.diameter { &[] } else { &["Diameter"] }) + .default("1.0000") + .base(c) + } + (Some(c), Some(rb), None) => { + Prompt::new(if self.diameter { "Specify top diameter" } else { "Specify top radius or" }, Accept::POINT_OR_NUMBER) + .kw(if self.diameter { &[] } else { &["Diameter"] }) + .default(format!("{rb:.4}")) + .base(c) + } + (Some(c), Some(_), Some(_)) => Prompt::new("Specify helix height or", Accept::POINT_OR_NUMBER) + .kw(&["Axis endpoint", "Turns", "turn Height", "tWist"]) + .default("1.0000") + .base(c), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + match self.asking { + 1 => { + if let Input::Text(t) = &i { + let v = number(t).filter(|v| *v > 0.0 && *v <= 500.0).ok_or_else(|| other("Turns must be in (0, 500]."))?; + self.turns = Some(v); + } + self.asking = 0; + return Ok(Step::Continue); + } + 2 => { + if let Input::Keyword(k) | Input::Text(k) = &i { + self.cw = k.eq_ignore_ascii_case("cw"); + } + self.asking = 0; + return Ok(Step::Continue); + } + 3 => { + self.asking = 0; + if let Input::Text(t) = &i { + let v = number(t).ok_or_else(|| other("Requires a distance."))?; + return self.create(s, v * self.turns()); + } + return Ok(Step::Continue); + } + _ => {} + } + let num = |i: &Input, base: Option| -> Option { + match i { + Input::Point(p) => base.map(|b| b.dist(*p)), + Input::Text(t) => number(t), + _ => None, + } + }; + match (self.center, self.rb, self.rt, &i) { + (None, _, _, Input::Point(p)) => { + self.center = Some(*p); + Ok(Step::Continue) + } + (Some(_), None | Some(_), _, Input::Keyword(k)) if k == "Diameter" => { + self.diameter = true; + Ok(Step::Continue) + } + (Some(c), None, _, _) => { + let v = match &i { + Input::Enter => Some(1.0), + _ => num(&i, Some(c)), + }; + let v = v.filter(|v| *v > 0.0).ok_or_else(|| other("Requires a positive distance."))?; + if let Input::Point(p) = &i { + self.start = Some(c.angle_to(*p)); + } + self.rb = Some(if self.diameter { v / 2.0 } else { v }); + self.diameter = false; + Ok(Step::Continue) + } + (Some(c), Some(rb), None, _) => { + let v = match &i { + Input::Enter => Some(if self.diameter { rb * 2.0 } else { rb }), + _ => num(&i, Some(c)), + }; + let v = v.filter(|v| *v >= 0.0).ok_or_else(|| other("Requires a distance."))?; + self.rt = Some(if self.diameter { v / 2.0 } else { v }); + Ok(Step::Continue) + } + (Some(c), Some(_), Some(_), _) => match &i { + Input::Keyword(k) => { + self.asking = match k.as_str() { + "Turns" => 1, + "tWist" => 2, + "turn Height" => 3, + _ => 0, + }; + Ok(Step::Continue) + } + Input::Enter => self.create(s, 1.0), + _ => { + let h = num(&i, Some(c)).ok_or_else(|| other("Requires a distance."))?; + self.create(s, h) + } + }, + _ => Ok(Step::Continue), + } + } + fn preview(&self, _s: &Session, c: Vec2) -> Vec { + match (self.center, self.rb, self.rt) { + (Some(ct), None, _) => vec![circle(ct, ct.dist(c))], + (Some(ct), Some(rb), None) => { + vec![helix_kind(ct, rb, ct.dist(c), 0.0, self.turns(), !self.cw, self.start.unwrap_or(0.0))] + } + _ => Vec::new(), + } + } +} + +// ===================================================================================== +// SPLINE by control vertices +// ===================================================================================== + +pub(super) fn run_spline_cv(s: &mut Session, p: &Value) -> Result { + let mut c = points_param(p, "control").ok_or_else(|| bad("spline.cv", "`control` points are required"))?; + if c.len() < 2 || c.len() > MAX_GEN { + return Err(bad("spline.cv", "need 2+ control points")); + } + let closed = bool_or(p, "closed", false); + if closed && let Some(f) = c.first().copied() { + c.push(f); + } + let deg = p.get("degree").and_then(Value::as_u64).unwrap_or(3).clamp(1, 10) as usize; + let mut sp = Spline::from_control(c, deg); + sp.closed = closed; + added(s.add_entity(EntityKind::Spline(sp))?) +} + +#[derive(Default)] +pub(super) struct SplineCvM { + pts: Vec, + degree: Option, + asking_degree: bool, +} + +impl Interactive for SplineCvM { + fn name(&self) -> &'static str { + "SPLINE" + } + fn begin(&mut self, s: &mut Session) -> Result { + s.echo(format!("Current settings: Method=CV Degree={}", self.degree.unwrap_or(3))); + Ok(Step::Continue) + } + fn prompt(&self, _s: &Session) -> Prompt { + if self.asking_degree { + return Prompt::new("Enter degree of spline", Accept::NUMBER).default(self.degree.unwrap_or(3).to_string()); + } + match self.pts.len() { + 0 => Prompt::new("Specify first point", Accept::POINT).kw(&["Method", "Degree", "Object"]), + 1 => Prompt::new("Enter next point", Accept::POINT).base_opt(self.pts.last().copied()), + 2 => Prompt::new("Enter next point", Accept::POINT).kw(&["Undo"]).base_opt(self.pts.last().copied()), + _ => Prompt::new("Enter next point", Accept::POINT).kw(&["Close", "Undo"]).base_opt(self.pts.last().copied()), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + if self.asking_degree { + if let Input::Text(t) = &i { + let d = number(t).filter(|d| (1.0..=10.0).contains(d)).ok_or_else(|| other("Degree must be 1..10."))?; + self.degree = Some(d as usize); + } + self.asking_degree = false; + return Ok(Step::Continue); + } + let deg = self.degree.unwrap_or(3); + match i { + Input::Point(p) => { + if self.pts.len() < MAX_GEN { + self.pts.push(p); + } + Ok(Step::Continue) + } + Input::Keyword(k) => { + match k.as_str() { + "Degree" => self.asking_degree = true, + "Undo" => { + self.pts.pop(); + } + "Close" => { + let mut c = self.pts.clone(); + c.extend(self.pts.first().copied()); + let mut sp = Spline::from_control(c, deg); + sp.closed = true; + s.add_entity(EntityKind::Spline(sp))?; + return Ok(Step::Done); + } + _ => s.echo(format!("{k}: use SPLINE for fit points.")), + } + Ok(Step::Continue) + } + Input::Enter => { + if self.pts.len() >= 2 { + s.add_entity(EntityKind::Spline(Spline::from_control(self.pts.clone(), deg)))?; + } + Ok(Step::Done) + } + _ => Ok(Step::Continue), + } + } + fn preview(&self, _s: &Session, c: Vec2) -> Vec { + if self.pts.is_empty() { + return Vec::new(); + } + let mut pts = self.pts.clone(); + pts.push(c); + vec![EntityKind::Spline(Spline::from_control(pts, self.degree.unwrap_or(3)))] + } +} diff --git a/crates/engine/src/cmd/draw2/divide.rs b/crates/engine/src/cmd/draw2/divide.rs new file mode 100644 index 000000000..1ba624c15 --- /dev/null +++ b/crates/engine/src/cmd/draw2/divide.rs @@ -0,0 +1,181 @@ +use super::*; + +// ===================================================================================== +// DIVIDE / MEASURE +// ===================================================================================== + +fn chain_of(s: &Session, h: Handle) -> Result { + let e = curves::entity(s, h)?; + Chain::of(&e.kind).ok_or_else(|| other("Cannot divide/measure that object.")) +} + +fn place_markers(s: &mut Session, at: &[(Vec2, Vec2)], block: Option<&str>, align: bool) -> Result> { + let mut out = Vec::new(); + for (p, t) in at { + let h = match block { + Some(b) => crate::cmd::blocks::insert(s, b, *p, 1.0, if align { t.angle() } else { 0.0 }, &serde_json::Map::new())?, + None => s.add_entity(EntityKind::Point(Point { p: v3(*p), angle: 0.0 }))?, + }; + out.push(h.hex()); + } + Ok(out) +} + +fn check_block(s: &Session, b: Option<&str>) -> Result<()> { + if let Some(b) = b + && s.doc()?.block(b).is_none() + { + return Err(other(format!("Block \"{b}\" not found."))); + } + Ok(()) +} + +pub(crate) fn divide_points(c: &Chain, n: usize) -> Vec<(Vec2, Vec2)> { + let l = c.len(); + let range = if c.closed { 0..n } else { 1..n }; + range.filter_map(|i| c.at_length(l * i as f64 / n as f64)).collect() +} + +pub(crate) fn measure_points(c: &Chain, step: f64) -> Vec<(Vec2, Vec2)> { + let l = c.len(); + let mut out = Vec::new(); + let mut k = 1usize; + while (k as f64) * step < l - 1e-9 && out.len() < MAX_GEN { + let d = k as f64 * step; + out.extend(c.at_length(d)); + k += 1; + } + out +} + +pub(super) fn run_divide(s: &mut Session, p: &Value) -> Result { + let h = targets(s, p)?.first().copied().ok_or_else(|| bad("divide", "`handle` is required"))?; + let n = p.get("segments").and_then(Value::as_u64).ok_or_else(|| bad("divide", "`segments` (2..32767) is required"))?; + if !(2..=32767).contains(&n) { + return Err(bad("divide", "segments must be 2..32767")); + } + let block = str_param(p, "block").map(str::to_string); + check_block(s, block.as_deref())?; + let c = chain_of(s, h)?; + let pts = divide_points(&c, n as usize); + let hs = place_markers(s, &pts, block.as_deref(), bool_or(p, "align", true))?; + Ok(json!({ "handles": hs })) +} + +pub(super) fn run_measure(s: &mut Session, p: &Value) -> Result { + let h = targets(s, p)?.first().copied().ok_or_else(|| bad("measure", "`handle` is required"))?; + let step = f64_req("measure", p, "length")?; + let mut c = chain_of(s, h)?; + if step.is_nan() || step <= 0.0 || c.len() / step > MAX_GEN as f64 { + return Err(bad("measure", "length must be positive (and not tiny)")); + } + let block = str_param(p, "block").map(str::to_string); + check_block(s, block.as_deref())?; + if let Some(from) = point_param(p, "from") + && !c.closed + && let (Some(a), Some(b)) = (c.start(), c.end()) + && from.dist(b) < from.dist(a) + { + c = c.reversed(); + } + let pts = measure_points(&c, step); + let hs = place_markers(s, &pts, block.as_deref(), bool_or(p, "align", true))?; + Ok(json!({ "handles": hs })) +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum DivPhase { + Object, + Value, + BlockName, + Align, +} + +pub(super) struct DivideM { + measure: bool, + obj: Option<(Handle, Vec2)>, + phase: DivPhase, + block: Option, + align: bool, +} + +impl DivideM { + pub(super) fn new(measure: bool) -> Self { + DivideM { measure, obj: None, phase: DivPhase::Object, block: None, align: true } + } +} + +impl Interactive for DivideM { + fn name(&self) -> &'static str { + if self.measure { "MEASURE" } else { "DIVIDE" } + } + fn prompt(&self, _s: &Session) -> Prompt { + match self.phase { + DivPhase::Object => Prompt::new(if self.measure { "Select object to measure" } else { "Select object to divide" }, Accept::POINT), + DivPhase::Value => { + let m = if self.measure { "Specify length of segment" } else { "Enter the number of segments" }; + let p = Prompt::new(m, if self.measure { Accept::POINT_OR_NUMBER } else { Accept::NUMBER }); + let p = if self.measure { p.base_opt(self.obj.map(|o| o.1)) } else { p }; + if self.block.is_some() { p } else { p.kw(&["Block"]) } + } + DivPhase::BlockName => Prompt::new("Enter name of block to insert", Accept::TEXT), + DivPhase::Align => Prompt::new("Align block with object?", curves::KW).kw(&["Yes", "No"]).default("Y"), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + match (self.phase, i) { + (DivPhase::Object, Input::Point(p)) => { + let h = curves::pick_at(s, p).ok_or_else(|| other("*Invalid selection*"))?; + chain_of(s, h)?; + self.obj = Some((h, p)); + self.phase = DivPhase::Value; + Ok(Step::Continue) + } + (DivPhase::Value, Input::Keyword(k)) if k == "Block" => { + self.phase = DivPhase::BlockName; + Ok(Step::Continue) + } + (DivPhase::BlockName, Input::Text(t) | Input::Keyword(t)) => { + let name = t.trim().to_string(); + check_block(s, Some(&name))?; + self.block = Some(name); + self.phase = DivPhase::Align; + Ok(Step::Continue) + } + (DivPhase::Align, Input::Keyword(k) | Input::Text(k)) => { + self.align = !k.trim().to_ascii_lowercase().starts_with('n'); + self.phase = DivPhase::Value; + Ok(Step::Continue) + } + (DivPhase::Align, Input::Enter) => { + self.phase = DivPhase::Value; + Ok(Step::Continue) + } + (DivPhase::Value, inp @ (Input::Text(_) | Input::Point(_))) => { + let Some((h, pick)) = self.obj else { return Ok(Step::Cancel) }; + let v = match inp { + Input::Text(t) => number(&t).ok_or_else(|| other("Requires a number."))?, + Input::Point(q) => pick.dist(q), + _ => return Ok(Step::Continue), + }; + let mut params = json!({ "handle": h.hex(), "align": self.align, "from": [pick.x, pick.y] }); + if let Some(b) = &self.block { + params["block"] = json!(b); + } + if self.measure { + params["length"] = json!(v); + run_measure(s, ¶ms)?; + } else { + if !(2.0..=32767.0).contains(&v) || v.fract() != 0.0 { + return Err(other("Requires an integer between 2 and 32767.")); + } + params["segments"] = json!(v as u64); + run_divide(s, ¶ms)?; + } + Ok(Step::Done) + } + (_, Input::Enter) => Ok(Step::Cancel), + _ => Err(other("Invalid input.")), + } + } +} diff --git a/crates/engine/src/cmd/draw2/ellipse.rs b/crates/engine/src/cmd/draw2/ellipse.rs new file mode 100644 index 000000000..ca3f9c6e2 --- /dev/null +++ b/crates/engine/src/cmd/draw2/ellipse.rs @@ -0,0 +1,60 @@ +use super::*; + +// ---------------- ellipse entries ---------------- + +fn ellipse_from_axis(p1: Vec2, p2: Vec2, half: f64) -> Option { + let c = p1.mid(p2); + let m = p2 - c; + let ml = m.len(); + let half = half.abs(); + if ml < 1e-12 || half < 1e-12 || !half.is_finite() { + return None; + } + Some(if half > ml { Ellipse::full(c, m.normalized().perp() * half, ml / half) } else { Ellipse::full(c, m, half / ml) }) +} + +fn axis_ellipse_params(cmd: &str, p: &Value) -> Result { + let p1 = point_req(cmd, p, "p1")?; + let p2 = point_req(cmd, p, "p2")?; + let half = match point_param(p, "p3") { + Some(p3) => { + let ax = Line::new(p1, p2); + ax.project(p3).dist(p3) + } + None => f64_req(cmd, p, "distance")?, + }; + ellipse_from_axis(p1, p2, half).ok_or_else(|| bad(cmd, "degenerate ellipse")) +} + +fn ellipse_kind(e: &Ellipse) -> EntityKind { + EntityKind::Ellipse(cadcraft_doc::Ellipse { center: v3(e.center), major: v3(e.major), ratio: e.ratio, start: e.start, end: e.end }) +} + +pub(super) fn run_ellipse_axis(s: &mut Session, p: &Value) -> Result { + let e = axis_ellipse_params("ellipse.axis", p)?; + added(s.add_entity(ellipse_kind(&e))?) +} + +pub(super) fn run_ellipse_arc(s: &mut Session, p: &Value) -> Result { + let mut e = if let Some(c) = point_param(p, "center") { + let m = point_req("ellipse.arc", p, "major")?; + let ratio = f64_req("ellipse.arc", p, "ratio")?; + if !(ratio > 0.0 && ratio <= 1.0) || m.len() < 1e-12 { + return Err(bad("ellipse.arc", "ratio must be in (0, 1] and major non-zero")); + } + Ellipse::full(c, m, ratio) + } else { + axis_ellipse_params("ellipse.arc", p)? + }; + // Angles are measured from the major axis (like the prompts); convert to parameters. + let to_param = |deg: f64| { + let a = deg.to_radians(); + let ge = Ellipse { start: 0.0, end: TAU, ..e }; + ge.param_of(e.center + Vec2::from_angle(a + e.major.angle())) + }; + let st = to_param(f64_req("ellipse.arc", p, "start")?); + let en = to_param(f64_req("ellipse.arc", p, "end")?); + e.start = st; + e.end = en; + added(s.add_entity(ellipse_kind(&e))?) +} diff --git a/crates/engine/src/cmd/draw2/rect.rs b/crates/engine/src/cmd/draw2/rect.rs new file mode 100644 index 000000000..84db5093a --- /dev/null +++ b/crates/engine/src/cmd/draw2/rect.rs @@ -0,0 +1,308 @@ +use super::*; + +// ===================================================================================== +// RECTANG with Area / Dimensions / Rotation / Width / Elevation / Thickness +// ===================================================================================== + +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); + if let EntityKind::LwPolyline(pl) = &mut k { + pl.const_width = width.max(0.0); + pl.elevation = elevation; + } + k +} + +/// 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") { + Some(q) => { + let loc = (q - a).rotate(-rot); + (if loc.x < 0.0 { -1.0 } else { 1.0 }, if loc.y < 0.0 { -1.0 } else { 1.0 }) + } + None => (1.0, 1.0), + }; + (l.abs() * sx, wd.abs() * sy) + } else if let Some(area) = p.get("area").and_then(Value::as_f64).filter(|v| v.is_finite()) { + if area <= 0.0 { + return Err(bad("rectang", "area must be positive")); + } + match (p.get("length").and_then(Value::as_f64), p.get("breadth").and_then(Value::as_f64)) { + (Some(l), _) if l.is_finite() && l > 0.0 => (l, area / l), + (_, Some(b)) if b.is_finite() && b > 0.0 => (area / b, b), + _ => return Err(bad("rectang", "area needs `length` or `breadth`")), + } + } else { + let b = point_req("rectang", p, "p2")?; + 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 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); + s.doc_mut()?.modify_entity(hd, |e| e.common.thickness = th)?; + added(hd) +} + +#[derive(Clone, Copy, Debug, PartialEq)] +enum RAsk { + Chamfer1, + Chamfer2, + Fillet, + Width, + Elevation, + Thickness, + Area, + AreaBasis, + AreaLength, + AreaWidth, + DimL, + DimW, + Rotation, + RotPick1, + RotPick2(Vec2), +} + +#[derive(Default)] +pub(crate) struct RectM2 { + first: Option, + /// The first point is the rectangle's centre (the `Center` option), not a corner. + center: bool, + fillet: f64, + chamfer: (f64, f64), + width: f64, + elevation: f64, + thickness: f64, + rotation: f64, + dims: Option<(f64, f64)>, + area: f64, + asking: Option, +} + +impl RectM2 { + /// Adds the rectangle of full size `w` × `h` with the collected options, drawing it from + /// the centre when centre mode is on, then applies the requested thickness. + fn make(&self, s: &mut Session, w: f64, h: f64) -> Result { + let Some(a) = self.first else { return Ok(Step::Cancel) }; + if w.abs() < 1e-12 || h.abs() < 1e-12 { + 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))?; + // 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 + /// point itself as a corner, or the first point less half the size around the centre. + fn origin(&self, w: f64, h: f64) -> Option { + let a = self.first?; + Some(if self.center { a - Vec2::new(w / 2.0, h / 2.0).rotate(self.rotation) } else { a }) + } + /// The rectangle's signed full size implied by the picked point `p`: explicit `dims` only + /// take their quadrant from `p`, otherwise `p` is a corner, doubled about the centre in + /// centre mode. + fn other_corner(&self, p: Vec2) -> Option<(f64, f64)> { + let a = self.first?; + let loc = (p - a).rotate(-self.rotation); + Some(match self.dims { + Some((l, w)) => (if loc.x < 0.0 { -l } else { l }, if loc.y < 0.0 { -w } else { w }), + // In centre mode the picked point is one corner, mirrored across the centre. + None if self.center => (2.0 * loc.x, 2.0 * loc.y), + None => (loc.x, loc.y), + }) + } +} + +impl Interactive for RectM2 { + fn name(&self) -> &'static str { + "RECTANG" + } + /// Asks for the pending option's value, or for the first and second points; in centre mode + /// the first prompt asks for the centre instead of a corner. + fn prompt(&self, _s: &Session) -> Prompt { + let num = |m: &str, d: f64| Prompt::new(m, Accept::NUMBER).default(format!("{d:.4}")); + if let Some(a) = self.asking { + return match a { + RAsk::Chamfer1 => num("Specify first chamfer distance for rectangles", self.chamfer.0), + RAsk::Chamfer2 => num("Specify second chamfer distance for rectangles", self.chamfer.1), + RAsk::Fillet => num("Specify fillet radius for rectangles", self.fillet), + RAsk::Width => num("Specify line width for rectangles", self.width), + RAsk::Elevation => num("Specify the elevation for rectangles", self.elevation), + RAsk::Thickness => num("Specify thickness for rectangles", self.thickness), + RAsk::Area => num("Enter area of rectangle in current units", if self.area > 0.0 { self.area } else { 100.0 }), + RAsk::AreaBasis => Prompt::new("Calculate rectangle dimensions based on", curves::KW).kw(&["Length", "Width"]).default("Length"), + RAsk::AreaLength => num("Enter rectangle length", 10.0), + RAsk::AreaWidth => num("Enter rectangle width", 10.0), + RAsk::DimL => num("Specify length for rectangles", self.dims.map(|d| d.0).unwrap_or(10.0)), + RAsk::DimW => num("Specify width for rectangles", self.dims.map(|d| d.1).unwrap_or(10.0)), + RAsk::Rotation => Prompt::new("Specify rotation angle or", Accept::POINT_OR_NUMBER) + .kw(&["Pick points"]) + .default(format!("{:.0}", self.rotation.to_degrees())) + .base_opt(self.first), + RAsk::RotPick1 => Prompt::new("Specify first point", Accept::POINT), + RAsk::RotPick2(b) => Prompt::new("Specify second point", Accept::POINT).base(b), + }; + } + match self.first { + None => { + let msg = if self.center { "Specify center point for rectangle" } else { "Specify first corner point" }; + Prompt::new(msg, Accept::POINT).kw(&["Chamfer", "CEnter", "Elevation", "Fillet", "Thickness", "Width"]) + } + Some(p) => Prompt::new("Specify other corner point", Accept::POINT).kw(&["Area", "Dimensions", "Rotation"]).corner(p), + } + } + /// Consumes one input: option keywords (`Center` only switches the first point's meaning + /// to the centre and asks nothing), option values, and the points that size the rectangle. + fn input(&mut self, s: &mut Session, i: Input) -> Result { + if let Some(a) = self.asking { + let val = |i: &Input, default: f64| -> Result { + match i { + Input::Text(t) => number(t).ok_or_else(|| other("Requires a number.")), + Input::Enter => Ok(default), + _ => Err(other("Requires a number.")), + } + }; + self.asking = None; + match a { + RAsk::Chamfer1 => { + self.chamfer.0 = val(&i, self.chamfer.0)?.max(0.0); + self.chamfer.1 = self.chamfer.0; + self.asking = Some(RAsk::Chamfer2); + } + RAsk::Chamfer2 => self.chamfer.1 = val(&i, self.chamfer.1)?.max(0.0), + RAsk::Fillet => self.fillet = val(&i, self.fillet)?.max(0.0), + RAsk::Width => self.width = val(&i, self.width)?.max(0.0), + RAsk::Elevation => self.elevation = val(&i, self.elevation)?, + RAsk::Thickness => self.thickness = val(&i, self.thickness)?, + RAsk::Area => { + let v = val(&i, if self.area > 0.0 { self.area } else { 100.0 })?; + if v <= 0.0 { + self.asking = Some(RAsk::Area); + return Err(other("Value must be positive.")); + } + self.area = v; + self.asking = Some(RAsk::AreaBasis); + } + RAsk::AreaBasis => { + let w = matches!(&i, Input::Keyword(k) | Input::Text(k) if k.to_ascii_lowercase().starts_with('w')); + self.asking = Some(if w { RAsk::AreaWidth } else { RAsk::AreaLength }); + } + RAsk::AreaLength | RAsk::AreaWidth => { + let v = val(&i, 10.0)?; + if v <= 0.0 { + self.asking = Some(a); + return Err(other("Value must be positive.")); + } + let (w, h) = if a == RAsk::AreaLength { (v, self.area / v) } else { (self.area / v, v) }; + return self.make(s, w, h); + } + RAsk::DimL => { + let l = val(&i, self.dims.map(|d| d.0).unwrap_or(10.0))?.abs(); + self.dims = Some((l, self.dims.map(|d| d.1).unwrap_or(10.0))); + self.asking = Some(RAsk::DimW); + } + RAsk::DimW => { + let w = val(&i, self.dims.map(|d| d.1).unwrap_or(10.0))?.abs(); + self.dims = Some((self.dims.map(|d| d.0).unwrap_or(10.0), w)); + } + RAsk::Rotation => match i { + Input::Keyword(_) => self.asking = Some(RAsk::RotPick1), + Input::Point(p) => self.rotation = self.first.map(|f| f.angle_to(p)).unwrap_or(0.0), + Input::Text(t) => self.rotation = s.angle_settings().direction(&t).ok_or_else(|| other("Requires a valid angle."))?, + _ => {} + }, + RAsk::RotPick1 => { + if let Input::Point(p) = i { + self.asking = Some(RAsk::RotPick2(p)); + } + } + RAsk::RotPick2(b) => { + if let Input::Point(p) = i { + self.rotation = b.angle_to(p); + } + } + } + return Ok(Step::Continue); + } + match i { + Input::Keyword(k) => { + // `Center` only switches the first point's meaning; it asks nothing. + if k == "CEnter" { + self.center = true; + return Ok(Step::Continue); + } + self.asking = Some(match k.as_str() { + "Chamfer" => RAsk::Chamfer1, + "Elevation" => RAsk::Elevation, + "Fillet" => RAsk::Fillet, + "Thickness" => RAsk::Thickness, + "Width" => RAsk::Width, + "Area" => RAsk::Area, + "Dimensions" => RAsk::DimL, + _ => RAsk::Rotation, + }); + Ok(Step::Continue) + } + Input::Point(p) => match self.first { + None => { + self.first = Some(p); + Ok(Step::Continue) + } + Some(_) => { + let (w, h) = self.other_corner(p).ok_or_else(|| other("No first corner."))?; + self.make(s, w, h) + } + }, + Input::Enter => Ok(Step::Cancel), + _ => Ok(Step::Continue), + } + } + /// The rubber-band rectangle for the cursor at `c`, mapped through the same size-and-origin + /// rules as the committed entity so centre mode previews where it will land. + fn preview(&self, _s: &Session, c: Vec2) -> Vec { + if self.asking.is_some() { + return Vec::new(); + } + 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::new(), + } + } +} diff --git a/crates/engine/src/cmd/draw2/revcloud.rs b/crates/engine/src/cmd/draw2/revcloud.rs new file mode 100644 index 000000000..83d8cbb75 --- /dev/null +++ b/crates/engine/src/cmd/draw2/revcloud.rs @@ -0,0 +1,273 @@ +use super::*; + +// ===================================================================================== +// REVCLOUD +// ===================================================================================== + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum CloudKind { + Rect, + Poly, + Free, +} + +fn default_arc_len(s: &Session) -> f64 { + let d = s.doc().ok(); + let metric = d.is_some_and(|d| d.header.i64("MEASUREMENT", 0) == 1); + d.map(|d| d.header.f64("REVCLOUDARCLEN", if metric { 15.0 } else { 0.5 })).unwrap_or(0.5).max(1e-6) +} + +/// Bulged closed polyline vertices for a revision cloud around `boundary`. +pub(crate) fn cloud_vertices(boundary: &[Vec2], arc_len: f64, per_edge: bool, reverse: bool) -> Option> { + let mut pts: Vec = Vec::new(); + for p in boundary { + if p.is_finite() && pts.last().is_none_or(|l| !l.near(*p, 1e-9)) { + pts.push(*p); + } + } + while pts.len() > 1 && pts.first().zip(pts.last()).is_some_and(|(a, b)| a.near(*b, 1e-9)) { + pts.pop(); + } + if pts.len() < 3 || shoelace(&pts).abs() < 1e-12 { + return None; + } + if shoelace(&pts) < 0.0 { + pts.reverse(); + } + let n = pts.len(); + let perim: f64 = (0..n).filter_map(|i| Some(pts.get(i)?.dist(*pts.get((i + 1) % n)?))).sum(); + if !(arc_len.is_finite() && arc_len > 0.0) { + return None; + } + let arc_len = arc_len.max(perim / MAX_GEN as f64); + let mut out: Vec = Vec::new(); + if per_edge { + for i in 0..n { + let (Some(a), Some(b)) = (pts.get(i).copied(), pts.get((i + 1) % n).copied()) else { continue }; + let k = ((a.dist(b) / arc_len).round() as usize).clamp(1, MAX_GEN); + for j in 0..k { + out.push(a.lerp(b, j as f64 / k as f64)); + } + } + } else { + let chain = Chain { + segs: (0..n).filter_map(|i| Some(cadcraft_geom::Segment::Line(Line::new(*pts.get(i)?, *pts.get((i + 1) % n)?)))).collect(), + closed: true, + }; + let k = ((perim / arc_len).round() as usize).clamp(3, MAX_GEN); + for j in 0..k { + out.extend(chain.at_length(perim * j as f64 / k as f64).map(|x| x.0)); + } + } + let b = if reverse { -0.5 } else { 0.5 }; + Some(out.into_iter().map(|p| PolyVertex::with_bulge(p, b)).collect()) +} + +fn object_outline(s: &Session, h: Handle) -> Result> { + let e = curves::entity(s, h)?; + let tol = 1e-3; + let pts = match &e.kind { + EntityKind::LwPolyline(p) if p.closed => Polyline { vertices: p.vertices.clone(), closed: true }.tessellate(tol), + EntityKind::Circle(c) => { + let mut v = Vec::new(); + Circle::new(c.center.xy(), c.radius).tessellate(c.radius * 1e-3, &mut v); + v + } + EntityKind::Ellipse(el) => { + let mut v = Vec::new(); + Ellipse { center: el.center.xy(), major: el.major.xy(), ratio: el.ratio, start: el.start, end: el.end } + .tessellate(el.major.xy().len() * 1e-3, &mut v); + v + } + EntityKind::Spline(sp) if sp.closed => sp.tessellate(tol), + _ => return Err(other("Select a closed polyline, circle, ellipse or closed spline.")), + }; + Ok(pts) +} + +fn cloud_entity(vs: Vec, calligraphy: bool, arc_len: f64) -> EntityKind { + let vs = if calligraphy { vs.into_iter().map(|v| PolyVertex { start_width: 0.0, end_width: arc_len * 0.15, ..v }).collect() } else { vs }; + lwpoly(vs, true) +} + +pub(super) fn run_revcloud(s: &mut Session, p: &Value) -> Result { + let arc_len = p.get("arcLength").and_then(Value::as_f64).filter(|v| v.is_finite() && *v > 0.0).unwrap_or_else(|| default_arc_len(s)); + let reverse = bool_or(p, "reverse", false); + let calligraphy = str_param(p, "style").is_some_and(|x| x.eq_ignore_ascii_case("calligraphy")); + let (pts, per_edge, src) = if let (Some(a), Some(b)) = (point_param(p, "p1"), point_param(p, "p2")) { + (rect_vertices(a, b).into_iter().map(|v| v.p).collect(), true, None) + } else if let Some(pts) = points_param(p, "points") { + (pts, true, None) + } else if let Some(pts) = points_param(p, "freehand") { + (pts, false, None) + } else if let Some(h) = curves::handle_param(p, "handle") { + (object_outline(s, h)?, false, Some(h)) + } else { + return Err(bad("revcloud", "give {p1, p2}, {points}, {freehand} or {handle}")); + }; + if pts.len() > MAX_GEN { + return Err(bad("revcloud", "too many points")); + } + let vs = cloud_vertices(&pts, arc_len, per_edge, reverse).ok_or_else(|| bad("revcloud", "the boundary needs an area"))?; + let h = s.add_entity(cloud_entity(vs, calligraphy, arc_len))?; + if let Some(src) = src { + let common = curves::entity(s, src)?.common; + let d = s.doc_mut()?; + d.modify_entity(h, |e| e.common = common)?; + d.remove_entity(src); + } + added(h) +} + +pub(super) struct RevcloudM { + kind: CloudKind, + pts: Vec, + arc_len: f64, + asking_arc: bool, + object: bool, + calligraphy: bool, + asking_style: bool, +} + +impl RevcloudM { + pub(super) fn new(s: &Session, kind: CloudKind) -> Self { + RevcloudM { kind, pts: Vec::new(), arc_len: default_arc_len(s), asking_arc: false, object: false, calligraphy: false, asking_style: false } + } + fn boundary(&self, extra: Option) -> Vec { + let mut pts = self.pts.clone(); + pts.extend(extra); + if self.kind == CloudKind::Rect + && let (Some(a), Some(b)) = (pts.first().copied(), pts.get(1).copied()) + { + return rect_vertices(a, b).into_iter().map(|v| v.p).collect(); + } + pts + } + fn finish(&mut self, s: &mut Session) -> Result { + let b = self.boundary(None); + match cloud_vertices(&b, self.arc_len, self.kind != CloudKind::Free, false) { + Some(vs) => { + s.add_entity(cloud_entity(vs, self.calligraphy, self.arc_len))?; + s.echo("Revision cloud finished."); + Ok(Step::Done) + } + None => Err(other("The revision cloud needs at least three points enclosing an area.")), + } + } +} + +impl Interactive for RevcloudM { + fn name(&self) -> &'static str { + "REVCLOUD" + } + fn begin(&mut self, s: &mut Session) -> Result { + let t = match self.kind { + CloudKind::Rect => "Rectangular", + CloudKind::Poly => "Polygonal", + CloudKind::Free => "Freehand", + }; + s.echo(format!("Minimum arc length: {0:.4} Maximum arc length: {0:.4} Style: Normal Type: {t}", self.arc_len)); + Ok(Step::Continue) + } + fn prompt(&self, _s: &Session) -> Prompt { + if self.asking_arc { + return Prompt::new("Specify minimum length of arc", Accept::NUMBER).default(format!("{:.4}", self.arc_len)); + } + if self.asking_style { + return Prompt::new("Select arc style", curves::KW).kw(&["Normal", "Calligraphy"]).default("Normal"); + } + if self.object { + return Prompt::new("Select object", Accept::POINT); + } + 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 { 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()), + (CloudKind::Free, 0) => Prompt::new("Specify first point", Accept::POINT).kw(first_kw).default("Object"), + (CloudKind::Free, _) => Prompt::new("Guide crosshairs along cloud path", Accept::POINT).base_opt(self.pts.last().copied()), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + if self.asking_arc { + if let Input::Text(t) = &i { + let v = number(t).filter(|v| *v > 0.0).ok_or_else(|| other("Requires a positive value."))?; + self.arc_len = v; + s.doc_mut()?.header.set_f64("REVCLOUDARCLEN", v); + } + self.asking_arc = false; + return Ok(Step::Continue); + } + if self.asking_style { + if let Input::Keyword(k) | Input::Text(k) = &i { + self.calligraphy = k.to_ascii_lowercase().starts_with('c'); + } + self.asking_style = false; + return Ok(Step::Continue); + } + if self.object { + let Input::Point(p) = i else { return if i == Input::Enter { Ok(Step::Cancel) } else { Ok(Step::Continue) } }; + let h = curves::pick_at(s, p).ok_or_else(|| other("*Invalid selection*"))?; + let pts = object_outline(s, h)?; + let vs = cloud_vertices(&pts, self.arc_len, false, false).ok_or_else(|| other("The object has no area."))?; + let nh = s.add_entity(cloud_entity(vs, self.calligraphy, self.arc_len))?; + let common = curves::entity(s, h)?.common; + let d = s.doc_mut()?; + d.modify_entity(nh, |e| e.common = common)?; + d.remove_entity(h); + s.echo("Revision cloud finished."); + return Ok(Step::Done); + } + match i { + Input::Keyword(k) => { + match k.as_str() { + "Arc length" => self.asking_arc = true, + "Object" => self.object = true, + "Rectangular" => self.kind = CloudKind::Rect, + "Polygonal" => self.kind = CloudKind::Poly, + "Freehand" => self.kind = CloudKind::Free, + "Style" => self.asking_style = true, + "Undo" => { + self.pts.pop(); + } + _ => {} + } + Ok(Step::Continue) + } + Input::Point(p) => { + self.pts.push(p); + if self.kind == CloudKind::Rect && self.pts.len() == 2 { + return self.finish(s); + } + if self.pts.len() > MAX_GEN { + return self.finish(s); + } + Ok(Step::Continue) + } + Input::Enter if self.pts.is_empty() => { + self.object = true; + Ok(Step::Continue) + } + Input::Enter => self.finish(s), + _ => Ok(Step::Continue), + } + } + fn preview(&self, _s: &Session, c: Vec2) -> Vec { + if self.pts.is_empty() || self.object { + return Vec::new(); + } + let b = self.boundary(Some(c)); + match cloud_vertices(&b, self.arc_len, self.kind != CloudKind::Free, false) { + Some(vs) if vs.len() < 2000 => vec![lwpoly(vs, true)], + _ => { + let mut pts = self.pts.clone(); + pts.push(c); + vec![lwpoly(pts.into_iter().map(PolyVertex::new).collect(), false)] + } + } + } +} diff --git a/crates/engine/src/cmd/draw2/tancircle.rs b/crates/engine/src/cmd/draw2/tancircle.rs new file mode 100644 index 000000000..48268987a --- /dev/null +++ b/crates/engine/src/cmd/draw2/tancircle.rs @@ -0,0 +1,137 @@ +use super::*; + +pub(super) fn run_circle_variant(s: &mut Session, p: &Value) -> Result { + let c = if let (Some(c), Some(d)) = (point_param(p, "center"), p.get("diameter").and_then(Value::as_f64)) { + Circle::new(c, d / 2.0) + } else if let (Some(a), Some(b)) = (point_param(p, "p1"), point_param(p, "p2")) { + match point_param(p, "p3") { + Some(c3) => Circle::from_3_points(a, b, c3).ok_or_else(|| bad("circle", "points are collinear"))?, + None => Circle::from_2_points(a, b), + } + } else { + return Err(bad("circle", "give {center, diameter}, {p1, p2} or {p1, p2, p3}")); + }; + if !(c.radius > 1e-12 && c.radius.is_finite()) { + return Err(bad("circle", "radius must be positive")); + } + let h = s.add_entity(circle(c.center, c.radius))?; + s.doc_mut()?.header.set_f64("CIRCLERAD", c.radius); + added(h) +} + +// ---------------- tangent circles ---------------- + +fn tan_obj(s: &Session, h: Handle, pick: Vec2) -> Result { + let e = curves::entity(s, h)?; + TanObj::from_kind(&e.kind, pick).ok_or_else(|| other("Select a line, circle, arc or polyline segment.")) +} + +fn pick_pair(cmd: &str, p: &Value, hk: &str, pk: &str) -> Result<(Handle, Vec2)> { + let h = curves::handle_param(p, hk).ok_or_else(|| bad(cmd, format!("`{hk}` is required")))?; + let q = point_req(cmd, p, pk)?; + Ok((h, q)) +} + +pub(super) fn run_circle_ttr(s: &mut Session, p: &Value) -> Result { + let (h1, p1) = pick_pair("circle.ttr", p, "h1", "p1")?; + let (h2, p2) = pick_pair("circle.ttr", p, "h2", "p2")?; + let r = f64_req("circle.ttr", p, "radius")?; + let c = curves::ttr(tan_obj(s, h1, p1)?, p1, tan_obj(s, h2, p2)?, p2, r).ok_or_else(|| other("Circle does not exist."))?; + let h = s.add_entity(circle(c.center, c.radius))?; + s.doc_mut()?.header.set_f64("CIRCLERAD", c.radius); + added(h) +} + +pub(super) fn run_circle_ttt(s: &mut Session, p: &Value) -> Result { + let (h1, p1) = pick_pair("circle.ttt", p, "h1", "p1")?; + let (h2, p2) = pick_pair("circle.ttt", p, "h2", "p2")?; + let (h3, p3) = pick_pair("circle.ttt", p, "h3", "p3")?; + let objs = [(tan_obj(s, h1, p1)?, p1), (tan_obj(s, h2, p2)?, p2), (tan_obj(s, h3, p3)?, p3)]; + let c = curves::ttt(objs).ok_or_else(|| other("Circle does not exist."))?; + added(s.add_entity(circle(c.center, c.radius))?) +} + +/// TTR (two picks + radius) or TTT (three picks). Also used by CIRCLE's `Ttr` option. +pub(crate) struct TanCircleM { + radius_mode: bool, + picks: Vec<(TanObj, Vec2)>, +} + +impl TanCircleM { + pub(crate) fn new(radius_mode: bool) -> Self { + TanCircleM { radius_mode, picks: Vec::new() } + } +} + +impl Interactive for TanCircleM { + fn name(&self) -> &'static str { + "CIRCLE" + } + fn prompt(&self, s: &Session) -> Prompt { + let n = self.picks.len(); + if self.radius_mode { + match n { + 0 => Prompt::new("Specify point on object for first tangent of circle", Accept::POINT), + 1 => Prompt::new("Specify point on object for second tangent of circle", Accept::POINT), + _ => { + let r = s.doc().map(|d| d.header.f64("CIRCLERAD", 0.0)).unwrap_or(0.0); + let p = Prompt::new("Specify radius of circle", Accept::POINT_OR_NUMBER).base_opt(self.picks.last().map(|x| x.1)).distance(); + if r > 0.0 { p.default(format!("{r:.4}")) } else { p } + } + } + } else { + let m = ["Specify first point on circle: _tan to", "Specify second point on circle: _tan to", "Specify third point on circle: _tan to"]; + Prompt::new(m.get(n).copied().unwrap_or(""), Accept::POINT) + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + let need = if self.radius_mode { 2 } else { 3 }; + if self.picks.len() < need { + return match i { + Input::Point(p) => { + let h = curves::pick_at(s, p).ok_or_else(|| other("*Invalid selection*"))?; + let o = tan_obj(s, h, p)?; + self.picks.push((o, p)); + if !self.radius_mode && self.picks.len() == 3 { + let (Some(a), Some(b), Some(c)) = (self.picks.first(), self.picks.get(1), self.picks.get(2)) else { + return Ok(Step::Cancel); + }; + let c = curves::ttt([*a, *b, *c]).ok_or_else(|| { + self.picks.clear(); + other("Circle does not exist.") + })?; + s.add_entity(circle(c.center, c.radius))?; + return Ok(Step::Done); + } + Ok(Step::Continue) + } + Input::Enter => Ok(Step::Cancel), + _ => Err(other("Select an object.")), + }; + } + let r = match i { + Input::Point(p) => self.picks.last().map(|x| x.1.dist(p)).unwrap_or(0.0), + Input::Text(t) => number(&t).ok_or_else(|| other("Requires numeric distance or second point."))?, + Input::Enter => s.doc()?.header.f64("CIRCLERAD", 0.0), + _ => return Ok(Step::Continue), + }; + if r.is_nan() || r <= 0.0 { + return Err(other("Value must be positive and nonzero.")); + } + let ((o1, p1), (o2, p2)) = match (self.picks.first(), self.picks.get(1)) { + (Some(a), Some(b)) => (*a, *b), + _ => return Ok(Step::Cancel), + }; + match curves::ttr(o1, p1, o2, p2, r) { + Some(c) => { + s.add_entity(circle(c.center, c.radius))?; + s.doc_mut()?.header.set_f64("CIRCLERAD", r); + Ok(Step::Done) + } + None => { + s.echo("Circle does not exist."); + Ok(Step::Done) + } + } + } +} diff --git a/crates/engine/src/cmd/draw2_tests.rs b/crates/engine/src/cmd/draw2/tests.rs similarity index 100% rename from crates/engine/src/cmd/draw2_tests.rs rename to crates/engine/src/cmd/draw2/tests.rs diff --git a/crates/engine/src/cmd/draw2/wipeout.rs b/crates/engine/src/cmd/draw2/wipeout.rs new file mode 100644 index 000000000..212b645f3 --- /dev/null +++ b/crates/engine/src/cmd/draw2/wipeout.rs @@ -0,0 +1,137 @@ +use super::*; + +// ===================================================================================== +// WIPEOUT +// ===================================================================================== + +fn set_frames(s: &mut Session, mode: &str) -> Result { + let v = match mode.trim().to_ascii_lowercase().as_str() { + "on" | "1" => 1, + "off" | "0" => 0, + "display" | "d" | "2" | "display but not plot" => 2, + _ => return Err(other("Frames mode must be ON, OFF or Display.")), + }; + s.doc_mut()?.header.set_i64("WIPEOUTFRAME", v); + Ok(v) +} + +fn wipeout_from_polyline(s: &mut Session, h: Handle, erase: bool) -> Result { + let e = curves::entity(s, h)?; + let EntityKind::LwPolyline(p) = &e.kind else { return Err(other("Select a closed polyline.")) }; + if !p.closed || p.vertices.len() < 3 { + return Err(other("The polyline must be closed with at least three vertices.")); + } + if p.vertices.iter().any(|v| v.bulge.abs() > 1e-12) { + return Err(other("Polylines with arc segments cannot be used for wipeouts.")); + } + let nh = s.add_entity(EntityKind::Wipeout(Wipeout { boundary: p.vertices.iter().map(|v| v.p).collect() }))?; + if erase { + s.doc_mut()?.remove_entity(h); + } + Ok(nh) +} + +pub(super) fn run_wipeout(s: &mut Session, p: &Value) -> Result { + if let Some(f) = str_param(p, "frames") { + let v = set_frames(s, f)?; + return Ok(json!({ "WIPEOUTFRAME": v })); + } + if let Some(h) = curves::handle_param(p, "handle") { + return added(wipeout_from_polyline(s, h, bool_or(p, "erase", false))?); + } + let pts = points_param(p, "points").ok_or_else(|| bad("wipeout", "`points` (3 or more) or `handle` is required"))?; + if pts.len() < 3 || pts.len() > MAX_GEN || shoelace(&pts).abs() < 1e-12 { + return Err(bad("wipeout", "need 3+ points enclosing an area")); + } + added(s.add_entity(EntityKind::Wipeout(Wipeout { boundary: pts }))?) +} + +#[derive(Default)] +pub(super) struct WipeoutM { + pts: Vec, + phase: u8, // 0 points, 1 select polyline, 2 erase?, 3 frames + poly: Option, +} + +impl Interactive for WipeoutM { + fn name(&self) -> &'static str { + "WIPEOUT" + } + fn prompt(&self, _s: &Session) -> Prompt { + match (self.phase, self.pts.len()) { + (1, _) => Prompt::new("Select a closed polyline", Accept::POINT), + (2, _) => Prompt::new("Erase polyline?", curves::KW).kw(&["Yes", "No"]).default("No"), + (3, _) => Prompt::new("Enter mode", curves::KW).kw(&["ON", "OFF", "Display but not plot"]).default("ON"), + (_, 0) => Prompt::new("Specify first point", Accept::POINT).kw(&["Frames", "Polyline"]).default("Polyline"), + (_, 1) => Prompt::new("Specify next point", Accept::POINT).base_opt(self.pts.last().copied()), + (_, _) => Prompt::new("Specify next point", Accept::POINT).kw(&["Undo"]).base_opt(self.pts.last().copied()), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + match (self.phase, i) { + (1, Input::Point(p)) => { + let h = curves::pick_at(s, p).ok_or_else(|| other("*Invalid selection*"))?; + let e = curves::entity(s, h)?; + if !matches!(&e.kind, EntityKind::LwPolyline(pl) if pl.closed) { + return Err(other("Select a closed polyline.")); + } + self.poly = Some(h); + self.phase = 2; + Ok(Step::Continue) + } + (2, inp @ (Input::Keyword(_) | Input::Text(_) | Input::Enter)) => { + let erase = matches!(&inp, Input::Keyword(k) | Input::Text(k) if k.to_ascii_lowercase().starts_with('y')); + let h = self.poly.ok_or_else(|| other("No polyline."))?; + wipeout_from_polyline(s, h, erase)?; + Ok(Step::Done) + } + (3, inp @ (Input::Keyword(_) | Input::Text(_) | Input::Enter)) => { + let m = match &inp { + Input::Keyword(k) | Input::Text(k) => k.clone(), + _ => "on".into(), + }; + let m = if m.to_ascii_lowercase().starts_with('d') { "display".to_string() } else { m }; + set_frames(s, &m)?; + Ok(Step::Done) + } + (0, Input::Keyword(k)) => { + match k.as_str() { + "Frames" => self.phase = 3, + "Polyline" => self.phase = 1, + "Undo" => { + self.pts.pop(); + } + _ => {} + } + Ok(Step::Continue) + } + (0, Input::Point(p)) => { + if self.pts.len() < MAX_GEN { + self.pts.push(p); + } + Ok(Step::Continue) + } + (0, Input::Enter) if self.pts.is_empty() => { + self.phase = 1; + Ok(Step::Continue) + } + (0, Input::Enter) => { + if self.pts.len() < 3 || shoelace(&self.pts).abs() < 1e-12 { + return Err(other("A wipeout needs three or more points enclosing an area.")); + } + s.add_entity(EntityKind::Wipeout(Wipeout { boundary: self.pts.clone() }))?; + Ok(Step::Done) + } + (_, Input::Enter) => Ok(Step::Cancel), + _ => Ok(Step::Continue), + } + } + fn preview(&self, _s: &Session, c: Vec2) -> Vec { + if self.phase != 0 || self.pts.is_empty() { + return Vec::new(); + } + let mut pts = self.pts.clone(); + pts.push(c); + vec![lwpoly(pts.into_iter().map(PolyVertex::new).collect(), true)] + } +} diff --git a/crates/engine/src/cmd/draw2/xline.rs b/crates/engine/src/cmd/draw2/xline.rs new file mode 100644 index 000000000..1e4b6cdae --- /dev/null +++ b/crates/engine/src/cmd/draw2/xline.rs @@ -0,0 +1,260 @@ +use super::*; + +// ===================================================================================== +// XLINE with Hor/Ver/Ang/Bisect/Offset +// ===================================================================================== + +fn add_xline(s: &mut Session, base: Vec2, dir: Vec2) -> Result { + let d = dir.normalized(); + if d == Vec2::ZERO || !base.is_finite() { + return Err(other("Invalid direction.")); + } + s.add_entity(EntityKind::XLine(RayLine { base: v3(base), dir: v3(d) })) +} + +/// A line-like object for XLINE Offset/Reference: (base, direction). For a polyline, the +/// segment nearest `pick` (the first without one), which must be straight. +fn linear_of(s: &Session, h: Handle, pick: Option) -> Result<(Vec2, Vec2)> { + let e = curves::entity(s, h)?; + match &e.kind { + EntityKind::Line(l) => Ok((l.a.xy(), (l.b.xy() - l.a.xy()).normalized())), + EntityKind::XLine(r) | EntityKind::Ray(r) => Ok((r.base.xy(), r.dir.xy().normalized())), + EntityKind::LwPolyline(p) => { + let segs = Polyline { vertices: p.vertices.clone(), closed: p.closed }.segments(); + let seg = match pick { + Some(q) => segs.iter().min_by(|a, b| a.closest(q).dist(q).total_cmp(&b.closest(q).dist(q))), + None => segs.first(), + }; + match seg { + Some(cadcraft_geom::Segment::Line(l)) => Ok((l.a, l.dir())), + _ => Err(other("Select a line object.")), + } + } + _ => Err(other("Select a line object.")), + } +} + +fn bisect_dir(v: Vec2, a: Vec2, b: Vec2) -> Option { + let u1 = (a - v).normalized(); + let u2 = (b - v).normalized(); + if u1 == Vec2::ZERO || u2 == Vec2::ZERO { + return None; + } + let s = u1 + u2; + if s.len() < 1e-12 { Some(u1.perp()) } else { Some(s.normalized()) } +} + +fn offset_xline(base: Vec2, dir: Vec2, dist: f64, side: Vec2) -> (Vec2, Vec2) { + let n = dir.perp(); + let sgn = if n.dot(side - base) >= 0.0 { 1.0 } else { -1.0 }; + (base + n * (dist * sgn), dir) +} + +pub(crate) fn run_xline2(s: &mut Session, p: &Value) -> Result { + if let (Some(v), Some(a), Some(b)) = (point_param(p, "vertex"), point_param(p, "start"), point_param(p, "end")) { + let d = bisect_dir(v, a, b).ok_or_else(|| bad("xline", "degenerate angle"))?; + return added(add_xline(s, v, d)?); + } + if let Some(h) = curves::handle_param(p, "handle") { + let (base, dir) = linear_of(s, h, None)?; + let (nb, nd) = if let Some(t) = point_param(p, "through") { + (t, dir) + } else { + let dist = f64_req("xline", p, "distance")?; + let side = point_req("xline", p, "side")?; + offset_xline(base, dir, dist, side) + }; + return added(add_xline(s, nb, nd)?); + } + let b = point_param(p, "base").or_else(|| point_param(p, "through")).ok_or_else(|| bad("xline", "`base` is required"))?; + let dir = if bool_or(p, "hor", false) { + Vec2::X + } else if bool_or(p, "ver", false) { + Vec2::Y + } else if let (Some(t), Some(_)) = (point_param(p, "through"), point_param(p, "base")) { + t - b + } else { + Vec2::from_angle(f64_req("xline", p, "angle")?.to_radians()) + }; + if dir.len() < 1e-12 { + return Err(bad("xline", "through point equals base")); + } + added(add_xline(s, b, dir)?) +} + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +enum XMode { + #[default] + Two, + Fixed(f64), + AngAsk, + RefSelect, + RefAngle(f64), + Bisect, + OffDist, + OffSelect, + OffSide, +} + +#[derive(Default)] +pub(crate) struct XlineM2 { + mode: XMode, + base: Option, + bis: Vec, + offset: Option, // None = Through + line: Option<(Vec2, Vec2)>, +} + +impl Interactive for XlineM2 { + fn name(&self) -> &'static str { + "XLINE" + } + fn prompt(&self, s: &Session) -> Prompt { + match self.mode { + XMode::Two => match self.base { + None => Prompt::new("Specify a point", Accept::POINT).kw(&["Hor", "Ver", "Ang", "Bisect", "Offset"]), + Some(b) => Prompt::new("Specify through point", Accept::POINT).base(b), + }, + XMode::Fixed(_) => Prompt::new("Specify through point", Accept::POINT), + XMode::AngAsk => Prompt::new("Enter angle of xline (0) or", Accept::NUMBER).kw(&["Reference"]), + XMode::RefSelect => Prompt::new("Select a line object", Accept::POINT), + XMode::RefAngle(_) => Prompt::new("Enter angle of xline", Accept::NUMBER).default("0"), + XMode::Bisect => match self.bis.len() { + 0 => Prompt::new("Specify angle vertex point", Accept::POINT), + 1 => Prompt::new("Specify angle start point", Accept::POINT).base_opt(self.bis.first().copied()), + _ => Prompt::new("Specify angle end point", Accept::POINT).base_opt(self.bis.first().copied()), + }, + XMode::OffDist => { + let d = s.doc().map(|d| d.header.f64("OFFSETDIST", 1.0)).unwrap_or(1.0); + Prompt::new("Specify offset distance or", Accept::NUMBER).kw(&["Through"]).default(if d < 0.0 { + "Through".into() + } else { + format!("{d:.4}") + }) + } + XMode::OffSelect => Prompt::new("Select a line object", Accept::POINT), + XMode::OffSide => Prompt::new(if self.offset.is_some() { "Specify side to offset" } else { "Specify through point" }, Accept::POINT), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + match (self.mode, i) { + (_, Input::Enter) if matches!(self.mode, XMode::OffDist) => { + let d = s.doc()?.header.f64("OFFSETDIST", 1.0); + self.offset = (d >= 0.0).then_some(d); + self.mode = XMode::OffSelect; + Ok(Step::Continue) + } + (_, Input::Enter) => Ok(Step::Done), + (XMode::Two, Input::Keyword(k)) => { + self.mode = match k.as_str() { + "Hor" => XMode::Fixed(0.0), + "Ver" => XMode::Fixed(std::f64::consts::FRAC_PI_2), + "Ang" => XMode::AngAsk, + "Bisect" => XMode::Bisect, + _ => XMode::OffDist, + }; + Ok(Step::Continue) + } + (XMode::Two, Input::Point(p)) => { + match self.base { + None => self.base = Some(p), + Some(b) => { + if !b.near(p, 1e-12) { + add_xline(s, b, p - b)?; + } + } + } + Ok(Step::Continue) + } + (XMode::Fixed(a), Input::Point(p)) => { + add_xline(s, p, Vec2::from_angle(a))?; + Ok(Step::Continue) + } + (XMode::AngAsk, Input::Keyword(_)) => { + self.mode = XMode::RefSelect; + Ok(Step::Continue) + } + (XMode::AngAsk, Input::Text(t)) => { + let a = s.angle_settings().direction(&t).ok_or_else(|| other("Requires a valid angle."))?; + self.mode = XMode::Fixed(a); + Ok(Step::Continue) + } + (XMode::RefSelect, Input::Point(p)) => { + let h = curves::pick_at(s, p).ok_or_else(|| other("*Invalid selection*"))?; + let (_, d) = linear_of(s, h, Some(p))?; + self.mode = XMode::RefAngle(d.angle()); + Ok(Step::Continue) + } + (XMode::RefAngle(base), Input::Text(t)) => { + let a = s.angle_settings().rotation(&t).ok_or_else(|| other("Requires a valid angle."))?; + self.mode = XMode::Fixed(base + a); + Ok(Step::Continue) + } + (XMode::Bisect, Input::Point(p)) => { + if self.bis.len() < 2 { + self.bis.push(p); + } else if let (Some(v), Some(a)) = (self.bis.first().copied(), self.bis.get(1).copied()) { + let d = bisect_dir(v, a, p).ok_or_else(|| other("Invalid angle."))?; + add_xline(s, v, d)?; + } + Ok(Step::Continue) + } + (XMode::OffDist, Input::Keyword(_)) => { + self.offset = None; + s.doc_mut()?.header.set_f64("OFFSETDIST", -1.0); + self.mode = XMode::OffSelect; + Ok(Step::Continue) + } + (XMode::OffDist, Input::Text(t)) => { + let d = number(&t).filter(|d| *d >= 0.0).ok_or_else(|| other("Requires a non-negative distance."))?; + self.offset = Some(d); + s.doc_mut()?.header.set_f64("OFFSETDIST", d); + self.mode = XMode::OffSelect; + Ok(Step::Continue) + } + (XMode::OffSelect, Input::Point(p)) => { + let h = curves::pick_at(s, p).ok_or_else(|| other("*Invalid selection*"))?; + self.line = Some(linear_of(s, h, Some(p))?); + self.mode = XMode::OffSide; + Ok(Step::Continue) + } + (XMode::OffSide, Input::Point(p)) => { + let (b, d) = self.line.ok_or_else(|| other("No line selected."))?; + let (nb, nd) = match self.offset { + Some(dist) => offset_xline(b, d, dist, p), + None => (p, d), + }; + add_xline(s, nb, nd)?; + self.mode = XMode::OffSelect; + Ok(Step::Continue) + } + _ => Err(other("Invalid input.")), + } + } + fn preview(&self, _s: &Session, c: Vec2) -> Vec { + let xl = |b: Vec2, d: Vec2| { + let d = d.normalized(); + (d != Vec2::ZERO).then(|| EntityKind::XLine(RayLine { base: v3(b), dir: v3(d) })) + }; + match self.mode { + XMode::Two => self.base.and_then(|b| xl(b, c - b)).into_iter().collect(), + XMode::Fixed(a) => xl(c, Vec2::from_angle(a)).into_iter().collect(), + XMode::Bisect => match self.bis.as_slice() { + [v, a] => bisect_dir(*v, *a, c).and_then(|d| xl(*v, d)).into_iter().collect(), + _ => Vec::new(), + }, + XMode::OffSide => self + .line + .and_then(|(b, d)| { + let (nb, nd) = match self.offset { + Some(dist) => offset_xline(b, d, dist, c), + None => (c, d), + }; + xl(nb, nd) + }) + .into_iter() + .collect(), + _ => Vec::new(), + } + } +} diff --git a/crates/engine/src/cmd/inquiry.rs b/crates/engine/src/cmd/inquiry.rs index f1bfd8073..c7f59a0cf 100644 --- a/crates/engine/src/cmd/inquiry.rs +++ b/crates/engine/src/cmd/inquiry.rs @@ -11,6 +11,7 @@ use super::{CommandSpec, bad, bool_or, f64_req, point_req, point_value, points_p use crate::units::{format_angle, format_distance}; use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step}; +mod arcs; mod loops; mod regions; mod shapes; @@ -418,6 +419,9 @@ fn run_area(s: &mut Session, p: &Value) -> Result { let h = targets(s, p)?.first().copied().ok_or_else(|| bad("area", "`points` or `handle` is required"))?; object_area(s, h)? }; + if let Some(what) = too_large(a, per) { + return Err(bad("area", format!("the {what} is too large"))); + } Ok(json!({ "area": a, "perimeter": per, "message": area_text(s, a, per) })) } @@ -456,6 +460,17 @@ fn fmt_volume(s: &Session, v: f64) -> String { format_distance(v, if matches!(u, 3 | 4) { 2 } else { u }, p) } +/// "area" or "perimeter" when that value is past f64's range, `None` when both can be reported. +fn too_large(a: f64, per: f64) -> Option<&'static str> { + if !a.is_finite() { + Some("area") + } else if !per.is_finite() { + Some("perimeter") + } else { + None + } +} + /// 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()?; @@ -694,10 +709,16 @@ pub(super) struct DistM { sources: Vec>, /// Asking for the length of a segment (the Length option). length: bool, + /// The bulge of the segment that starts at each point (the Arc option). + bulges: Vec, + arcs: arcs::Arcs, } -/// Total length of the segments through `pts`. -fn path_len(pts: &[Vec2]) -> f64 { +/// Total length of the segments through `pts`, each with its bulge in `bulges`. +fn path_len(pts: &[Vec2], bulges: &[f64]) -> f64 { + if arcs::has_arcs(bulges) { + return arcs::polyline(pts, bulges, false).len(); + } pts.windows(2) .map(|w| match w { [a, b] => a.dist(*b), @@ -707,15 +728,11 @@ fn path_len(pts: &[Vec2]) -> f64 { } /// The point `len` beyond the last of `pts`, continuing the last segment (along X after a single -/// point): the Length option of AREA and DIST's point sequences. -fn extend_last(pts: &[Vec2], len: f64) -> Option { +/// point, along the tangent after an arc segment): the Length option of AREA and DIST's point +/// sequences. +fn extend_last(pts: &[Vec2], bulges: &[f64], len: f64) -> Option { let last = *pts.last()?; - let dir = match pts.len().checked_sub(2).and_then(|i| pts.get(i)) { - Some(prev) => (last - *prev).normalized(), - None => Vec2::X, - }; - let dir = if dir == Vec2::ZERO { Vec2::X } else { dir }; - let p = last + dir * len; + let p = last + arcs::tangent(pts, bulges) * len; p.is_finite().then_some(p) } @@ -724,13 +741,21 @@ fn length_prompt() -> Prompt { } /// Feed the Length option's answer: the next point, or `None` when the option was dismissed (Enter). -fn length_input(pts: &[Vec2], i: Input) -> Result> { +fn length_input(pts: &[Vec2], bulges: &[f64], i: Input) -> Result> { let t = match i { Input::Text(t) => t, _ => return Ok(None), }; let len = crate::units::parse_distance(&t).filter(|v| v.is_finite()).ok_or_else(|| crate::EngineError::Other("Requires a distance.".into()))?; - Ok(extend_last(pts, len)) + Ok(extend_last(pts, bulges, len)) +} + +/// Where the paper point `p`, computed near `last` (an arc option's end point), lies in the model +/// space of the viewport `last` was picked through (the last of `sources`). +fn follow_source(sources: &[Option], last: Vec2, p: Vec2) -> Option { + let source = sources.last().copied().flatten()?; + let point = source.point + (p - last) / source.scale; + (source.scale.abs() > 1e-12 && point.is_finite()).then_some(crate::snap::ViewportPoint { point, ..source }) } /// Model units apply only when every point was snapped through one and the same viewport. @@ -743,10 +768,13 @@ impl Interactive for DistM { fn name(&self) -> &'static str { "DIST" } - fn prompt(&self, _s: &Session) -> Prompt { + fn prompt(&self, s: &Session) -> Prompt { if self.length { return length_prompt(); } + if let Some(p) = self.multi.as_deref().and_then(|pts| self.arcs.prompt(s, pts, &self.bulges, "")) { + return p; + } match (&self.multi, self.first) { (Some(pts), _) if pts.len() < 2 => { Prompt::new("Specify second point", Accept::POINT).kw(&["Arc", "Length", "Undo"]).base_opt(pts.last().copied()) @@ -763,7 +791,7 @@ impl Interactive for DistM { if let Some(pts) = &mut self.multi { if self.length { let model = model_points(&self.sources); - if let Some(p) = length_input(model.as_deref().unwrap_or(pts), i)? { + if let Some(p) = length_input(model.as_deref().unwrap_or(pts), &self.bulges, i)? { let source = model.as_ref().and_then(|_| self.sources.last().copied().flatten()); let paper = match (source, pts.last()) { (Some(source), Some(last)) => *last + (p - source.point) * source.scale, @@ -772,31 +800,64 @@ impl Interactive for DistM { if !paper.is_finite() { return Err(bad("dist", "the next point is too large")); } - pts.push(paper); + arcs::push(pts, &mut self.bulges, paper, 0.0); self.sources.push(source.map(|source| crate::snap::ViewportPoint { point: p, ..source })); let model = model_points(&self.sources); - s.echo(format!("Distance = {}", fmt_d(s, path_len(model.as_deref().unwrap_or(pts))))); + s.echo(format!("Distance = {}", fmt_d(s, path_len(model.as_deref().unwrap_or(pts), &self.bulges)))); } self.length = false; return Ok(Step::Continue); } + if self.arcs.asking() { + let last = pts.last().copied(); + // A picked end point keeps its own viewport; an end computed from the answers follows the + // last point's. In model units, a typed radius or chord length is in model units too. + let picked_end = match i { + Input::Point(p) if self.arcs.asks_end_point() => Some(p), + _ => None, + }; + let i = match model_points(&self.sources).and(self.sources.last().copied().flatten()) { + Some(source) => self.arcs.scale_typed_size(i, source.scale), + None => i, + }; + if self.arcs.answer(s, i, pts, &mut self.bulges)? { + // The arc can end off the picked point (CEnter projects it onto the circle). + let source = match (picked_end, last, pts.last()) { + (Some(p), _, Some(end)) => follow_source(&[s.picked_viewport], p, *end), + (None, Some(last), Some(end)) => follow_source(&self.sources, last, *end), + _ => None, + }; + self.sources.push(source); + let model = model_points(&self.sources); + s.echo(format!("Distance = {}", fmt_d(s, path_len(model.as_deref().unwrap_or(pts), &self.bulges)))); + } + return Ok(Step::Continue); + } match i { Input::Point(p) => { - pts.push(p); + self.arcs.point(pts, &mut self.bulges, p)?; self.sources.push(s.picked_viewport); let model = model_points(&self.sources); - s.echo(format!("Distance = {}", fmt_d(s, path_len(model.as_deref().unwrap_or(pts))))); + s.echo(format!("Distance = {}", fmt_d(s, path_len(model.as_deref().unwrap_or(pts), &self.bulges)))); } Input::Keyword(k) if k == "Undo" => { if pts.len() > 1 { - pts.pop(); + self.arcs.undo(pts, &mut self.bulges); self.sources.pop(); } } Input::Keyword(k) if k == "Length" => self.length = true, - Input::Keyword(k) if k == "Total" => return self.total(s), - Input::Keyword(k) => s.echo(format!("{k}: not available yet")), - Input::Enter => return self.total(s), + Input::Keyword(k) if k == "Total" => return self.total(s, false), + Input::Keyword(k) if k == "CLose" && self.arcs.on => { + self.arcs.close(pts, &mut self.bulges)?; + return self.total(s, true); + } + Input::Keyword(k) => { + if !self.arcs.keyword(&k, pts) { + s.echo(format!("{k}: not available yet")); + } + } + Input::Enter => return self.total(s, false), _ => {} } return Ok(Step::Continue); @@ -830,20 +891,20 @@ impl Interactive for DistM { } fn preview(&self, _s: &Session, c: Vec2) -> Vec { match &self.multi { - Some(pts) => { - let mut v: Vec = pts.iter().map(|p| cadcraft_geom::PolyVertex::new(*p)).collect(); - v.push(cadcraft_geom::PolyVertex::new(c)); - vec![super::helpers::lwpoly(v, false)] - } + Some(pts) => self.arcs.preview(pts, &self.bulges, c, false), None => self.first.map(|a| vec![super::helpers::line(a, c)]).unwrap_or_default(), } } } impl DistM { - fn total(&self, s: &mut Session) -> Result { + /// Report the total distance and end; `closed` adds the closing segment (CLose). + fn total(&self, s: &mut Session, closed: bool) -> Result { let model = model_points(&self.sources); - let d = model.as_deref().or(self.multi.as_deref()).map_or(0.0, path_len); + let d = model + .as_deref() + .or(self.multi.as_deref()) + .map_or(0.0, |pts| if closed { arcs::polyline(pts, &self.bulges, true).len() } else { path_len(pts, &self.bulges) }); s.echo(format!("Distance = {}", fmt_d(s, d))); Ok(Step::Done) } @@ -936,6 +997,9 @@ pub(super) struct AreaM { pending: Vec, /// The first point of a height given by two points. height_from: Option, + /// The bulge of the segment that starts at each point (the Arc option). + bulges: Vec, + arcs: arcs::Arcs, } impl AreaM { @@ -963,6 +1027,57 @@ impl AreaM { 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()) { @@ -988,27 +1103,45 @@ impl AreaM { } /// 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. + /// An area, perimeter or total past f64's range is not reported or added to the total. fn measured(&mut self, s: &mut Session, a: f64, per: f64) -> Step { if self.volume { + if !a.is_finite() { + s.echo("The area is too large."); + return if self.mode == AreaMode::Single { Step::Done } else { Step::Continue }; + } self.pending.push(a); return Step::Continue; } - s.echo(area_text(s, a, per)); - match self.mode { - AreaMode::Single => return Step::Done, - AreaMode::Add => self.total += a, - AreaMode::Subtract => self.total -= a, + let total = self.total_with(a); + if let Some(what) = too_large(a, per).or_else(|| (!total.is_finite()).then_some("total area")) { + s.echo(format!("The {what} is too large.")); + return if self.mode == AreaMode::Single { Step::Done } else { Step::Continue }; } + s.echo(area_text(s, a, per)); + if self.mode == AreaMode::Single { + return Step::Done; + } + self.total = total; s.echo(format!("Total area = {}", fmt_d(s, self.total))); Step::Continue } /// Close the boundary picked so far (Enter or Total at the next-point prompt). + /// An arc segment (the Arc option) can close an area of two points. fn close(&mut self, s: &mut Session) -> Step { let pts = std::mem::take(&mut self.pts); - if pts.len() < 3 { + let bulges = std::mem::take(&mut self.bulges); + self.arcs = arcs::Arcs::default(); + let curved = arcs::has_arcs(&bulges); + if pts.len() < if curved { 2 } else { 3 } { return if self.mode == AreaMode::Single { Step::Done } else { Step::Continue }; } - let (a, per) = area_of(&pts); + let (a, per) = if curved { + let g = arcs::polyline(&pts, &bulges, true); + (g.area().abs(), g.len()) + } else { + area_of(&pts) + }; self.measured(s, a, per) } } @@ -1017,7 +1150,7 @@ impl Interactive for AreaM { fn name(&self) -> &'static str { if self.volume { "MEASUREGEOM" } else { "AREA" } } - fn prompt(&self, _s: &Session) -> Prompt { + 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), @@ -1030,6 +1163,9 @@ impl Interactive for AreaM { if self.length { return length_prompt(); } + if let Some(p) = self.arcs.prompt(s, &self.pts, &self.bulges, self.mode_prefix()) { + return p; + } let next = format!("{}Specify next point", self.mode_prefix()); let (add, subtract) = self.mode_keywords(); match (self.pts.len(), self.mode) { @@ -1042,72 +1178,40 @@ impl Interactive for AreaM { } fn input(&mut self, s: &mut Session, i: Input) -> Result { if !self.pending.is_empty() { - // A height is typed, or given by two points (their distance). - match (i, self.height_from) { - (Input::Point(p), None) => self.height_from = Some(p), - (Input::Point(p), Some(from)) => { - self.height_from = None; - return Ok(self.height(s, p.dist(from))); - } - (Input::Text(t), None) => match crate::units::parse_distance(&t).filter(|v| v.is_finite()) { - Some(h) => return Ok(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."), - _ => {} - } - return Ok(Step::Continue); + 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); - } - // 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 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)? { - self.pts.push(p); + if let Some(p) = length_input(&self.pts, &self.bulges, i)? { + arcs::push(&mut self.pts, &mut self.bulges, p, 0.0); } self.length = false; return Ok(Step::Continue); } + if self.arcs.asking() { + self.arcs.answer(s, i, &mut self.pts, &mut self.bulges)?; + return Ok(Step::Continue); + } match i { - Input::Point(p) => self.pts.push(p), + Input::Point(p) => self.arcs.point(&mut self.pts, &mut self.bulges, p)?, Input::Keyword(k) => match k.as_str() { "Object" => self.object = true, "Add area" | "Add volume" => self.mode = AreaMode::Add, "Subtract area" | "Subtract volume" => self.mode = AreaMode::Subtract, - "Undo" => { - self.pts.pop(); - } + "Undo" => self.arcs.undo(&mut self.pts, &mut self.bulges), "Length" => self.length = true, "Total" => return Ok(self.close(s)), - _ => s.echo(format!("{k}: not available yet")), + "CLose" if self.arcs.on => { + self.arcs.close(&self.pts, &mut self.bulges)?; + return Ok(self.close(s)); + } + k => { + if !self.arcs.keyword(k, &self.pts) { + s.echo(format!("{k}: not available yet")); + } + } }, Input::Enter if self.pts.is_empty() => match self.mode { // Enter takes the default, Object; in add/subtract mode it ends the command. @@ -1123,9 +1227,7 @@ impl Interactive for AreaM { if self.pts.is_empty() || self.object || self.length { return Vec::new(); } - let mut v: Vec = self.pts.iter().map(|p| cadcraft_geom::PolyVertex::new(*p)).collect(); - v.push(cadcraft_geom::PolyVertex::new(c)); - vec![super::helpers::lwpoly(v, true)] + self.arcs.preview(&self.pts, &self.bulges, c, true) } } diff --git a/crates/engine/src/cmd/inquiry/arcs.rs b/crates/engine/src/cmd/inquiry/arcs.rs new file mode 100644 index 000000000..416e9f941 --- /dev/null +++ b/crates/engine/src/cmd/inquiry/arcs.rs @@ -0,0 +1,195 @@ +//! The Arc option of AREA's and DIST's point sequences (and of MEASUREGEOM's Distance, Area and +//! Volume): arc segments, asked for as PLINE's arc mode asks for them. Each point carries the +//! bulge of the segment that starts at it; the last point's bulge is the closing segment's, +//! which only CLose sets. + +use cadcraft_doc::EntityKind; +use cadcraft_geom::{PolyVertex, Polyline, Vec2, arc_to_bulge, bulge_to_arc}; + +use super::super::helpers::lwpoly; +use super::super::machines::number; +use super::super::pline_opts::{Outcome, PlineAsk}; + +use crate::{Accept, EngineError, Input, Prompt, Result, Session}; + +/// Arc mode's keywords: PLINE's arc mode without Halfwidth and Width. +const KEYWORDS: [&str; 8] = ["Angle", "CEnter", "CLose", "Direction", "Line", "Radius", "Second pt", "Undo"]; + +/// Arc mode of a point sequence. +#[derive(Default)] +pub(super) struct Arcs { + /// The Arc option turned arc mode on; Line turns it off. + pub(super) on: bool, + /// An arc option waiting for its answers (Angle, CEnter, Direction, Radius, Second pt). + ask: Option, +} + +/// The polyline through `pts`, each segment with its bulge in `bulges` (straight when missing). +pub(super) fn polyline(pts: &[Vec2], bulges: &[f64], closed: bool) -> Polyline { + let vertices = pts.iter().enumerate().map(|(i, p)| PolyVertex::with_bulge(*p, bulges.get(i).copied().unwrap_or(0.0))).collect(); + Polyline { vertices, closed } +} + +/// Whether any segment of the sequence is an arc. +pub(super) fn has_arcs(bulges: &[f64]) -> bool { + bulges.iter().any(|b| *b != 0.0) +} + +/// The direction the sequence runs in at its last point (+X before the second point): along the +/// last line segment, or along the tangent at the end of the last arc segment. +pub(super) fn tangent(pts: &[Vec2], bulges: &[f64]) -> Vec2 { + let Some(i) = pts.len().checked_sub(2) else { return Vec2::X }; + let (Some(prev), Some(last)) = (pts.get(i).copied(), pts.last().copied()) else { return Vec2::X }; + let d = match bulge_to_arc(prev, last, bulges.get(i).copied().unwrap_or(0.0)) { + Some((a, ccw)) => { + let r = (last - a.center).perp().normalized(); + if ccw { r } else { -r } + } + None => (last - prev).normalized(), + }; + if d == Vec2::ZERO { Vec2::X } else { d } +} + +/// The bulge of an arc from the last point to `p` that continues the sequence's direction; an +/// error when `p` lies straight behind the last point, where no such arc exists. +fn tangent_bulge(pts: &[Vec2], bulges: &[f64], p: Vec2) -> Result { + let Some(last) = pts.last().copied() else { return Ok(0.0) }; + let dir = tangent(pts, bulges); + let chord = p - last; + // The included angle is twice the angle between the tangent and the chord. + let theta = dir.cross(chord).atan2(dir.dot(chord)); + let b = arc_to_bulge(2.0 * theta); + if theta.abs() < std::f64::consts::PI - 1e-6 && b.is_finite() { Ok(b) } else { Err(EngineError::Other("Invalid arc endpoint.".into())) } +} + +/// Add the segment from the last point to `end` with bulge `bulge` (the first point when there is none). +pub(super) fn push(pts: &mut Vec, bulges: &mut Vec, end: Vec2, bulge: f64) { + bulges.resize(pts.len(), 0.0); + if let Some(b) = bulges.last_mut() { + *b = bulge; + } + pts.push(end); + bulges.push(0.0); +} + +impl Arcs { + /// An arc option is waiting for its answers. + pub(super) fn asking(&self) -> bool { + self.ask.is_some() + } + + /// The question being answered takes the arc's end point, so a picked point is the end of the segment. + pub(super) fn asks_end_point(&self) -> bool { + matches!( + self.ask, + Some(PlineAsk::AngleEnd(_) | PlineAsk::CenterEnd(_) | PlineAsk::DirectionEnd(_) | PlineAsk::RadiusEnd(_) | PlineAsk::SecondEnd(_)) + ) + } + + /// A typed radius or chord length multiplied by `scale`, for a sequence measured in another + /// space's units than the one its points are in; any other input as it is. + pub(super) fn scale_typed_size(&self, i: Input, scale: f64) -> Input { + let size = matches!(self.ask, Some(PlineAsk::Radius | PlineAsk::AngleRadius(_) | PlineAsk::CenterLength(_))); + match i { + Input::Text(t) if size => match number(&t).map(|v| v * scale).filter(|v| v.is_finite()) { + Some(v) => Input::Text(v.to_string()), + None => Input::Text(t), + }, + i => i, + } + } + + /// The arc-mode prompt, or the question of the arc option being answered; `None` in line mode + /// and before the first point. + pub(super) fn prompt(&self, s: &Session, pts: &[Vec2], bulges: &[f64], prefix: &str) -> Option { + let st = pts.last().copied()?; + if let Some(a) = self.ask { + return Some(a.prompt(st, tangent(pts, bulges), s)); + } + self.on.then(|| Prompt::new(format!("{prefix}Specify endpoint of arc"), Accept::POINT).kw(&KEYWORDS).base(st)) + } + + /// Feed an answer to the arc option being answered; true when it added a segment. + pub(super) fn answer(&mut self, s: &Session, i: Input, pts: &mut Vec, bulges: &mut Vec) -> Result { + let (Some(a), Some(st)) = (self.ask, pts.last().copied()) else { + self.ask = None; + return Ok(false); + }; + match a.input(s, i, st, tangent(pts, bulges))? { + Outcome::Ask(next) => self.ask = Some(next), + Outcome::Back => self.ask = None, + Outcome::Segment(end, bulge) => { + self.ask = None; + push(pts, bulges, end, bulge); + return Ok(true); + } + } + Ok(false) + } + + /// Arc, Line, and arc mode's Angle, CEnter, Direction, Radius and Second pt; false for any + /// other keyword. + pub(super) fn keyword(&mut self, k: &str, pts: &[Vec2]) -> bool { + match k { + "Arc" if !pts.is_empty() => self.on = true, + "Line" if self.on => self.on = false, + _ => match PlineAsk::from_keyword(k, true) { + Some(a) if self.on && !pts.is_empty() => self.ask = Some(a), + _ => return false, + }, + } + true + } + + /// A point at the next-point prompt: in arc mode, the end of an arc that continues the + /// sequence's direction; otherwise the end of a line segment. + pub(super) fn point(&self, pts: &mut Vec, bulges: &mut Vec, p: Vec2) -> Result<()> { + let bulge = if self.on && !pts.is_empty() { tangent_bulge(pts, bulges, p)? } else { 0.0 }; + push(pts, bulges, p, bulge); + Ok(()) + } + + /// CLose: the closing segment becomes an arc from the last point to the first that continues + /// the sequence's direction. + pub(super) fn close(&self, pts: &[Vec2], bulges: &mut Vec) -> Result<()> { + let Some(first) = pts.first().copied().filter(|_| pts.len() >= 2) else { + return Err(EngineError::Other("CLose needs at least two points.".into())); + }; + let bulge = tangent_bulge(pts, bulges, first)?; + bulges.resize(pts.len(), 0.0); + if let Some(b) = bulges.last_mut() { + *b = bulge; + } + Ok(()) + } + + /// Undo: remove the last point and the segment to it. Removing the first point leaves arc mode. + pub(super) fn undo(&mut self, pts: &mut Vec, bulges: &mut Vec) { + pts.pop(); + bulges.truncate(pts.len()); + if let Some(b) = bulges.last_mut() { + *b = 0.0; + } + if pts.is_empty() { + *self = Arcs::default(); + } + } + + /// The sequence with the segment to the cursor `c` that a click would add. + pub(super) fn preview(&self, pts: &[Vec2], bulges: &[f64], c: Vec2, closed: bool) -> Vec { + let Some(st) = pts.last().copied() else { return Vec::new() }; + let next = match self.ask { + Some(a) => a.preview(st, tangent(pts, bulges), c), + None if self.on => tangent_bulge(pts, bulges, c).ok().map(|b| (c, b)), + None => Some((c, 0.0)), + }; + let mut g = polyline(pts, bulges, closed); + if let Some((end, bulge)) = next { + if let Some(v) = g.vertices.last_mut() { + v.bulge = bulge; + } + g.vertices.push(PolyVertex::new(end)); + } + vec![lwpoly(g.vertices, closed)] + } +} diff --git a/crates/engine/src/cmd/inquiry/loops.rs b/crates/engine/src/cmd/inquiry/loops.rs index 20c4ac6b8..0be936451 100644 --- a/crates/engine/src/cmd/inquiry/loops.rs +++ b/crates/engine/src/cmd/inquiry/loops.rs @@ -117,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); @@ -140,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(); @@ -225,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/usage.rs b/crates/engine/src/cmd/inquiry/usage.rs index 7bf6a2d17..a206c9218 100644 --- a/crates/engine/src/cmd/inquiry/usage.rs +++ b/crates/engine/src/cmd/inquiry/usage.rs @@ -432,5 +432,4 @@ pub fn run(s: &mut Session, p: &Value) -> Result { } #[cfg(test)] -#[path = "usage_tests.rs"] mod tests; diff --git a/crates/engine/src/cmd/inquiry/usage_tests.rs b/crates/engine/src/cmd/inquiry/usage/tests.rs similarity index 100% rename from crates/engine/src/cmd/inquiry/usage_tests.rs rename to crates/engine/src/cmd/inquiry/usage/tests.rs diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index 924aed5ee..9821055e9 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -1,4 +1,4 @@ -//! Layouts, paper-space viewports, page setup and plotting: LAYOUT (new/delete/rename/copy), +//! Layouts, paper-space viewports, page setup and plotting: LAYOUT (new/delete/rename/copy/saveas), //! MVIEW / VPORTS, viewport scale/lock/layer settings, PAGESETUP, PLOT and EXPORTPDF. //! //! Plotting goes through the host's [`super::file::IoHooks::plot`] hook so the engine stays @@ -22,7 +22,7 @@ pub fn specs() -> Vec { vec![ CommandSpec::new("layout", "Layout", run_layout) .alias(&["lo"]) - .params("{option: new|copy|delete|rename|set|list, name?, to?} (typed LAYOUT / -LAYOUT: option prompts)") + .params("{option: new|copy|delete|rename|saveas|set|list, name?, to?, path? (saveas)} (typed LAYOUT / -LAYOUT: option prompts)") .interactive(|_| Ok(Box::new(prompts::LayoutM::default()))), CommandSpec::new("layout.new", "New Layout", run_new) .menu(&["Insert", "Layout", "New Layout"]) @@ -32,6 +32,9 @@ pub fn specs() -> Vec { CommandSpec::new("layout.rename", "Rename Layout", run_rename).params("{from?: current layout, to}"), CommandSpec::new("layout.copy", "Copy Layout", run_copy).params("{from, to?}"), CommandSpec::new("layout.list", "List Layouts", run_list).params("→ [{name, tabOrder, page, viewports}]").noundo(), + CommandSpec::new("layout.saveas", "Save Layout as Template", saveas::run_saveas) + .params("{name?: layout (default: the current layout, else the first tab), path: .dwt (or .dwg, .dxf; no extension adds .dwt)} → {name, path, bytes, notSaved?}") + .noundo(), CommandSpec::new("mview", "Viewports", run_mview) .alias(&["mv"]) .params( @@ -334,6 +337,9 @@ fn run_list(s: &mut Session, _p: &Value) -> Result { )) } +/// LAYOUT SAveas: a layout written as a drawing template. +mod saveas; + /// `-LAYOUT`-style dispatcher. fn run_layout(s: &mut Session, p: &Value) -> Result { let opt = str_param(p, "option").unwrap_or("list").to_ascii_lowercase(); @@ -362,8 +368,9 @@ fn run_layout(s: &mut Session, p: &Value) -> Result { let set = find_command("layout.set").ok_or_else(|| bad("layout", "layout.set is missing"))?; (set.run)(s, p) } + "saveas" | "sa" => saveas::run_saveas(s, p), "list" | "?" => run_list(s, p), - _ => Err(bad("layout", "option must be new, copy, delete, rename, set or list")), + _ => Err(bad("layout", "option must be new, copy, delete, rename, saveas, set or list")), } } @@ -884,303 +891,4 @@ fn plot_window(s: &Session, p: &Value, cmd: &str, space: &Space) -> Result Session { - let mut s = Session::new(); - s.cmdline("line 0,0 100,0").unwrap(); - s.cmdline("").unwrap(); - s.cmdline("circle 50,25 20").unwrap(); - s - } - - fn viewports(s: &Session, layout: &str) -> Vec<(Handle, Viewport)> { - s.doc() - .unwrap() - .layout(layout) - .unwrap() - .entities - .iter() - .filter_map(|e| if let EntityKind::Viewport(v) = &e.kind { Some((e.handle, v.clone())) } else { None }) - .collect() - } - - #[test] - fn new_layout_gets_viewport_zoomed_to_extents() { - let mut s = session_with_model(); - let r = s.execute("layout.new", &json!({})).unwrap(); - assert_eq!(r["name"], "Layout3"); - let vps = viewports(&s, "Layout3"); - assert_eq!(vps.len(), 1); - let (_, v) = &vps[0]; - let sheet = cadcraft_render::sheet(s.doc().unwrap(), "Layout3").unwrap(); - assert!((v.width - sheet.printable.width()).abs() < 1e-9 && (v.height - sheet.printable.height()).abs() < 1e-9); - assert!(v.id >= 2); - // Model extents (0,0)..(100,45) are centred and fully visible. - assert!(v.view_center.near(Vec2::new(50.0, 22.5), 1e-6)); - let model_w = v.view_height * v.width / v.height; - assert!(model_w >= 100.0 && v.view_height >= 45.0); - // The render shows model geometry inside the viewport only. - let list = cadcraft_render::build(s.doc().unwrap(), &Space::Paper("Layout3".into()), &cadcraft_render::Options::default()); - let rect = Bounds2::new(v.center.xy() - Vec2::new(v.width, v.height) / 2.0, v.center.xy() + Vec2::new(v.width, v.height) / 2.0).expand(1e-9); - let content: Vec<_> = list.prims.iter().filter(|p| p.handle == cadcraft_render::VIEWPORT_CONTENT).collect(); - assert!(content.len() >= 2); - assert!(content.iter().all(|p| list.points(p).iter().all(|q| rect.contains(*q)))); - assert!(list.sheet.is_some()); - // Undo removes the layout. - s.undo().unwrap(); - assert!(s.doc().unwrap().layout("Layout3").is_none()); - } - - #[test] - fn rename_keeps_the_active_layout_whatever_its_case() { - let mut s = session_with_model(); - s.doc_mut().unwrap().active_layout = Some("layout1".into()); - s.execute("layout.rename", &json!({"from": "Layout1", "to": "Sheet A"})).unwrap(); - assert_eq!(s.doc().unwrap().active_layout.as_deref(), Some("Sheet A")); - } - - #[test] - fn rename_copy_delete() { - let mut s = session_with_model(); - s.execute("layout.set", &json!({"name": "Layout1"})).unwrap(); - s.execute("layout.rename", &json!({"to": "Sheet A"})).unwrap(); - assert_eq!(s.space(), Space::Paper("Sheet A".into())); - assert!(s.execute("layout.rename", &json!({"from": "Sheet A", "to": "Layout2"})).is_err(), "duplicate"); - assert!(s.execute("layout.rename", &json!({"from": "Sheet A", "to": "a/b"})).is_err(), "bad char"); - assert!(s.execute("layout.rename", &json!({"from": "Sheet A", "to": "model"})).is_err(), "reserved"); - s.execute("mview", &json!({"p1": [1, 1], "p2": [5, 4]})).unwrap(); - let r = s.execute("layout.copy", &json!({"from": "Sheet A"})).unwrap(); - assert_eq!(r["name"], "Sheet A (2)"); - let a = viewports(&s, "Sheet A"); - let b = viewports(&s, "Sheet A (2)"); - assert_eq!(a.len(), 1); - assert_eq!(b.len(), 1); - assert_ne!(a[0].0, b[0].0, "copied entities get new handles"); - s.execute("layout.delete", &json!({"name": "sheet a"})).unwrap(); - assert_eq!(s.space(), Space::Model, "deleting the current layout returns to Model"); - s.execute("layout", &json!({"option": "delete", "name": "Layout2"})).unwrap(); - assert!(s.execute("layout.delete", &json!({"name": "Sheet A (2)"})).is_err(), "last layout stays"); - let l = s.execute("layout", &json!({"option": "list"})).unwrap(); - assert_eq!(l.as_array().unwrap().len(), 1); - assert!(s.execute("layout.delete", &json!({"name": "Model"})).is_err()); - } - - #[test] - fn new_layout_from_the_menu_asks_for_a_name() { - let mut s = session_with_model(); - s.start("layout.new").unwrap(); - assert_eq!(s.current_prompt().map(|p| p.display()).as_deref(), Some("Enter name of new layout :")); - s.cmdline("Layout1").unwrap(); - assert!(s.running.is_some(), "a taken name asks again"); - s.cmdline("Sheet A").unwrap(); - assert!(s.running.is_none()); - s.start("layout.new").unwrap(); - s.cmdline("").unwrap(); - let d = s.doc().unwrap(); - assert!(d.layout("Sheet A").is_some() && d.layout("Layout3").is_some(), "Enter takes the default name"); - } - - #[test] - fn mview_arrangements_and_interactive() { - let mut s = session_with_model(); - assert!(s.execute("mview", &json!({"p1": [0, 0], "p2": [1, 1]})).is_err(), "not in model space"); - s.execute("mview", &json!({"layout": "Layout2", "count": 4})).unwrap(); - assert_eq!(viewports(&s, "Layout2").len(), 4); - s.execute("vports.3", &json!({"layout": "Layout2", "p1": [0, 0], "p2": [9, 6]})).unwrap(); - let v = viewports(&s, "Layout2"); - assert_eq!(v.len(), 7); - let total: f64 = v[4..].iter().map(|(_, v)| v.width * v.height).sum(); - assert!((total - 54.0).abs() < 1e-9, "three viewports tile the window"); - // Interactive MVIEW: two corners. - s.execute("layout.set", &json!({"name": "Layout1"})).unwrap(); - s.cmdline("mview").unwrap(); - s.cmdline("1,1").unwrap(); - s.cmdline("6,4").unwrap(); - assert!(s.running.is_none()); - let v = viewports(&s, "Layout1"); - assert_eq!(v.len(), 1); - assert!((v[0].1.width - 5.0).abs() < 1e-9 && (v[0].1.height - 3.0).abs() < 1e-9); - // MVIEW 2 → arrangement (Enter: Vertical) → Fit (Enter). - s.cmdline("mview").unwrap(); - s.cmdline("2").unwrap(); - s.cmdline("").unwrap(); - s.cmdline("").unwrap(); - assert!(s.running.is_none()); - assert_eq!(viewports(&s, "Layout1").len(), 3); - // One undo step per MVIEW. - s.undo().unwrap(); - assert_eq!(viewports(&s, "Layout1").len(), 1); - // In model space the interactive command ends at once. - s.execute("layout.set", &json!({"name": "Model"})).unwrap(); - s.cmdline("vports.4").unwrap(); - assert!(s.running.is_none()); - } - - #[test] - fn viewport_scale_lock_and_layers() { - let mut s = session_with_model(); - s.execute("layout.set", &json!({"name": "Layout1"})).unwrap(); - let r = s.execute("mview", &json!({"p1": [1, 1], "p2": [5, 4]})).unwrap(); - let h = r["viewports"][0].as_str().unwrap().to_string(); - let out = s.execute("viewport.set", &json!({"handle": h, "scale": "1:20", "center": [50, 20]})).unwrap(); - assert!((out[0]["viewHeight"].as_f64().unwrap() - 60.0).abs() < 1e-9); - assert!((out[0]["scale"].as_f64().unwrap() - 0.05).abs() < 1e-12); - s.execute("viewport.set", &json!({"handle": h, "locked": true})).unwrap(); - assert!(s.execute("viewport.set", &json!({"handle": h, "scale": 0.1})).is_err(), "locked"); - s.execute("viewport.set", &json!({"handle": h, "scale": 0.1, "locked": false})).unwrap(); - s.execute("vplayer", &json!({"handle": h, "freeze": ["0", "Walls"]})).unwrap(); - s.execute("vplayer", &json!({"handle": h, "thaw": "walls"})).unwrap(); - let v = viewports(&s, "Layout1"); - assert_eq!(v[0].1.frozen_layers, vec!["0".to_string()]); - s.execute("vplayer", &json!({"handle": h, "colors": {"Walls": "red"}})).unwrap(); - assert!(s.execute("vplayer", &json!({"handle": h, "colors": {"Walls": "bylayer"}})).is_err()); - assert!(s.execute("vplayer", &json!({"handle": h, "colors": {"Walls": [1]}})).is_err()); - let vv = viewports(&s, "Layout1"); - assert_eq!(vv[0].1.layer_colors, vec![("Walls".to_string(), cadcraft_color::Color::Index(1))]); - s.execute("vplayer", &json!({"handle": h, "colors": {"walls": null}})).unwrap(); - assert!(viewports(&s, "Layout1")[0].1.layer_colors.is_empty()); - assert!((v[0].1.view_height - 30.0).abs() < 1e-9); - // Layer 0 frozen in the viewport: nothing of the model shows. - let list = cadcraft_render::build(s.doc().unwrap(), &Space::Paper("Layout1".into()), &cadcraft_render::Options::default()); - assert!(list.prims.iter().all(|p| p.handle != cadcraft_render::VIEWPORT_CONTENT)); - for bad_p in [ - json!({"handle": h, "scale": "0:0"}), - json!({"handle": h, "scale": -1}), - json!({"handle": h, "viewHeight": 0}), - json!({"handle": "FFFFFF"}), - ] { - assert!(s.execute("viewport.set", &bad_p).is_err(), "{bad_p}"); - } - } - - #[test] - fn pagesetup_paper_table() { - let mut s = Session::new(); - let r = s.execute("pagesetup", &json!({"layout": "Layout1", "paper": "A3", "landscape": true, "margins": [5, 5, 5, 5]})).unwrap(); - assert_eq!(r["page"]["widthMm"], 297.0); - let sheet = cadcraft_render::sheet(s.doc().unwrap(), "Layout1").unwrap(); - // Imperial drawing: paper units are inches. - assert!((sheet.size.x - 420.0 / 25.4).abs() < 1e-9 && (sheet.size.y - 297.0 / 25.4).abs() < 1e-9); - assert!(s.execute("pagesetup", &json!({"layout": "Layout1", "paper": "Z12"})).is_err()); - assert!(s.execute("pagesetup", &json!({"layout": "Layout1", "margins": [1, 2]})).is_err()); - assert!(s.execute("pagesetup", &json!({"layout": "Layout1", "width": 1e308, "height": 5})).is_err()); - assert!(s.execute("pagesetup", &json!({"paper": "A4"})).is_err(), "model tab needs a layout"); - for area in ["extents", "display", "window", "limits"] { - let e = s.execute("pagesetup", &json!({"layout": "Layout1", "plotArea": area})).unwrap_err(); - assert!(e.to_string().contains("not available yet"), "{area}: {e}"); - } - let r = s.execute("pagesetup", &json!({"layout": "Layout1", "plotArea": "Layout"})).unwrap(); - assert_eq!(r["page"]["plotArea"], "layout"); - let report = s.execute("pagesetup", &Value::Null).unwrap(); - assert!(report["papers"].as_array().unwrap().len() >= 10); - s.execute("pagesetup", &json!({"layout": "Layout2", "width": 500, "height": 300})).unwrap(); - let p = &s.doc().unwrap().layout("Layout2").unwrap().page; - assert_eq!((p.width_mm, p.height_mm), (300.0, 500.0)); - } - - fn fake_plot(d: &Drawing, space: &Space, opts: &Value) -> std::result::Result, String> { - let _ = d; - Ok(format!("%PDF-1.4 fake {space:?} {opts}").into_bytes()) - } - - #[test] - fn plot_goes_through_the_hook() { - super::super::file::set_io(super::super::file::IoHooks { - read: |_, _| Err("no reader in tests".into()), - write: |_, _| Err("no writer in tests".into()), - plot: Some(fake_plot), - }); - let mut s = session_with_model(); - if io().and_then(|h| h.plot).is_none() { - return; // another test installed hooks without a plotter first - } - let r = s.execute("plot", &json!({"layout": "Layout1", "paper": "A4"})).unwrap(); - let data = super::super::file::base64_decode(r["data"].as_str().unwrap()).unwrap(); - let text = String::from_utf8(data).unwrap(); - assert!(text.starts_with("%PDF") && text.contains("Paper(\"Layout1\")") && text.contains("A4")); - let r = s.execute("exportpdf", &json!({})).unwrap(); - assert_eq!(r["layout"], "Model"); - let baseline = String::from_utf8(super::super::file::base64_decode(r["data"].as_str().unwrap()).unwrap()).unwrap(); - assert!(!baseline.contains("\"center\"") && !baseline.contains("\"offset\""), "omitted options remain omitted: {baseline}"); - assert!(s.execute("plot", &json!({"layout": "Nope"})).is_err()); - // Plot area and a ratio scale reach the plotter as a model-space window and a number. - let opts = |r: Value| String::from_utf8(super::super::file::base64_decode(r["data"].as_str().unwrap()).unwrap()).unwrap(); - let p = json!({"layout": "Model", "plotArea": "window", "window": [[10, 0], [0, 5]], "fit": false, "scale": "1:2"}); - let text = opts(s.execute("plot", &p).unwrap()); - assert!(text.contains(r#""window":[[0.0,0.0],[10.0,5.0]]"#) && text.contains(r#""scale":0.5"#), "{text}"); - let text = opts(s.execute("plot", &json!({"layout": "Model", "center": false, "offset": [12.5, -7.0]})).unwrap()); - assert!(text.contains(r#""center":false"#) && text.contains(r#""offset":[12.5,-7.0]"#), "{text}"); - for offset in [json!([1]), json!([1, 2, 3]), json!([f64::NAN, 0]), json!([100_001, 0])] { - assert!(s.execute("plot", &json!({"layout": "Model", "center": false, "offset": offset})).is_err()); - } - assert!(s.execute("plot", &json!({"layout": "Model", "output": "svg", "center": false, "offset": [0, 0]})).is_err()); - assert!(opts(s.execute("plot", &json!({"layout": "Model", "plotArea": "limits"})).unwrap()).contains(r#""window":[[0.0,0.0],"#)); - assert!(s.execute("plot", &json!({"layout": "Model", "plotArea": "window"})).is_err(), "window needs corners"); - assert!(s.execute("plot", &json!({"layout": "Layout1", "plotArea": "display"})).is_err(), "layouts plot their sheet"); - assert!(s.execute("plot", &json!({"layout": "Model", "scale": -1})).is_err()); - #[cfg(not(target_arch = "wasm32"))] - { - let path = std::env::temp_dir().join(format!("cadcraft-plot-test-{}.pdf", std::process::id())); - let ps = path.to_string_lossy().to_string(); - let r = s.execute("plot", &json!({"path": ps})).unwrap(); - assert!(r["bytes"].as_u64().unwrap() > 0); - assert!(std::fs::read(&path).unwrap().starts_with(b"%PDF")); - let _ = std::fs::remove_file(&path); - } - } - - #[test] - fn hostile_layout_params() { - let mut s = session_with_model(); - let long = "x".repeat(300); - for (c, p) in [ - ("layout.new", json!({"name": ""})), - ("layout.new", json!({"name": long})), - ("layout.new", json!({"name": "a\u{0}b"})), - ("layout.new", json!({"name": "Layout1"})), - ("mview", json!({"layout": "Layout1", "p1": [1e308, -1e308], "p2": [-1e308, 1e308]})), - ("mview", json!({"layout": "Layout1", "p1": [1, 1], "p2": [1, 1]})), - ("mview", json!({"layout": "Layout1", "count": 99})), - ("mview", json!({"layout": "Layout1", "count": -1})), - ("layout.rename", json!({"from": "Layout1"})), - ("layout.copy", json!({"from": "nope"})), - ("viewport.set", json!({"handles": ["zz"]})), - ] { - assert!(s.execute(c, &p).is_err(), "{c} {p}"); - } - } - - #[test] - fn mspace_edits_model_through_viewport() { - let mut s = session_with_model(); - let before = s.doc().unwrap().model.len(); - s.execute("layout.new", &json!({"name": "L"})).unwrap(); - s.execute("layout.set", &json!({"name": "L"})).unwrap(); - assert!(s.execute("mspace", &json!({"at": [-1000, -1000]})).is_err()); - let vps = viewports(&s, "L"); - let (h, vp) = vps.iter().find(|(_, v)| v.id != 1).cloned().unwrap(); - s.execute("mspace", &json!({"at": [vp.center.x, vp.center.y]})).unwrap(); - assert_eq!(s.space(), Space::Model); - assert_eq!(s.layout_space(), Space::Paper("L".into())); - // The screen view in MSPACE maps the viewport centre to its model view centre. - let st = s.state().unwrap(); - let (pv, v) = (st.paper_view(), st.view()); - let k = vp.view_height / vp.height; - assert!((v.height - pv.height * k).abs() < 1e-9); - // New geometry goes to model space. - s.cmdline("circle 10,10 3").unwrap(); - assert_eq!(s.doc().unwrap().model.len(), before + 1); - // Zooming changes the viewport's model view, not the sheet. - s.state_mut().unwrap().set_view(crate::View { center: v.center, height: v.height / 2.0 }); - let vp2 = viewports(&s, "L").into_iter().find(|(x, _)| *x == h).unwrap().1; - assert!((vp2.view_height - vp.view_height / 2.0).abs() < 1e-9); - assert_eq!(s.state().unwrap().paper_view(), pv); - s.execute("pspace", &json!({})).unwrap(); - assert_eq!(s.space(), Space::Paper("L".into())); - s.execute("mspace", &json!({})).unwrap(); - s.execute("layout.set", &json!({"name": "Model"})).unwrap(); - assert!(s.state().unwrap().mspace.is_none()); - } -} +mod tests; diff --git a/crates/engine/src/cmd/layout/prompts.rs b/crates/engine/src/cmd/layout/prompts.rs index d25b40c86..0d0e20f9b 100644 --- a/crates/engine/src/cmd/layout/prompts.rs +++ b/crates/engine/src/cmd/layout/prompts.rs @@ -25,6 +25,8 @@ enum Stage { RenameFrom, RenameTo(String), Set, + SaveasLayout, + SaveasFile(String), List, } @@ -34,7 +36,7 @@ pub struct LayoutM { } /// The layout the name prompts default to: the current one, else the first tab. -fn current_layout(s: &Session) -> Option { +pub(super) fn current_layout(s: &Session) -> Option { if let Space::Paper(n) = s.layout_space() { return Some(n); } @@ -70,9 +72,10 @@ impl LayoutM { "New" => Stage::New, "Rename" => Stage::RenameFrom, "Set" => Stage::Set, + "SAveas" => Stage::SaveasLayout, "?" => Stage::List, other => { - // Template, SAveas: report and end, leaving the next input to the command line. + // Template: report and end, leaving the next input to the command line. s.echo(format!("LAYOUT {other} is not available yet.")); return Ok(Step::Done); } @@ -131,6 +134,8 @@ impl Interactive for LayoutM { let tab = s.layout_space().tab_name().to_string(); Prompt::new("Enter layout to make current", Accept::TEXT).default(tab) } + Stage::SaveasLayout => with_cur(Prompt::new("Enter layout to save to template", Accept::TEXT)), + Stage::SaveasFile(layout) => Prompt::new("Enter template file name", Accept::TEXT).default(super::saveas::default_path(s, layout)), Stage::List => Prompt::new("Enter layout name(s) to list", Accept::TEXT).default("*"), } } @@ -199,6 +204,19 @@ impl Interactive for LayoutM { (set.run)(s, &json!({ "name": n })).map_err(retry)?; Ok(Step::Done) } + Stage::SaveasLayout => { + let n = need(name.or_else(|| current_layout(s)))?; + let n = super::layout_name("layout.saveas", s.doc()?, &n).map_err(retry)?; + self.stage = Stage::SaveasFile(n); + Ok(Step::Continue) + } + Stage::SaveasFile(layout) => { + let path = name.unwrap_or_else(|| super::saveas::default_path(s, &layout)); + let r = super::saveas::run_saveas(s, &json!({ "name": layout, "path": path })).map_err(retry)?; + let path = r.get("path").and_then(Value::as_str).unwrap_or_default(); + s.echo(format!("Layout \"{layout}\" saved to template \"{path}\".")); + Ok(Step::Done) + } Stage::List => { Self::list(s, name.as_deref().unwrap_or("*"))?; Ok(Step::Done) diff --git a/crates/engine/src/cmd/layout/saveas.rs b/crates/engine/src/cmd/layout/saveas.rs new file mode 100644 index 000000000..b6d3f6873 --- /dev/null +++ b/crates/engine/src/cmd/layout/saveas.rs @@ -0,0 +1,133 @@ +//! LAYOUT SAveas: a layout written to a drawing template file (`.dwt`, or a `.dwg` or `.dxf`). +//! +//! The template holds the layout's page setup and paper space objects, viewports included, and +//! no model space objects and no other layout. Named objects the layout does not use (blocks, +//! layers, linetypes, text, dimension, multileader, multiline and table styles, empty groups) +//! are left out, as PURGE All removes them; layers the layout's viewports freeze or recolor stay. +//! The open drawing is not changed. + +use std::collections::BTreeSet; + +use cadcraft_doc::{Common, Drawing, Entity, EntityKind, EntityStore, Point}; +use cadcraft_geom::Vec3; +use serde_json::{Value, json}; + +use super::super::file::{drops_unknown, io, not_saved, not_saved_message}; +use super::super::{bad, str_param}; +use super::layout_name; +use super::prompts::current_layout; +use crate::{Result, Session}; + +/// File types a layout template is written as; a path without an extension gets `.dwt`. +const TEMPLATE_TYPES: [&str; 3] = ["dwt", "dwg", "dxf"]; + +/// The most PURGE All passes run on a template; they stop at the first pass that purges nothing. +const MAX_PURGE_PASSES: usize = 64; + +/// The file a SAveas typed at the command line suggests: `.dwt` beside the drawing. +pub(super) fn default_path(s: &Session, layout: &str) -> String { + let file = format!("{layout}.dwt"); + match s.state().ok().and_then(|st| st.path.clone()) { + Some(p) => std::path::Path::new(&p).with_file_name(&file).to_string_lossy().into_owned(), + None => file, + } +} + +/// `path` with `.dwt` added when it has no extension; an error for any other file type. +fn template_path(path: &str) -> Result { + let path = path.trim(); + if path.is_empty() { + return Err(bad("layout.saveas", "`path` is empty")); + } + match std::path::Path::new(path).extension() { + None => Ok(format!("{path}.dwt")), + Some(e) if TEMPLATE_TYPES.iter().any(|t| e.eq_ignore_ascii_case(t)) => Ok(path.to_string()), + Some(_) => Err(bad("layout.saveas", "a layout template is saved as a .dwt, .dwg or .dxf file")), + } +} + +/// The drawing a template of `layout` holds: `d` with that layout alone, an empty model space, +/// and without the named objects nothing left in it uses. +fn template_of(d: &Drawing, layout: &str) -> Result { + let mut t = d.clone(); + t.layouts.retain(|l| l.name == layout); + t.active_layout = Some(layout.to_string()); + t.model = EntityStore::new(); + t.hidden_objects.clear(); + let gone: Vec = t.constraints.iter().filter(|c| c.refs.iter().any(|r| t.entity(r.handle).is_none())).map(|c| c.id).collect(); + t.constraints.retain(|c| !gone.contains(&c.id)); + // Layers the layout's viewports freeze or recolor stay, with those settings. PURGE counts + // only objects, so each such layer gets a point in model space while PURGE runs. + let named: BTreeSet = t + .layouts + .iter() + .flat_map(|l| l.entities.iter()) + .filter_map(|e| match &e.kind { + EntityKind::Viewport(v) => Some(v.frozen_layers.iter().cloned().chain(v.layer_colors.iter().map(|(n, _)| n.clone())).collect::>()), + _ => None, + }) + .flatten() + .collect(); + for layer in named { + let handle = t.new_handle(); + t.model.push(Entity { handle, common: Common { layer, ..Common::default() }, kind: EntityKind::Point(Point { p: Vec3::ZERO, angle: 0.0 }) }); + } + // PURGE runs on a session of its own, so the open drawing keeps everything. + let mut scratch = Session::empty(); + scratch.open_drawing(t, layout, None); + for _ in 0..MAX_PURGE_PASSES { + let r = super::super::blocks::purge::run(&mut scratch, &json!({ "type": "all" }))?; + let purged = r.get("purged").and_then(Value::as_object).is_some_and(|o| o.values().any(|v| v.as_array().is_some_and(|a| !a.is_empty()))); + if !purged { + break; + } + } + let st = scratch.docs.pop().ok_or_else(|| bad("layout.saveas", "the template could not be made"))?; + let mut t = std::sync::Arc::unwrap_or_clone(st.doc); + t.model = EntityStore::new(); + Ok(t) +} + +/// `layout.saveas {name?, path}`: write a layout as a drawing template. +pub(super) fn run_saveas(s: &mut Session, p: &Value) -> Result { + let path = template_path(str_param(p, "path").ok_or_else(|| bad("layout.saveas", "`path` is required"))?)?; + if s.doc()?.refedit.is_some() { + return Err(bad("layout.saveas", "A block reference is being edited in place: finish with REFCLOSE (Save or Discard) before saving.")); + } + let name = match str_param(p, "name") { + Some(n) => n.to_string(), + None => current_layout(s).ok_or_else(|| bad("layout.saveas", "the drawing has no layout"))?, + }; + // A file is the drawing outside the Block Editor. + let d = s.state()?.drawing(); + let name = layout_name("layout.saveas", d, &name)?; + let t = template_of(d, &name)?; + let hooks = io().ok_or_else(|| bad("layout.saveas", "file formats are not available in this build"))?; + let bytes = (hooks.write)(&t, &path).map_err(|e| bad("layout.saveas", e))?; + store(&path, &bytes)?; + let mut r = json!({ "name": name, "path": path, "bytes": bytes.len() }); + let lost = not_saved(&t); + if drops_unknown(&path) + && let Some(msg) = not_saved_message(&lost) + { + s.echo(msg); + if let Some(o) = r.as_object_mut() { + o.insert("notSaved".into(), json!(lost)); + } + } + Ok(r) +} + +/// Write the template: natively to `path` through a temporary file; on the web as a download. +fn store(path: &str, bytes: &[u8]) -> Result<()> { + #[cfg(not(target_arch = "wasm32"))] + { + // Exclusively create a fresh temporary file and rename it into place so a planted + // `.cadcraft-tmp` symlink cannot redirect the write (CWE-59). + crate::atomic_write::write_atomic(std::path::Path::new(path), bytes).map_err(|e| bad("layout.saveas", format!("{path}: {e}"))) + } + #[cfg(target_arch = "wasm32")] + { + super::super::exchange::deliver_file("layout.saveas", path, bytes, super::super::file::deliver()) + } +} diff --git a/crates/engine/src/cmd/layout/tests.rs b/crates/engine/src/cmd/layout/tests.rs new file mode 100644 index 000000000..03356b892 --- /dev/null +++ b/crates/engine/src/cmd/layout/tests.rs @@ -0,0 +1,295 @@ +use super::*; + +fn session_with_model() -> Session { + let mut s = Session::new(); + s.cmdline("line 0,0 100,0").unwrap(); + s.cmdline("").unwrap(); + s.cmdline("circle 50,25 20").unwrap(); + s +} + +fn viewports(s: &Session, layout: &str) -> Vec<(Handle, Viewport)> { + s.doc() + .unwrap() + .layout(layout) + .unwrap() + .entities + .iter() + .filter_map(|e| if let EntityKind::Viewport(v) = &e.kind { Some((e.handle, v.clone())) } else { None }) + .collect() +} + +#[test] +fn new_layout_gets_viewport_zoomed_to_extents() { + let mut s = session_with_model(); + let r = s.execute("layout.new", &json!({})).unwrap(); + assert_eq!(r["name"], "Layout3"); + let vps = viewports(&s, "Layout3"); + assert_eq!(vps.len(), 1); + let (_, v) = &vps[0]; + let sheet = cadcraft_render::sheet(s.doc().unwrap(), "Layout3").unwrap(); + assert!((v.width - sheet.printable.width()).abs() < 1e-9 && (v.height - sheet.printable.height()).abs() < 1e-9); + assert!(v.id >= 2); + // Model extents (0,0)..(100,45) are centred and fully visible. + assert!(v.view_center.near(Vec2::new(50.0, 22.5), 1e-6)); + let model_w = v.view_height * v.width / v.height; + assert!(model_w >= 100.0 && v.view_height >= 45.0); + // The render shows model geometry inside the viewport only. + let list = cadcraft_render::build(s.doc().unwrap(), &Space::Paper("Layout3".into()), &cadcraft_render::Options::default()); + let rect = Bounds2::new(v.center.xy() - Vec2::new(v.width, v.height) / 2.0, v.center.xy() + Vec2::new(v.width, v.height) / 2.0).expand(1e-9); + let content: Vec<_> = list.prims.iter().filter(|p| p.handle == cadcraft_render::VIEWPORT_CONTENT).collect(); + assert!(content.len() >= 2); + assert!(content.iter().all(|p| list.points(p).iter().all(|q| rect.contains(*q)))); + assert!(list.sheet.is_some()); + // Undo removes the layout. + s.undo().unwrap(); + assert!(s.doc().unwrap().layout("Layout3").is_none()); +} + +#[test] +fn rename_keeps_the_active_layout_whatever_its_case() { + let mut s = session_with_model(); + s.doc_mut().unwrap().active_layout = Some("layout1".into()); + s.execute("layout.rename", &json!({"from": "Layout1", "to": "Sheet A"})).unwrap(); + assert_eq!(s.doc().unwrap().active_layout.as_deref(), Some("Sheet A")); +} + +#[test] +fn rename_copy_delete() { + let mut s = session_with_model(); + s.execute("layout.set", &json!({"name": "Layout1"})).unwrap(); + s.execute("layout.rename", &json!({"to": "Sheet A"})).unwrap(); + assert_eq!(s.space(), Space::Paper("Sheet A".into())); + assert!(s.execute("layout.rename", &json!({"from": "Sheet A", "to": "Layout2"})).is_err(), "duplicate"); + assert!(s.execute("layout.rename", &json!({"from": "Sheet A", "to": "a/b"})).is_err(), "bad char"); + assert!(s.execute("layout.rename", &json!({"from": "Sheet A", "to": "model"})).is_err(), "reserved"); + s.execute("mview", &json!({"p1": [1, 1], "p2": [5, 4]})).unwrap(); + let r = s.execute("layout.copy", &json!({"from": "Sheet A"})).unwrap(); + assert_eq!(r["name"], "Sheet A (2)"); + let a = viewports(&s, "Sheet A"); + let b = viewports(&s, "Sheet A (2)"); + assert_eq!(a.len(), 1); + assert_eq!(b.len(), 1); + assert_ne!(a[0].0, b[0].0, "copied entities get new handles"); + s.execute("layout.delete", &json!({"name": "sheet a"})).unwrap(); + assert_eq!(s.space(), Space::Model, "deleting the current layout returns to Model"); + s.execute("layout", &json!({"option": "delete", "name": "Layout2"})).unwrap(); + assert!(s.execute("layout.delete", &json!({"name": "Sheet A (2)"})).is_err(), "last layout stays"); + let l = s.execute("layout", &json!({"option": "list"})).unwrap(); + assert_eq!(l.as_array().unwrap().len(), 1); + assert!(s.execute("layout.delete", &json!({"name": "Model"})).is_err()); +} + +#[test] +fn new_layout_from_the_menu_asks_for_a_name() { + let mut s = session_with_model(); + s.start("layout.new").unwrap(); + assert_eq!(s.current_prompt().map(|p| p.display()).as_deref(), Some("Enter name of new layout :")); + s.cmdline("Layout1").unwrap(); + assert!(s.running.is_some(), "a taken name asks again"); + s.cmdline("Sheet A").unwrap(); + assert!(s.running.is_none()); + s.start("layout.new").unwrap(); + s.cmdline("").unwrap(); + let d = s.doc().unwrap(); + assert!(d.layout("Sheet A").is_some() && d.layout("Layout3").is_some(), "Enter takes the default name"); +} + +#[test] +fn mview_arrangements_and_interactive() { + let mut s = session_with_model(); + assert!(s.execute("mview", &json!({"p1": [0, 0], "p2": [1, 1]})).is_err(), "not in model space"); + s.execute("mview", &json!({"layout": "Layout2", "count": 4})).unwrap(); + assert_eq!(viewports(&s, "Layout2").len(), 4); + s.execute("vports.3", &json!({"layout": "Layout2", "p1": [0, 0], "p2": [9, 6]})).unwrap(); + let v = viewports(&s, "Layout2"); + assert_eq!(v.len(), 7); + let total: f64 = v[4..].iter().map(|(_, v)| v.width * v.height).sum(); + assert!((total - 54.0).abs() < 1e-9, "three viewports tile the window"); + // Interactive MVIEW: two corners. + s.execute("layout.set", &json!({"name": "Layout1"})).unwrap(); + s.cmdline("mview").unwrap(); + s.cmdline("1,1").unwrap(); + s.cmdline("6,4").unwrap(); + assert!(s.running.is_none()); + let v = viewports(&s, "Layout1"); + assert_eq!(v.len(), 1); + assert!((v[0].1.width - 5.0).abs() < 1e-9 && (v[0].1.height - 3.0).abs() < 1e-9); + // MVIEW 2 → arrangement (Enter: Vertical) → Fit (Enter). + s.cmdline("mview").unwrap(); + s.cmdline("2").unwrap(); + s.cmdline("").unwrap(); + s.cmdline("").unwrap(); + assert!(s.running.is_none()); + assert_eq!(viewports(&s, "Layout1").len(), 3); + // One undo step per MVIEW. + s.undo().unwrap(); + assert_eq!(viewports(&s, "Layout1").len(), 1); + // In model space the interactive command ends at once. + s.execute("layout.set", &json!({"name": "Model"})).unwrap(); + s.cmdline("vports.4").unwrap(); + assert!(s.running.is_none()); +} + +#[test] +fn viewport_scale_lock_and_layers() { + let mut s = session_with_model(); + s.execute("layout.set", &json!({"name": "Layout1"})).unwrap(); + let r = s.execute("mview", &json!({"p1": [1, 1], "p2": [5, 4]})).unwrap(); + let h = r["viewports"][0].as_str().unwrap().to_string(); + let out = s.execute("viewport.set", &json!({"handle": h, "scale": "1:20", "center": [50, 20]})).unwrap(); + assert!((out[0]["viewHeight"].as_f64().unwrap() - 60.0).abs() < 1e-9); + assert!((out[0]["scale"].as_f64().unwrap() - 0.05).abs() < 1e-12); + s.execute("viewport.set", &json!({"handle": h, "locked": true})).unwrap(); + assert!(s.execute("viewport.set", &json!({"handle": h, "scale": 0.1})).is_err(), "locked"); + s.execute("viewport.set", &json!({"handle": h, "scale": 0.1, "locked": false})).unwrap(); + s.execute("vplayer", &json!({"handle": h, "freeze": ["0", "Walls"]})).unwrap(); + s.execute("vplayer", &json!({"handle": h, "thaw": "walls"})).unwrap(); + let v = viewports(&s, "Layout1"); + assert_eq!(v[0].1.frozen_layers, vec!["0".to_string()]); + s.execute("vplayer", &json!({"handle": h, "colors": {"Walls": "red"}})).unwrap(); + assert!(s.execute("vplayer", &json!({"handle": h, "colors": {"Walls": "bylayer"}})).is_err()); + assert!(s.execute("vplayer", &json!({"handle": h, "colors": {"Walls": [1]}})).is_err()); + let vv = viewports(&s, "Layout1"); + assert_eq!(vv[0].1.layer_colors, vec![("Walls".to_string(), cadcraft_color::Color::Index(1))]); + s.execute("vplayer", &json!({"handle": h, "colors": {"walls": null}})).unwrap(); + assert!(viewports(&s, "Layout1")[0].1.layer_colors.is_empty()); + assert!((v[0].1.view_height - 30.0).abs() < 1e-9); + // Layer 0 frozen in the viewport: nothing of the model shows. + let list = cadcraft_render::build(s.doc().unwrap(), &Space::Paper("Layout1".into()), &cadcraft_render::Options::default()); + assert!(list.prims.iter().all(|p| p.handle != cadcraft_render::VIEWPORT_CONTENT)); + for bad_p in + [json!({"handle": h, "scale": "0:0"}), json!({"handle": h, "scale": -1}), json!({"handle": h, "viewHeight": 0}), json!({"handle": "FFFFFF"})] + { + assert!(s.execute("viewport.set", &bad_p).is_err(), "{bad_p}"); + } +} + +#[test] +fn pagesetup_paper_table() { + let mut s = Session::new(); + let r = s.execute("pagesetup", &json!({"layout": "Layout1", "paper": "A3", "landscape": true, "margins": [5, 5, 5, 5]})).unwrap(); + assert_eq!(r["page"]["widthMm"], 297.0); + let sheet = cadcraft_render::sheet(s.doc().unwrap(), "Layout1").unwrap(); + // Imperial drawing: paper units are inches. + assert!((sheet.size.x - 420.0 / 25.4).abs() < 1e-9 && (sheet.size.y - 297.0 / 25.4).abs() < 1e-9); + assert!(s.execute("pagesetup", &json!({"layout": "Layout1", "paper": "Z12"})).is_err()); + assert!(s.execute("pagesetup", &json!({"layout": "Layout1", "margins": [1, 2]})).is_err()); + assert!(s.execute("pagesetup", &json!({"layout": "Layout1", "width": 1e308, "height": 5})).is_err()); + assert!(s.execute("pagesetup", &json!({"paper": "A4"})).is_err(), "model tab needs a layout"); + for area in ["extents", "display", "window", "limits"] { + let e = s.execute("pagesetup", &json!({"layout": "Layout1", "plotArea": area})).unwrap_err(); + assert!(e.to_string().contains("not available yet"), "{area}: {e}"); + } + let r = s.execute("pagesetup", &json!({"layout": "Layout1", "plotArea": "Layout"})).unwrap(); + assert_eq!(r["page"]["plotArea"], "layout"); + let report = s.execute("pagesetup", &Value::Null).unwrap(); + assert!(report["papers"].as_array().unwrap().len() >= 10); + s.execute("pagesetup", &json!({"layout": "Layout2", "width": 500, "height": 300})).unwrap(); + let p = &s.doc().unwrap().layout("Layout2").unwrap().page; + assert_eq!((p.width_mm, p.height_mm), (300.0, 500.0)); +} + +fn fake_plot(d: &Drawing, space: &Space, opts: &Value) -> std::result::Result, String> { + let _ = d; + Ok(format!("%PDF-1.4 fake {space:?} {opts}").into_bytes()) +} + +#[test] +fn plot_goes_through_the_hook() { + super::super::file::set_io(super::super::file::IoHooks { + read: |_, _| Err("no reader in tests".into()), + write: |_, _| Err("no writer in tests".into()), + plot: Some(fake_plot), + }); + let mut s = session_with_model(); + if io().and_then(|h| h.plot).is_none() { + return; // another test installed hooks without a plotter first + } + let r = s.execute("plot", &json!({"layout": "Layout1", "paper": "A4"})).unwrap(); + let data = super::super::file::base64_decode(r["data"].as_str().unwrap()).unwrap(); + let text = String::from_utf8(data).unwrap(); + assert!(text.starts_with("%PDF") && text.contains("Paper(\"Layout1\")") && text.contains("A4")); + let r = s.execute("exportpdf", &json!({})).unwrap(); + assert_eq!(r["layout"], "Model"); + let baseline = String::from_utf8(super::super::file::base64_decode(r["data"].as_str().unwrap()).unwrap()).unwrap(); + assert!(!baseline.contains("\"center\"") && !baseline.contains("\"offset\""), "omitted options remain omitted: {baseline}"); + assert!(s.execute("plot", &json!({"layout": "Nope"})).is_err()); + // Plot area and a ratio scale reach the plotter as a model-space window and a number. + let opts = |r: Value| String::from_utf8(super::super::file::base64_decode(r["data"].as_str().unwrap()).unwrap()).unwrap(); + let p = json!({"layout": "Model", "plotArea": "window", "window": [[10, 0], [0, 5]], "fit": false, "scale": "1:2"}); + let text = opts(s.execute("plot", &p).unwrap()); + assert!(text.contains(r#""window":[[0.0,0.0],[10.0,5.0]]"#) && text.contains(r#""scale":0.5"#), "{text}"); + let text = opts(s.execute("plot", &json!({"layout": "Model", "center": false, "offset": [12.5, -7.0]})).unwrap()); + assert!(text.contains(r#""center":false"#) && text.contains(r#""offset":[12.5,-7.0]"#), "{text}"); + for offset in [json!([1]), json!([1, 2, 3]), json!([f64::NAN, 0]), json!([100_001, 0])] { + assert!(s.execute("plot", &json!({"layout": "Model", "center": false, "offset": offset})).is_err()); + } + assert!(s.execute("plot", &json!({"layout": "Model", "output": "svg", "center": false, "offset": [0, 0]})).is_err()); + assert!(opts(s.execute("plot", &json!({"layout": "Model", "plotArea": "limits"})).unwrap()).contains(r#""window":[[0.0,0.0],"#)); + assert!(s.execute("plot", &json!({"layout": "Model", "plotArea": "window"})).is_err(), "window needs corners"); + assert!(s.execute("plot", &json!({"layout": "Layout1", "plotArea": "display"})).is_err(), "layouts plot their sheet"); + assert!(s.execute("plot", &json!({"layout": "Model", "scale": -1})).is_err()); + #[cfg(not(target_arch = "wasm32"))] + { + let path = std::env::temp_dir().join(format!("cadcraft-plot-test-{}.pdf", std::process::id())); + let ps = path.to_string_lossy().to_string(); + let r = s.execute("plot", &json!({"path": ps})).unwrap(); + assert!(r["bytes"].as_u64().unwrap() > 0); + assert!(std::fs::read(&path).unwrap().starts_with(b"%PDF")); + let _ = std::fs::remove_file(&path); + } +} + +#[test] +fn hostile_layout_params() { + let mut s = session_with_model(); + let long = "x".repeat(300); + for (c, p) in [ + ("layout.new", json!({"name": ""})), + ("layout.new", json!({"name": long})), + ("layout.new", json!({"name": "a\u{0}b"})), + ("layout.new", json!({"name": "Layout1"})), + ("mview", json!({"layout": "Layout1", "p1": [1e308, -1e308], "p2": [-1e308, 1e308]})), + ("mview", json!({"layout": "Layout1", "p1": [1, 1], "p2": [1, 1]})), + ("mview", json!({"layout": "Layout1", "count": 99})), + ("mview", json!({"layout": "Layout1", "count": -1})), + ("layout.rename", json!({"from": "Layout1"})), + ("layout.copy", json!({"from": "nope"})), + ("viewport.set", json!({"handles": ["zz"]})), + ] { + assert!(s.execute(c, &p).is_err(), "{c} {p}"); + } +} + +#[test] +fn mspace_edits_model_through_viewport() { + let mut s = session_with_model(); + let before = s.doc().unwrap().model.len(); + s.execute("layout.new", &json!({"name": "L"})).unwrap(); + s.execute("layout.set", &json!({"name": "L"})).unwrap(); + assert!(s.execute("mspace", &json!({"at": [-1000, -1000]})).is_err()); + let vps = viewports(&s, "L"); + let (h, vp) = vps.iter().find(|(_, v)| v.id != 1).cloned().unwrap(); + s.execute("mspace", &json!({"at": [vp.center.x, vp.center.y]})).unwrap(); + assert_eq!(s.space(), Space::Model); + assert_eq!(s.layout_space(), Space::Paper("L".into())); + // The screen view in MSPACE maps the viewport centre to its model view centre. + let st = s.state().unwrap(); + let (pv, v) = (st.paper_view(), st.view()); + let k = vp.view_height / vp.height; + assert!((v.height - pv.height * k).abs() < 1e-9); + // New geometry goes to model space. + s.cmdline("circle 10,10 3").unwrap(); + assert_eq!(s.doc().unwrap().model.len(), before + 1); + // Zooming changes the viewport's model view, not the sheet. + s.state_mut().unwrap().set_view(crate::View { center: v.center, height: v.height / 2.0 }); + let vp2 = viewports(&s, "L").into_iter().find(|(x, _)| *x == h).unwrap().1; + assert!((vp2.view_height - vp.view_height / 2.0).abs() < 1e-9); + assert_eq!(s.state().unwrap().paper_view(), pv); + s.execute("pspace", &json!({})).unwrap(); + assert_eq!(s.space(), Space::Paper("L".into())); + s.execute("mspace", &json!({})).unwrap(); + s.execute("layout.set", &json!({"name": "Model"})).unwrap(); + assert!(s.state().unwrap().mspace.is_none()); +} diff --git a/crates/engine/src/cmd/mleader_edit.rs b/crates/engine/src/cmd/mleader_edit.rs index e8e50bbf8..e9ebf2af2 100644 --- a/crates/engine/src/cmd/mleader_edit.rs +++ b/crates/engine/src/cmd/mleader_edit.rs @@ -793,5 +793,4 @@ impl Interactive for CollectM { } #[cfg(test)] -#[path = "mleader_edit_tests.rs"] mod tests; diff --git a/crates/engine/src/cmd/mleader_edit_tests.rs b/crates/engine/src/cmd/mleader_edit/tests.rs similarity index 100% rename from crates/engine/src/cmd/mleader_edit_tests.rs rename to crates/engine/src/cmd/mleader_edit/tests.rs diff --git a/crates/engine/src/cmd/modify.rs b/crates/engine/src/cmd/modify.rs index c25eb50c8..bbcd25267 100644 --- a/crates/engine/src/cmd/modify.rs +++ b/crates/engine/src/cmd/modify.rs @@ -16,6 +16,26 @@ use super::{CommandSpec, bad, bool_or, f64_or, f64_req, point_param, point_req, use crate::select::{SelectKind, SelectMode}; use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step}; +mod break_join; +mod explode; +mod fillet; +mod offset; +mod select_then; +mod stretch; +mod transform; +mod trim; + +use break_join::{BreakM, run_break, run_breakat, run_join}; +use explode::run_explode; +pub(crate) use explode::{explode_kind, explode_with}; +use fillet::{FilletM, run_chamfer, run_fillet}; +use offset::{OffsetM, run_offset}; +use select_then::{DisplaceM, Op, SelectThen}; +use stretch::{StretchM, run_stretch}; +pub(crate) use transform::transform_entities; +use transform::{run_copy, run_erase, run_mirror, run_move, run_rotate, run_scale}; +pub(crate) use trim::{Edges, extend, trim}; + pub fn specs() -> Vec { vec![ CommandSpec::new("erase", "Erase", run_erase) @@ -99,2979 +119,4 @@ pub fn specs() -> Vec { ] } -// ---------------- core operations ---------------- - -pub(crate) fn transform_entities(s: &mut Session, hs: &[Handle], m: &Mat3, copy: bool) -> Result> { - let d = s.doc_mut()?; - let mut out = Vec::new(); - for h in hs { - let Some(sp) = d.space_of(*h) else { continue }; - if copy { - let Some(e) = d.entity(*h).map(|e| (**e).clone()) else { continue }; - let nh = d.new_handle(); - let mut ne = Entity { handle: nh, ..e }; - ne.kind.transform(m); - if let Some(st) = d.space_mut(&sp) { - st.push(ne); - out.push(nh); - } - } else { - let locked = d.entity(*h).and_then(|e| d.layer(&e.common.layer)).is_some_and(|l| l.locked); - if locked { - continue; - } - d.modify_entity(*h, |e| e.kind.transform(m))?; - out.push(*h); - } - } - Ok(out) -} - -fn erase(s: &mut Session, hs: &[Handle]) -> Result { - let d = s.doc_mut()?; - let mut n = 0; - for h in hs { - let locked = d.entity(*h).and_then(|e| d.layer(&e.common.layer)).is_some_and(|l| l.locked); - if !locked && d.remove_entity(*h).is_some() { - n += 1; - } - } - s.set_selection(Vec::new()); - Ok(n) -} - -fn run_erase(s: &mut Session, p: &Value) -> Result { - let hs = targets(s, p)?; - let n = erase(s, &hs)?; - Ok(json!({ "erased": n })) -} - -fn delta_of(cmd: &str, p: &Value) -> Result { - if let Some(d) = point_param(p, "delta") { - return Ok(d); - } - let a = point_req(cmd, p, "from")?; - let b = point_req(cmd, p, "to")?; - Ok(b - a) -} - -fn run_move(s: &mut Session, p: &Value) -> Result { - let hs = targets(s, p)?; - let d = delta_of("move", p)?; - let moved = transform_entities(s, &hs, &Mat3::translate(d), false)?; - Ok(json!({ "moved": moved.len() })) -} - -fn run_copy(s: &mut Session, p: &Value) -> Result { - let hs = targets(s, p)?; - let d = delta_of("copy", p)?; - let n = p.get("count").and_then(Value::as_u64).unwrap_or(1).clamp(1, 10_000); - let mut out = Vec::new(); - for k in 1..=n { - out.extend(transform_entities(s, &hs, &Mat3::translate(d * k as f64), true)?.into_iter().map(|h| h.hex())); - } - Ok(json!({ "handles": out })) -} - -fn run_rotate(s: &mut Session, p: &Value) -> Result { - let hs = targets(s, p)?; - let base = point_req("rotate", p, "base")?; - let a = f64_req("rotate", p, "angle")?.to_radians(); - let r = transform_entities(s, &hs, &Mat3::rotate_about(base, a), bool_or(p, "copy", false))?; - Ok(json!({ "handles": r.iter().map(|h| h.hex()).collect::>() })) -} - -fn run_scale(s: &mut Session, p: &Value) -> Result { - let hs = targets(s, p)?; - let base = point_req("scale", p, "base")?; - let f = f64_req("scale", p, "factor")?; - if f <= 0.0 { - return Err(bad("scale", "factor must be positive")); - } - let r = transform_entities(s, &hs, &Mat3::scale_about(base, f), bool_or(p, "copy", false))?; - Ok(json!({ "handles": r.iter().map(|h| h.hex()).collect::>() })) -} - -fn run_mirror(s: &mut Session, p: &Value) -> Result { - let hs = targets(s, p)?; - let a = point_req("mirror", p, "p1")?; - let b = point_req("mirror", p, "p2")?; - if a.near(b, 1e-12) { - return Err(bad("mirror", "mirror line points coincide")); - } - let erase_src = bool_or(p, "erase", false); - let r = transform_entities(s, &hs, &Mat3::mirror(a, b), !erase_src)?; - Ok(json!({ "handles": r.iter().map(|h| h.hex()).collect::>() })) -} - -/// An area whose vertices STRETCH moves: a window's box, or a polygon closed back to its first -/// point. -#[derive(Clone)] -enum StretchArea { - Box(cadcraft_geom::Bounds2), - Polygon(Vec), -} - -impl StretchArea { - fn contains(&self, p: Vec2) -> bool { - match self { - StretchArea::Box(bx) => bx.contains(p), - StretchArea::Polygon(poly) => cadcraft_geom::point_in_polygon(poly, p), - } - } -} - -/// Add to `hs` the visible multilines in `space` with a control vertex inside `area`. A crossing -/// selection tests the drawn element lines, which sit away from the control vertices by the -/// multiline's scale, so a small area around a vertex alone would miss them. -fn add_mline_vertex_hits(d: &cadcraft_doc::Drawing, space: &cadcraft_doc::Space, area: &StretchArea, hs: &mut Vec) { - let Some(store) = d.space(space) else { return }; - for e in store.iter() { - if let EntityKind::MLine(m) = &e.kind - && d.is_visible(e) - && !hs.contains(&e.handle) - && m.vertices.iter().any(|v| area.contains(v.p.xy())) - { - hs.push(e.handle); - } - } -} - -/// Move the vertices of `e` that lie inside any of the areas `wins` by `d` (STRETCH). -fn stretch_entity(e: &mut Entity, wins: &[StretchArea], d: Vec2) { - let inside = |p: Vec2| wins.iter().any(|a| a.contains(p)); - let mv = |p: &mut cadcraft_geom::Vec3| { - if inside(p.xy()) { - p.x += d.x; - p.y += d.y; - } - }; - match &mut e.kind { - EntityKind::Line(l) => { - mv(&mut l.a); - mv(&mut l.b); - } - EntityKind::LwPolyline(pl) => { - for v in &mut pl.vertices { - if inside(v.p) { - v.p += d; - } - } - } - EntityKind::MLine(m) => m.vertices.iter_mut().for_each(|v| mv(&mut v.p)), - EntityKind::Arc(a) => { - // Arcs keep their shape: move when the centre is inside. - if inside(a.center.xy()) { - a.center.x += d.x; - a.center.y += d.y; - } - } - EntityKind::Solid(so) | EntityKind::Trace(so) => so.corners.iter_mut().for_each(mv), - EntityKind::Dimension(dm) => { - for p in [&mut dm.p13, &mut dm.p14, &mut dm.defpt, &mut dm.text_mid] { - mv(p); - } - dm.block = None; - } - k => { - let g = k.grips(); - if g.first().is_some_and(|p| inside(*p)) { - k.transform(&Mat3::translate(d)); - } - } - } -} - -fn run_stretch(s: &mut Session, p: &Value) -> Result { - let d = delta_of("stretch", p)?; - let space = s.space(); - let listed = |key: &str, least: usize| -> Result>> { - let Some(pts) = points_param(p, key) else { return Ok(None) }; - if pts.len() < least || pts.len() > crate::select::MAX_MODE_POINTS || pts.iter().any(|q| !q.is_finite()) { - return Err(bad("stretch", format!("`{key}` needs {least} to {} finite points", crate::select::MAX_MODE_POINTS))); - } - Ok(Some(pts)) - }; - // A fence marks no area: the objects it crosses move whole. - if let Some(fence) = listed("fence", 2)? { - let hs = crate::select::select_fence(s.doc()?, &space, &fence); - let moved = transform_entities(s, &hs, &Mat3::translate(d), false)?; - return Ok(json!({ "stretched": moved.len() })); - } - let (mut hs, area) = if let Some(poly) = listed("polygon", 3)? { - (crate::select::select_polygon(s.doc()?, &space, &poly, true), StretchArea::Polygon(poly)) - } else { - let w = points_param(p, "window").ok_or_else(|| bad("stretch", "`window` [[x,y],[x,y]], `polygon` or `fence` is required"))?; - let (Some(a), Some(b)) = (w.first(), w.get(1)) else { return Err(bad("stretch", "window needs 2 corners")) }; - let bx = cadcraft_geom::Bounds2::new(*a, *b); - (crate::select::select_window(s.doc()?, &space, bx, true), StretchArea::Box(bx)) - }; - add_mline_vertex_hits(s.doc()?, &space, &area, &mut hs); - hs.retain(|h| !super::curves::is_locked(s, *h)); - let doc = s.doc_mut()?; - let areas = [area]; - for h in &hs { - doc.modify_entity(*h, |e| stretch_entity(e, &areas, d))?; - } - Ok(json!({ "stretched": hs.len() })) -} - -#[derive(Clone, Copy, Default, PartialEq)] -enum StretchPhase { - #[default] - Select, - Base, - Displacement, - Second, -} - -/// STRETCH: "Select objects" repeats until Enter. Crossing windows (implied right to left, or after -/// `C`), windows (left to right, or after `W`) and polygons (`CP`, `WP`) select objects and mark -/// the area whose vertices move; objects picked one at a time (or by a fence, `F`, or -/// All/Last/Previous) move whole. Then the base point or Displacement, and the second point. -#[derive(Default)] -struct StretchM { - phase: StretchPhase, - objs: Vec, - /// Objects selected without a window: they move whole. - whole: Vec, - /// The selection windows and polygons: vertices inside any of them move. - windows: Vec, - removing: bool, - /// `C`, `W`, `BOX`, `CP`, `WP` or `F` was typed: the next points are that kind of selection. - mode: Option, - /// First corner of the window being drawn. - corner: Option, - base: Option, -} - -impl StretchM { - fn add(&mut self, s: &mut Session, hs: Vec, whole: bool) { - let n = hs.len(); - for h in hs { - if self.removing { - self.objs.retain(|o| *o != h); - self.whole.retain(|o| *o != h); - } else { - if !self.objs.contains(&h) { - self.objs.push(h); - } - if whole && !self.whole.contains(&h) { - self.whole.push(h); - } - } - } - if n > 0 { - s.echo(format!("{n} found, {} total", self.objs.len())); - } else { - s.echo("0 found"); - } - s.set_selection(self.objs.clone()); - } - - /// Select with a typed mode that has its points. With too few (a fence of one point, a polygon - /// of two) the mode is dropped. - fn finish_mode(&mut self, s: &mut Session, m: SelectMode) -> Result<()> { - let space = s.space(); - let Some(mut hs) = m.select(s.doc()?, &space) else { return Ok(()) }; - let crossing = match (m.kind, m.pts.first().zip(m.pts.get(1))) { - (SelectKind::Crossing | SelectKind::CPolygon, _) => true, - (SelectKind::Box, Some((a, b))) => b.x < a.x, - _ => false, - }; - let area = match (m.kind, m.pts.first().zip(m.pts.get(1))) { - (SelectKind::WPolygon | SelectKind::CPolygon, _) => Some(StretchArea::Polygon(m.pts)), - (SelectKind::Window | SelectKind::Crossing | SelectKind::Box, Some((a, b))) => { - Some(StretchArea::Box(cadcraft_geom::Bounds2::new(*a, *b))) - } - // A fence marks no area: what it crosses moves whole. - _ => None, - }; - let whole = area.is_none(); - if let (Some(a), true, false) = (&area, crossing, self.removing) { - add_mline_vertex_hits(s.doc()?, &space, a, &mut hs); - } - if let (Some(a), false) = (area, self.removing) { - self.windows.push(a); - } - self.add(s, hs, whole); - Ok(()) - } - - fn select(&mut self, s: &mut Session, i: Input) -> Result { - if let Some(mut m) = self.mode.take() { - match &i { - Input::Point(p) => { - if m.push(*p) { - self.finish_mode(s, m)?; - } else { - self.mode = Some(m); - } - return Ok(Step::Continue); - } - Input::Text(t) | Input::Keyword(t) if matches!(t.trim().to_ascii_lowercase().as_str(), "u" | "undo") => { - m.pts.pop(); - self.mode = Some(m); - return Ok(Step::Continue); - } - // Enter ends a fence or closes a polygon. - Input::Enter => { - self.finish_mode(s, m)?; - return Ok(Step::Continue); - } - // Anything else leaves the mode and is read as usual. - _ => {} - } - } - match i { - Input::Point(p) => match self.corner.take() { - Some(c) => { - let bx = cadcraft_geom::Bounds2::new(c, p); - let space = s.space(); - let mut hs = crate::select::select_window(s.doc()?, &space, bx, p.x < c.x); - if !self.removing { - if p.x < c.x { - add_mline_vertex_hits(s.doc()?, &space, &StretchArea::Box(bx), &mut hs); - } - self.windows.push(StretchArea::Box(bx)); - } - self.add(s, hs, false); - } - None => { - let aperture = s.pixel_size() * s.settings.pickbox.max(1.0) * 1.5; - let space = s.space(); - match crate::select::pick(s.doc()?, &space, p, aperture) { - Some(h) => self.add(s, vec![h], true), - None => self.corner = Some(p), - } - } - }, - Input::Pick(hs) => self.add(s, hs, true), - Input::Text(t) | Input::Keyword(t) => { - self.corner = None; - let d = s.doc()?; - let store = d.space(&s.space()); - let word = t.trim().to_ascii_lowercase(); - // `_C`, `_L`: the English name, as scripts write it. - let word = word.strip_prefix('_').unwrap_or(&word).to_string(); - if let Some(m) = SelectMode::from_keyword(&word).filter(|m| m.kind != SelectKind::Group) { - self.mode = Some(m); - return Ok(Step::Continue); - } - let picked = match word.as_str() { - "r" | "remove" => { - self.removing = true; - None - } - "a" | "add" => { - self.removing = false; - None - } - "all" => Some(store.map(|st| st.iter().filter(|e| d.is_visible(e)).map(|e| e.handle).collect()).unwrap_or_default()), - "l" | "last" => Some(store.and_then(|st| st.last()).map(|e| vec![e.handle]).unwrap_or_default()), - "p" | "previous" => Some(s.state()?.previous_selection.clone()), - _ => { - s.echo("*Invalid selection*"); - None - } - }; - if let Some(hs) = picked { - self.add(s, hs, true); - } - } - Input::Enter if self.corner.is_some() => self.corner = None, - Input::Enter if self.objs.is_empty() => return Ok(Step::Done), - Input::Enter => { - s.remember_selection(&self.objs); - self.phase = StretchPhase::Base; - } - _ => {} - } - Ok(Step::Continue) - } - - fn apply(&self, s: &mut Session, d: Vec2) -> Result { - let whole: Vec = self.objs.iter().filter(|h| self.whole.contains(h)).copied().collect(); - transform_entities(s, &whole, &Mat3::translate(d), false)?; - let doc = s.doc_mut()?; - for h in self.objs.iter().filter(|h| !self.whole.contains(h)) { - let locked = doc.entity(*h).and_then(|e| doc.layer(&e.common.layer)).is_some_and(|l| l.locked); - if !locked { - doc.modify_entity(*h, |e| stretch_entity(e, &self.windows, d))?; - } - } - s.set_selection(Vec::new()); - Ok(Step::Done) - } -} - -impl Interactive for StretchM { - fn name(&self) -> &'static str { - "STRETCH" - } - fn begin(&mut self, s: &mut Session) -> Result { - s.set_selection(Vec::new()); - s.echo("Select objects to stretch by crossing-window or crossing-polygon..."); - Ok(Step::Continue) - } - fn prompt(&self, _s: &Session) -> Prompt { - if let (StretchPhase::Select, Some(m)) = (self.phase, &self.mode) { - return m.prompt(); - } - match (self.phase, self.corner) { - (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"), - (StretchPhase::Second, _) => Prompt::new("Specify second point or ", Accept::POINT).base_opt(self.base), - } - } - fn input(&mut self, s: &mut Session, i: Input) -> Result { - match (self.phase, i) { - (StretchPhase::Select, i) => self.select(s, i), - (StretchPhase::Base, Input::Point(p)) => { - self.base = Some(p); - self.phase = StretchPhase::Second; - Ok(Step::Continue) - } - (StretchPhase::Base, Input::Enter) => { - self.phase = StretchPhase::Displacement; - Ok(Step::Continue) - } - (StretchPhase::Base, Input::Keyword(k)) if k == "Displacement" => { - self.phase = StretchPhase::Displacement; - Ok(Step::Continue) - } - (StretchPhase::Displacement, Input::Point(p)) => self.apply(s, p), - (StretchPhase::Displacement, Input::Enter) => Ok(Step::Done), - (StretchPhase::Second, Input::Point(p)) => self.apply(s, p - self.base.unwrap_or(p)), - (StretchPhase::Second, Input::Enter) => self.apply(s, self.base.unwrap_or(Vec2::ZERO)), - _ => Ok(Step::Continue), - } - } - fn preview(&self, s: &Session, c: Vec2) -> Vec { - let rect = |a: Vec2| { - let (b, d) = (Vec2::new(c.x, a.y), Vec2::new(a.x, c.y)); - vec![line(a, b), line(b, c), line(c, d), line(d, a)] - }; - if let (StretchPhase::Select, Some(m)) = (self.phase, &self.mode) { - let Some(first) = m.pts.first().copied() else { return Vec::new() }; - if !matches!(m.kind, SelectKind::Fence | SelectKind::WPolygon | SelectKind::CPolygon) { - return rect(first); - } - // The fence or polygon so far, on to the cursor; a polygon closes back to its start. - let mut out: Vec = m.pts.windows(2).filter_map(|w| Some(line(*w.first()?, *w.get(1)?))).collect(); - out.extend(m.pts.last().map(|l| line(*l, c))); - if m.kind != SelectKind::Fence && m.pts.len() >= 2 { - out.push(line(c, first)); - } - return out; - } - match (self.phase, self.corner, self.base) { - (StretchPhase::Select, Some(a), _) => rect(a), - (StretchPhase::Second, _, Some(base)) => { - let Ok(doc) = s.doc() else { return Vec::new() }; - let m = Mat3::translate(c - base); - let mut out: Vec = self - .objs - .iter() - .take(500) - .filter_map(|h| doc.entity(*h)) - .map(|e| { - let mut e = (**e).clone(); - if self.whole.contains(&e.handle) { - e.kind.transform(&m); - } else { - stretch_entity(&mut e, &self.windows, c - base); - } - e.kind - }) - .collect(); - out.push(line(base, c)); - out - } - _ => Vec::new(), - } - } -} - -/// Whether OFFSET can work on this kind of object at all. -fn offsettable(k: &EntityKind) -> bool { - matches!( - k, - EntityKind::Line(_) - | EntityKind::Circle(_) - | EntityKind::Arc(_) - | EntityKind::Ellipse(_) - | EntityKind::LwPolyline(_) - | EntityKind::Spline(_) - | EntityKind::XLine(_) - | EntityKind::Ray(_) - ) -} - -/// Offset an entity by `dist` towards `side`. Returns the new geometry. -pub(crate) fn offset_kind(k: &EntityKind, dist: f64, side: Vec2) -> Option { - match k { - EntityKind::Line(l) => { - let ln = Line::new(l.a.xy(), l.b.xy()); - let sgn = if ln.side(side) >= 0.0 { 1.0 } else { -1.0 }; - let o = ln.offset(dist * sgn); - Some(line(o.a, o.b)) - } - EntityKind::Circle(c) => { - let inside = side.dist(c.center.xy()) < c.radius; - let r = if inside { c.radius - dist } else { c.radius + dist }; - (r > 1e-12).then(|| circle(c.center.xy(), r)) - } - EntityKind::Arc(a) => { - let inside = side.dist(a.center.xy()) < a.radius; - let r = if inside { a.radius - dist } else { a.radius + dist }; - (r > 1e-12).then(|| arc(&Arc::new(a.center.xy(), r, a.start, a.end))) - } - EntityKind::Ellipse(e) => { - // Approximate with a polyline offset of the tessellation. - let ge = cadcraft_geom::Ellipse { center: e.center.xy(), major: e.major.xy(), ratio: e.ratio, start: e.start, end: e.end }; - // Inside = on the centre side of the full ellipse, even for an open arc. - let inside = { - let a = e.major.xy().len(); - let b = a * e.ratio.abs(); - let u = e.major.xy().normalized(); - let d = side - e.center.xy(); - let (x, y) = (d.dot(u) * b, d.dot(u.perp()) * a); - x * x + y * y < (a * b) * (a * b) - }; - // offset_points shifts along the left normal, which points into a counter-clockwise ellipse. - let mut pts = Vec::new(); - ge.tessellate(e.major.xy().len() * 1e-3, &mut pts); - let off = offset_points(&pts, if inside { dist } else { -dist }, ge.is_full()); - Some(lwpoly(off.into_iter().map(PolyVertex::new).collect(), ge.is_full())) - } - EntityKind::LwPolyline(p) => { - let pl = Polyline { vertices: p.vertices.clone(), closed: p.closed }; - // Side: compare with the nearest segment. - let segs = pl.segments(); - let nearest = segs.iter().min_by(|a, b| a.closest(side).dist(side).total_cmp(&b.closest(side).dist(side)))?; - let c = nearest.closest(side); - let t = nearest.tangent(0.5); - let left = t.cross(side - c) > 0.0; - let d = if left { dist } else { -dist }; - offset_polyline(&pl, d).map(|vs| { - let mut k = lwpoly(vs, p.closed); - if let EntityKind::LwPolyline(n) = &mut k { - n.const_width = p.const_width; - } - k - }) - } - EntityKind::Spline(sp) => { - let pts = sp.tessellate(1e-3); - let n = pts.len(); - let mid = pts.get(n / 2).copied()?; - let next = pts.get((n / 2 + 1).min(n.saturating_sub(1))).copied()?; - let left = (next - mid).cross(side - mid) > 0.0; - let off = offset_points(&pts, if left { dist } else { -dist }, sp.closed); - // A closed spline stays closed (periodic), smooth across its start point. - Some(EntityKind::Spline(cadcraft_geom::Spline::from_fit(&decimate(&off, 40), sp.closed))) - } - EntityKind::XLine(r) | EntityKind::Ray(r) => { - let ln = Line::new(r.base.xy(), r.base.xy() + r.dir.xy()); - let sgn = if ln.side(side) >= 0.0 { 1.0 } else { -1.0 }; - let o = ln.offset(dist * sgn); - let rl = cadcraft_doc::RayLine { base: v3(o.a), dir: r.dir }; - Some(if matches!(k, EntityKind::XLine(_)) { EntityKind::XLine(rl) } else { EntityKind::Ray(rl) }) - } - _ => None, - } -} - -fn decimate(pts: &[Vec2], max: usize) -> Vec { - if pts.len() <= max { - return pts.to_vec(); - } - let step = pts.len() as f64 / max as f64; - let mut v: Vec = (0..max).filter_map(|i| pts.get((i as f64 * step) as usize).copied()).collect(); - if let Some(l) = pts.last() { - v.push(*l); - } - v -} - -fn offset_points(pts: &[Vec2], d: f64, closed: bool) -> Vec { - let n = pts.len(); - let mut out = Vec::with_capacity(n); - for i in 0..n { - let p = pts.get(i).copied().unwrap_or_default(); - let prev = if i > 0 { - pts.get(i - 1) - } else if closed { - pts.get(n.saturating_sub(2)) - } else { - None - }; - let next = if i + 1 < n { - pts.get(i + 1) - } else if closed { - pts.get(1) - } else { - None - }; - let t = match (prev, next) { - (Some(a), Some(b)) => (*b - *a).normalized(), - (None, Some(b)) => (*b - p).normalized(), - (Some(a), None) => (p - *a).normalized(), - _ => Vec2::X, - }; - out.push(p + t.perp() * d); - } - out -} - -/// Offset a polyline: offset each segment, then join neighbours at their intersections. -pub(crate) fn offset_polyline(pl: &Polyline, d: f64) -> Option> { - let segs: Vec = pl.segments().iter().filter_map(|s| s.offset(d)).collect(); - if segs.is_empty() { - return None; - } - let n = segs.len(); - // Where each offset segment starts after joining it to the previous one. - let mut starts: Vec = Vec::with_capacity(n); - for i in 0..n { - let s = segs.get(i)?; - starts.push(if i == 0 && !pl.closed { - s.start() - } else { - let prev = segs.get((i + n - 1) % n)?; - join_point(prev, s).unwrap_or(s.start()) - }); - } - let mut verts: Vec = Vec::new(); - for i in 0..n { - let s = segs.get(i)?; - let start = *starts.get(i)?; - let end = if i + 1 < n { - *starts.get(i + 1)? - } else if pl.closed { - *starts.first()? - } else { - s.end() - }; - let bulge = match s { - Segment::Line(_) => 0.0, - Segment::Arc { arc, ccw } => { - // The joined endpoints may have moved along the circle: take the sweep between them. - let sweep = if start.near(end, 1e-9) { - arc.sweep() - } else if *ccw { - ccw_sweep(arc.center.angle_to(start), arc.center.angle_to(end)) - } else { - ccw_sweep(arc.center.angle_to(end), arc.center.angle_to(start)) - }; - cadcraft_geom::arc_to_bulge(if *ccw { sweep } else { -sweep }) - } - }; - verts.push(PolyVertex { p: start, bulge, start_width: 0.0, end_width: 0.0 }); - } - if !pl.closed { - verts.push(PolyVertex::new(segs.last()?.end())); - } - Some(verts) -} - -fn join_point(a: &Segment, b: &Segment) -> Option { - if a.end().near(b.start(), 1e-9) { - return Some(b.start()); - } - let hits = intersect_ext(a, b, true); - hits.into_iter().min_by(|x, y| x.dist(a.end()).total_cmp(&y.dist(a.end()))) -} - -fn run_offset(s: &mut Session, p: &Value) -> Result { - let h = targets(s, p)?.first().copied().ok_or_else(|| bad("offset", "`handle` is required"))?; - let dist = f64_req("offset", p, "distance")?; - let side = point_req("offset", p, "side")?; - let e = s.doc()?.entity(h).map(|e| (**e).clone()).ok_or_else(|| bad("offset", "no such object"))?; - let k = offset_kind(&e.kind, dist.abs(), side).ok_or_else(|| bad("offset", "cannot offset that object"))?; - let current_layer = match str_param(p, "layer").map(str::to_ascii_lowercase).as_deref() { - None | Some("source") => false, - Some("current") => true, - Some(_) => return Err(bad("offset", "`layer` is \"source\" or \"current\"")), - }; - let nh = add_offset(s, &e, k, current_layer, bool_or(p, "erase", false))?; - Ok(json!({ "handle": nh.hex() })) -} - -/// Add the offset copy `k` of `src` (on the current layer instead of the source's when -/// `current_layer`), then erase the source when `erase` and its layer is not locked. -fn add_offset(s: &mut Session, src: &Entity, k: EntityKind, current_layer: bool, erase: bool) -> Result { - let space = s.space(); - let d = s.doc_mut()?; - let mut common = src.common.clone(); - if current_layer { - common.layer = d.header.str("CLAYER", "0"); - } - let nh = d.new_handle(); - if let Some(st) = d.space_mut(&space) { - st.push(Entity { handle: nh, common, kind: k }); - } - let locked = d.layer(&src.common.layer).is_some_and(|l| l.locked); - if erase && !locked { - d.remove_entity(src.handle); - } - Ok(nh) -} - -// ---------------- trim / extend ---------------- - -/// The cutting (TRIM) or boundary (EXTEND) edges. -#[derive(Clone, Copy, Default)] -pub(crate) struct Edges<'a> { - /// Only these objects; `None`: every visible object. - pub only: Option<&'a [Handle]>, - /// Implied edges (EDGEMODE 1): lines and the end segments of open polylines count as running - /// on without end, arcs as whole circles. - pub implied: bool, -} - -/// All primitives of the cutting edges (excluding `exclude`). -fn edge_segments(s: &Session, edges: Edges, exclude: Handle) -> Result> { - let d = s.doc()?; - let space = s.space(); - let store = d.space(&space).ok_or(EngineError::NoDocument)?; - let mut out = Vec::new(); - for e in store.iter() { - if e.handle == exclude || !d.is_visible(e) { - continue; - } - if let Some(es) = edges.only - && !es.contains(&e.handle) - { - continue; - } - if edges.implied - && let Some(segs) = implied_edge(&e.kind) - { - out.extend(segs); - continue; - } - for p in e.kind.prims() { - out.extend(prim_to_segments(&p)); - } - } - Ok(out) -} - -/// `k` as an implied edge (EDGEMODE 1), for lines, arcs and open polylines. -fn implied_edge(k: &EntityKind) -> Option> { - const FAR: f64 = 1e8; - let whole = |a: &Arc| Segment::Arc { arc: Arc { start: 0.0, end: TAU - 1e-12, ..*a }, ccw: true }; - // Run a segment on past its start and/or its end. - let run_on = |sg: Segment, back: bool, fwd: bool| match sg { - Segment::Line(l) => { - let d = (l.b - l.a).normalized(); - Segment::Line(Line::new(if back { l.a - d * FAR } else { l.a }, if fwd { l.b + d * FAR } else { l.b })) - } - Segment::Arc { arc, .. } => whole(&arc), - }; - match k { - EntityKind::Line(l) => Some(vec![run_on(Segment::Line(Line::new(l.a.xy(), l.b.xy())), true, true)]), - EntityKind::Arc(a) => Some(vec![whole(&Arc::new(a.center.xy(), a.radius, a.start, a.end))]), - EntityKind::LwPolyline(p) if !p.closed => { - let mut segs = Polyline { vertices: p.vertices.clone(), closed: false }.segments(); - let last = segs.len().checked_sub(1)?; - for (i, sg) in segs.iter_mut().enumerate() { - if i == 0 || i == last { - *sg = run_on(*sg, i == 0, i == last); - } - } - Some(segs) - } - _ => None, - } -} - -fn prim_to_segments(p: &Prim) -> Vec { - match p { - Prim::Seg(s) => vec![*s], - Prim::Circle(c) => vec![Segment::Arc { arc: Arc { center: c.center, radius: c.radius, start: 0.0, end: TAU - 1e-12 }, ccw: true }], - Prim::Infinite { base, dir, .. } => vec![Segment::Line(Line::new(*base - *dir * 1e8, *base + *dir * 1e8))], - Prim::Ellipse(e) => { - let mut pts = Vec::new(); - e.tessellate(e.major.len() * 1e-4, &mut pts); - pts.windows(2).filter_map(|w| Some(Segment::Line(Line::new(*w.first()?, *w.get(1)?)))).collect() - } - Prim::Spline(sp) => sp.tessellate(1e-4).windows(2).filter_map(|w| Some(Segment::Line(Line::new(*w.first()?, *w.get(1)?)))).collect(), - Prim::Fill(f) => { - let n = f.len(); - (0..n).filter_map(|i| Some(Segment::Line(Line::new(*f.get(i)?, *f.get((i + 1) % n)?)))).collect() - } - Prim::Point(_) => Vec::new(), - } -} - -/// Trim `h` at the piece containing `pick`. Returns the handles that replace it. -pub(crate) fn trim(s: &mut Session, h: Handle, pick: Vec2, edges: Edges) -> Result> { - if super::curves::is_locked(s, h) { - return Err(EngineError::Other("The object is on a locked layer.".into())); - } - let e = s.doc()?.entity(h).map(|e| (**e).clone()).ok_or_else(|| EngineError::Other("no such object".into()))?; - let cut = edge_segments(s, edges, h)?; - let pieces: Vec = match &e.kind { - EntityKind::Line(l) => { - let ln = Line::new(l.a.xy(), l.b.xy()); - let mut ts: Vec = cut - .iter() - .flat_map(|c| intersect_ext(&Segment::Line(ln), c, false)) - .map(|x| ln.param_of(x)) - .filter(|t| *t > 1e-9 && *t < 1.0 - 1e-9) - .collect(); - ts.sort_by(f64::total_cmp); - let tp = ln.param_of(pick).clamp(0.0, 1.0); - let lo = ts.iter().copied().filter(|t| *t < tp).fold(0.0, f64::max); - let hi = ts.iter().copied().filter(|t| *t > tp).fold(1.0, f64::min); - if lo == 0.0 && hi == 1.0 { - return Err(EngineError::Other("Object does not intersect a cutting edge.".into())); - } - let mut v = Vec::new(); - if lo > 0.0 { - v.push(line(ln.a, ln.at(lo))); - } - if hi < 1.0 { - v.push(line(ln.at(hi), ln.b)); - } - v - } - EntityKind::Arc(a) => { - let ga = Arc::new(a.center.xy(), a.radius, a.start, a.end); - let seg = Segment::Arc { arc: ga, ccw: true }; - let sweep = ga.sweep(); - let param = |p: Vec2| norm_angle(ga.center.angle_to(p) - ga.start); - let mut ts: Vec = - cut.iter().flat_map(|c| intersect_ext(&seg, c, false)).map(param).filter(|t| *t > 1e-9 && *t < sweep - 1e-9).collect(); - ts.sort_by(f64::total_cmp); - let tp = param(pick).min(sweep); - let lo = ts.iter().copied().filter(|t| *t < tp).fold(0.0, f64::max); - let hi = ts.iter().copied().filter(|t| *t > tp).fold(sweep, f64::min); - if lo == 0.0 && hi == sweep { - return Err(EngineError::Other("Object does not intersect a cutting edge.".into())); - } - let mut v = Vec::new(); - if lo > 0.0 { - v.push(arc(&Arc::new(ga.center, ga.radius, ga.start, ga.start + lo))); - } - if hi < sweep { - v.push(arc(&Arc::new(ga.center, ga.radius, ga.start + hi, ga.end))); - } - v - } - EntityKind::Circle(c) => { - let full = Segment::Arc { arc: Arc { center: c.center.xy(), radius: c.radius, start: 0.0, end: TAU - 1e-12 }, ccw: true }; - let mut angs: Vec = cut.iter().flat_map(|x| intersect_ext(&full, x, false)).map(|p| c.center.xy().angle_to(p)).collect(); - angs.sort_by(f64::total_cmp); - angs.dedup_by(|a, b| (*a - *b).abs() < 1e-9); - if angs.len() < 2 { - return Err(EngineError::Other("Circle must intersect cutting edges at two or more points.".into())); - } - let ap = c.center.xy().angle_to(pick); - // The removed arc runs between the two intersections around the pick. - let n = angs.len(); - let mut keep = None; - for i in 0..n { - let a0 = angs[i]; - let a1 = angs[(i + 1) % n]; - if angle_in_sweep(ap, a0, a1) { - keep = Some(arc(&Arc::new(c.center.xy(), c.radius, a1, a0))); - } - } - keep.into_iter().collect() - } - EntityKind::LwPolyline(p) => trim_polyline(p, &cut, pick)?, - k => super::modify2::trim_other(k, &cut, pick)?, - }; - let common = e.common.clone(); - let space = s.space(); - let d = s.doc_mut()?; - d.remove_entity(h); - let mut out = Vec::new(); - for k in pieces { - let nh = d.new_handle(); - if let Some(st) = d.space_mut(&space) { - st.push(Entity { handle: nh, common: common.clone(), kind: k }); - out.push(nh); - } - } - Ok(out) -} - -/// Sub-polyline between length parameters `a` and `b` (open polylines). -fn sub_polyline(segs: &[Segment], a: f64, b: f64) -> Vec { - let mut out: Vec = Vec::new(); - let mut acc = 0.0; - for s in segs { - let len = s.len(); - let s0 = acc; - let s1 = acc + len; - acc = s1; - if s1 <= a + 1e-12 || s0 >= b - 1e-12 || len <= 1e-15 { - continue; - } - let t0 = ((a - s0) / len).clamp(0.0, 1.0); - let t1 = ((b - s0) / len).clamp(0.0, 1.0); - let p0 = s.at(t0); - let bulge = match s { - Segment::Line(_) => 0.0, - Segment::Arc { arc, ccw } => { - let sw = arc.sweep() * (t1 - t0); - cadcraft_geom::arc_to_bulge(if *ccw { sw } else { -sw }) - } - }; - if out.last().is_none_or(|l| !l.p.near(p0, 1e-9)) { - out.push(PolyVertex { p: p0, bulge, ..Default::default() }); - } else if let Some(l) = out.last_mut() { - l.bulge = bulge; - } - out.push(PolyVertex::new(s.at(t1))); - } - out -} - -fn trim_polyline(p: &cadcraft_doc::LwPolyline, cut: &[Segment], pick: Vec2) -> Result> { - let pl = Polyline { vertices: p.vertices.clone(), closed: p.closed }; - let segs = pl.segments(); - let total: f64 = segs.iter().map(Segment::len).sum(); - let mut ts: Vec = Vec::new(); - let mut acc = 0.0; - let mut tp = 0.0; - let mut best = f64::INFINITY; - for s in &segs { - let len = s.len(); - for x in cut.iter().flat_map(|c| intersect_ext(s, c, false)) { - ts.push(acc + param_on(s, x) * len); - } - let c = s.closest(pick); - if c.dist(pick) < best { - best = c.dist(pick); - tp = acc + param_on(s, c) * len; - } - acc += len; - } - ts.retain(|t| *t > 1e-9 && *t < total - 1e-9); - ts.sort_by(f64::total_cmp); - if ts.is_empty() { - return Err(EngineError::Other("Object does not intersect a cutting edge.".into())); - } - let lo = ts.iter().copied().filter(|t| *t < tp).fold(f64::NEG_INFINITY, f64::max); - let hi = ts.iter().copied().filter(|t| *t > tp).fold(f64::INFINITY, f64::min); - let mk = |vs: Vec| lwpoly(vs, false); - let mut out = Vec::new(); - if p.closed { - // Keep the complementary run from hi around to lo. - // The pick lies before the first cut or after the last: the piece wraps past the start - // vertex, so the run to keep is the stretch between the first and the last cut. - let (lo, hi) = ( - if lo.is_finite() { lo } else { ts.last().copied().unwrap_or(0.0) - total }, - if hi.is_finite() { hi } else { ts.first().copied().unwrap_or(total) + total }, - ); - let doubled: Vec = segs.iter().chain(segs.iter()).copied().collect(); - let (a, mut b) = if hi > total { (hi - total, lo) } else { (hi, lo + total) }; - // A single cut leaves the whole outline, opened there. - if b <= a + 1e-9 { - b += total; - } - out.push(mk(sub_polyline(&doubled, a, b))); - } else { - if lo.is_finite() { - out.push(mk(sub_polyline(&segs, 0.0, lo))); - } - if hi.is_finite() { - out.push(mk(sub_polyline(&segs, hi, total))); - } - } - Ok(out.into_iter().filter(|k| matches!(k, EntityKind::LwPolyline(l) if l.vertices.len() >= 2)).collect()) -} - -fn param_on(s: &Segment, p: Vec2) -> f64 { - match s { - Segment::Line(l) => l.param_of(p).clamp(0.0, 1.0), - Segment::Arc { arc, ccw } => { - let t = norm_angle(arc.center.angle_to(p) - arc.start) / arc.sweep().max(1e-15); - let t = t.clamp(0.0, 1.0); - if *ccw { t } else { 1.0 - t } - } - } -} - -/// Extend the end of `h` nearest `pick` to the nearest boundary edge. -pub(crate) fn extend(s: &mut Session, h: Handle, pick: Vec2, edges: Edges) -> Result<()> { - if super::curves::is_locked(s, h) { - return Err(EngineError::Other("The object is on a locked layer.".into())); - } - let e = s.doc()?.entity(h).map(|e| (**e).clone()).ok_or_else(|| EngineError::Other("no such object".into()))?; - let cut = edge_segments(s, edges, h)?; - let new_kind = match &e.kind { - EntityKind::Line(l) => { - let (a, b) = (l.a.xy(), l.b.xy()); - let from_b = pick.dist(b) < pick.dist(a); - let (fixed, end) = if from_b { (a, b) } else { (b, a) }; - let dir = (end - fixed).normalized(); - let ray = Segment::Line(Line::new(end, end + dir * 1e8)); - let best = cut - .iter() - .flat_map(|c| intersect_ext(&ray, c, false)) - .filter(|x| (*x - end).dot(dir) > 1e-9) - .min_by(|x, y| x.dist(end).total_cmp(&y.dist(end))); - let np = best.ok_or_else(|| EngineError::Other("Object does not intersect an edge.".into()))?; - if from_b { line(a, np) } else { line(np, b) } - } - EntityKind::Arc(a) => { - let ga = Arc::new(a.center.xy(), a.radius, a.start, a.end); - let from_end = pick.dist(ga.end_point()) < pick.dist(ga.start_point()); - let full = Segment::Arc { arc: Arc { start: 0.0, end: TAU - 1e-12, ..ga }, ccw: true }; - let hits: Vec = - cut.iter().flat_map(|c| intersect_ext(&full, c, false)).map(|x| ga.center.angle_to(x)).filter(|t| !ga.contains_angle(*t)).collect(); - let pick_ang = if from_end { - hits.iter().copied().min_by(|x, y| ccw_sweep(ga.end, *x).total_cmp(&ccw_sweep(ga.end, *y))) - } else { - hits.iter().copied().min_by(|x, y| ccw_sweep(*x, ga.start).total_cmp(&ccw_sweep(*y, ga.start))) - }; - let t = pick_ang.ok_or_else(|| EngineError::Other("Object does not intersect an edge.".into()))?; - if from_end { arc(&Arc::new(ga.center, ga.radius, ga.start, t)) } else { arc(&Arc::new(ga.center, ga.radius, t, ga.end)) } - } - EntityKind::LwPolyline(p) if !p.closed && p.vertices.len() >= 2 => { - let mut vs = p.vertices.clone(); - let n = vs.len(); - let from_end = vs.last().is_some_and(|l| pick.dist(l.p) < vs.first().map(|f| pick.dist(f.p)).unwrap_or(f64::INFINITY)); - let (i_end, i_prev) = if from_end { (n - 1, n - 2) } else { (0, 1) }; - let end = vs.get(i_end).map(|v| v.p).unwrap_or_default(); - let prev = vs.get(i_prev).map(|v| v.p).unwrap_or_default(); - let dir = (end - prev).normalized(); - let ray = Segment::Line(Line::new(end, end + dir * 1e8)); - let np = cut - .iter() - .flat_map(|c| intersect_ext(&ray, c, false)) - .filter(|x| (*x - end).dot(dir) > 1e-9) - .min_by(|x, y| x.dist(end).total_cmp(&y.dist(end))) - .ok_or_else(|| EngineError::Other("Object does not intersect an edge.".into()))?; - if let Some(v) = vs.get_mut(i_end) { - v.p = np; - } - lwpoly(vs, false) - } - k => super::modify2::extend_other(k, &cut, pick)?, - }; - s.doc_mut()?.modify_entity(h, |e| e.kind = new_kind)?; - Ok(()) -} - -// ---------------- fillet / chamfer ---------------- - -/// What CHAMFER takes (as in AutoCAD, not arcs, circles, ellipses or splines). -const CHAMFER_LINES_ONLY: &str = - "CHAMFER works on lines (and polylines with the Polyline option); it cannot chamfer arcs, circles, ellipses or splines."; - -fn as_line(e: &Entity) -> Option { - match &e.kind { - EntityKind::Line(l) => Some(Line::new(l.a.xy(), l.b.xy())), - _ => None, - } -} - -/// Fillet (or chamfer) two lines. `p1`/`p2` mark the kept side of each line. -pub(crate) fn fillet_lines( - s: &mut Session, - h1: Handle, - p1: Vec2, - h2: Handle, - p2: Vec2, - radius: f64, - chamfer: Option<(f64, f64)>, -) -> Result> { - if super::curves::is_locked(s, h1) || super::curves::is_locked(s, h2) { - return Err(EngineError::Other("The object is on a locked layer.".into())); - } - let d = s.doc()?; - let e1 = d.entity(h1).map(|e| (**e).clone()).ok_or_else(|| EngineError::Other("no such object".into()))?; - let e2 = d.entity(h2).map(|e| (**e).clone()).ok_or_else(|| EngineError::Other("no such object".into()))?; - let (Some(l1), Some(l2)) = (as_line(&e1), as_line(&e2)) else { - if chamfer.is_none() { - return super::modify2::fillet_curves(s, h1, p1, h2, p2, radius); - } - return Err(EngineError::Other(CHAMFER_LINES_ONLY.into())); - }; - let (x, _, _) = line_line_infinite(l1.a, l1.b, l2.a, l2.b).ok_or_else(|| EngineError::Other("Lines are parallel.".into()))?; - // Keep the far end on the picked side of the corner. - let keep = |l: &Line, p: Vec2| -> Vec2 { - let ta = (l.a - x).dot(p - x); - let tb = (l.b - x).dot(p - x); - if ta >= tb { l.a } else { l.b } - }; - let k1 = keep(&l1, p1); - let k2 = keep(&l2, p2); - let u1 = (k1 - x).normalized(); - let u2 = (k2 - x).normalized(); - let mut new_arc = None; - let (t1, t2) = if let Some((d1, d2)) = chamfer { - (x + u1 * d1, x + u2 * d2) - } else if radius <= 1e-12 { - (x, x) - } else { - let half = u1.dot(u2).clamp(-1.0, 1.0).acos() / 2.0; - if half.sin().abs() < 1e-12 { - return Err(EngineError::Other("Lines are parallel.".into())); - } - let dist = radius / half.tan(); - let t1 = x + u1 * dist; - let t2 = x + u2 * dist; - let bis = (u1 + u2).normalized(); - let c = x + bis * (radius / half.sin()); - let a1 = c.angle_to(t1); - let a2 = c.angle_to(t2); - let ar = if ccw_sweep(a1, a2) <= std::f64::consts::PI { Arc::new(c, radius, a1, a2) } else { Arc::new(c, radius, a2, a1) }; - new_arc = Some(ar); - (t1, t2) - }; - let common = e1.common.clone(); - let doc = s.doc_mut()?; - doc.modify_entity(h1, |e| e.kind = line(k1, t1))?; - doc.modify_entity(h2, |e| e.kind = line(k2, t2))?; - let space = s.space(); - let doc = s.doc_mut()?; - let extra = match (chamfer, new_arc) { - (Some(_), _) if !t1.near(t2, 1e-12) => Some(line(t1, t2)), - (None, Some(a)) => Some(arc(&a)), - _ => None, - }; - if let Some(k) = extra { - let nh = doc.new_handle(); - if let Some(st) = doc.space_mut(&space) { - st.push(Entity { handle: nh, common, kind: k }); - } - return Ok(Some(nh)); - } - Ok(None) -} - -fn h_param(p: &Value, k: &str) -> Option { - p.get(k).and_then(|v| v.as_str().and_then(Handle::parse_hex).or_else(|| v.as_u64().map(Handle))) -} - -fn run_fillet(s: &mut Session, p: &Value) -> Result { - if bool_or(p, "polyline", false) { - let h = h_param(p, "handle").or_else(|| h_param(p, "h1")).ok_or_else(|| bad("fillet", "`handle` (polyline) is required"))?; - let r = f64_or(p, "radius", s.doc()?.header.f64("FILLETRAD", 0.0)); - let n = super::modify2::fillet_polyline(s, h, r)?; - return Ok(json!({ "filleted": n })); - } - let h1 = h_param(p, "h1").ok_or_else(|| bad("fillet", "`h1` is required"))?; - let h2 = h_param(p, "h2").ok_or_else(|| bad("fillet", "`h2` is required"))?; - let r = f64_or(p, "radius", s.doc()?.header.f64("FILLETRAD", 0.0)); - let p1 = point_param(p, "p1").unwrap_or_default(); - let p2 = point_param(p, "p2").unwrap_or_default(); - let a = fillet_corner(s, h1, p1, h2, p2, r, None, bool_or(p, "trim", true))?; - Ok(json!({ "arc": a.map(|h| h.hex()) })) -} - -fn run_chamfer(s: &mut Session, p: &Value) -> Result { - let cut = match (p.get("length").and_then(Value::as_f64), p.get("angle").and_then(Value::as_f64)) { - (Some(len), Some(deg)) if len.is_finite() && len >= 0.0 && deg.is_finite() && (0.0..180.0).contains(°) => { - ChamferCut::Angle(len, deg.to_radians()) - } - (None, None) => { - let d1 = f64_or(p, "d1", s.doc()?.header.f64("CHAMFERA", 0.0)); - ChamferCut::Dist(d1, f64_or(p, "d2", d1)) - } - _ => return Err(bad("chamfer", "`length` (>= 0) and `angle` (degrees, 0 to 180) go together")), - }; - if bool_or(p, "polyline", false) { - let h = h_param(p, "handle").or_else(|| h_param(p, "h1")).ok_or_else(|| bad("chamfer", "`handle` (polyline) is required"))?; - return Ok(json!({ "chamfered": chamfer_polyline(s, h, cut)? })); - } - let h1 = h_param(p, "h1").ok_or_else(|| bad("chamfer", "`h1` is required"))?; - let h2 = h_param(p, "h2").ok_or_else(|| bad("chamfer", "`h2` is required"))?; - let p1 = point_param(p, "p1").unwrap_or_default(); - let p2 = point_param(p, "p2").unwrap_or_default(); - let a = fillet_corner(s, h1, p1, h2, p2, 0.0, Some(cut), bool_or(p, "trim", true))?; - Ok(json!({ "line": a.map(|h| h.hex()) })) -} - -// ---------------- explode / array / order / break / join ---------------- - -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) { - let mut se = match d.anno_draw(e) { - cadcraft_doc::AnnoDraw::Scaled(f) => cadcraft_doc::anno_scaled(e, f), - _ => e.clone(), - }; - se.common.anno_scales.clear(); - return explode_kind(d, &se); - } - match &e.kind { - EntityKind::LwPolyline(p) => { - 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(), - ) - } - // 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( - p.points - .windows(2) - .filter_map(|w| { - let kind = EntityKind::Line(cadcraft_doc::Line { a: *w.first()?, b: *w.get(1)? }); - Some(Entity { handle: Handle(0), common: e.common.clone(), kind }) - }) - .collect(), - ), - EntityKind::MLine(m) => 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(), - ), - // 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 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, - }), - }); - } - Some(v) - } - EntityKind::Tolerance(t) => Some(super::tolerance::explode(d, e, t)), - _ => None, - } -} - -fn explode(s: &mut Session, hs: &[Handle]) -> Result> { - explode_with(s, hs, explode_kind) -} - -/// Replace each of `hs` (off locked layers) by the pieces `pieces` gives for it; objects it gives -/// none for are left alone. EXPLODE, and BURST with its own pieces for block references. -pub(crate) fn explode_with( - s: &mut Session, - hs: &[Handle], - pieces: impl Fn(&cadcraft_doc::Drawing, &Entity) -> Option>, -) -> Result> { - let space = s.space(); - let mut out = Vec::new(); - for h in hs { - if super::curves::is_locked(s, *h) { - continue; - } - let Some(e) = s.doc()?.entity(*h).map(|e| (**e).clone()) else { continue }; - let Some(parts) = pieces(s.doc()?, &e) else { continue }; - // The pieces of an object are new objects: its xdata goes with it. A block reference's - // pieces are copies of the block's objects and keep theirs. - let own_pieces = !matches!(e.kind, EntityKind::Insert(_)); - let d = s.doc_mut()?; - d.remove_entity(*h); - for mut p in parts { - if own_pieces { - p.common.xdata.clear(); - } - p.handle = d.new_handle(); - out.push(p.handle); - if let Some(st) = d.space_mut(&space) { - st.push(p); - } - } - } - s.set_selection(Vec::new()); - Ok(out) -} - -fn run_explode(s: &mut Session, p: &Value) -> Result { - let hs = targets(s, p)?; - let r = explode(s, &hs)?; - Ok(json!({ "handles": r.iter().map(|h| h.hex()).collect::>() })) -} - -/// Gives a rebuilt arc piece its source centre Z, or a rebuilt polyline piece its source elevation. -fn with_z(mut k: EntityKind, z: f64) -> EntityKind { - match &mut k { - EntityKind::Arc(a) => a.center.z = z, - EntityKind::LwPolyline(p) => p.elevation = z, - _ => {} - } - k -} - -/// Break `h` between two points (or at one point when p1 == p2). -pub(crate) fn break_entity(s: &mut Session, h: Handle, p1: Vec2, p2: Vec2) -> Result> { - if super::curves::is_locked(s, h) { - return Err(EngineError::Other("The object is on a locked layer.".into())); - } - let e = s.doc()?.entity(h).map(|e| (**e).clone()).ok_or_else(|| EngineError::Other("no such object".into()))?; - let pieces: Vec = match &e.kind { - EntityKind::Line(l) => { - let ln = Line::new(l.a.xy(), l.b.xy()); - let (mut t1, mut t2) = (ln.param_of(p1).clamp(0.0, 1.0), ln.param_of(p2).clamp(0.0, 1.0)); - if t1 > t2 { - std::mem::swap(&mut t1, &mut t2); - } - let at3 = |t: f64| ln.at(t).to3(l.a.z + (l.b.z - l.a.z) * t); - let mut v = Vec::new(); - if t1 > 1e-12 { - v.push(EntityKind::Line(cadcraft_doc::Line { a: l.a, b: at3(t1) })); - } - if t2 < 1.0 - 1e-12 { - v.push(EntityKind::Line(cadcraft_doc::Line { a: at3(t2), b: l.b })); - } - v - } - EntityKind::Circle(c) => { - if p1.near(p2, 1e-12) { - return Err(EngineError::Other("Arc cannot be full 360 degrees.".into())); - } - let a1 = c.center.xy().angle_to(p1); - let a2 = c.center.xy().angle_to(p2); - // AutoCAD removes the CCW portion from the first to the second point. - vec![with_z(arc(&Arc::new(c.center.xy(), c.radius, a2, a1)), c.center.z)] - } - EntityKind::Arc(a) => { - let ga = Arc::new(a.center.xy(), a.radius, a.start, a.end); - let par = |p: Vec2| norm_angle(ga.center.angle_to(p) - ga.start).min(ga.sweep()); - let (mut t1, mut t2) = (par(p1), par(p2)); - if t1 > t2 { - std::mem::swap(&mut t1, &mut t2); - } - let mut v = Vec::new(); - if t1 > 1e-12 { - v.push(with_z(arc(&Arc::new(ga.center, ga.radius, ga.start, ga.start + t1)), a.center.z)); - } - if t2 < ga.sweep() - 1e-12 { - v.push(with_z(arc(&Arc::new(ga.center, ga.radius, ga.start + t2, ga.end)), a.center.z)); - } - v - } - EntityKind::LwPolyline(p) => { - let pl = Polyline { vertices: p.vertices.clone(), closed: p.closed }; - let segs = pl.segments(); - let total: f64 = segs.iter().map(Segment::len).sum(); - let lp = |q: Vec2| { - let mut acc = 0.0; - let mut best = (f64::INFINITY, 0.0); - for s in &segs { - let c = s.closest(q); - let dd = c.dist(q); - if dd < best.0 { - best = (dd, acc + param_on(s, c) * s.len()); - } - acc += s.len(); - } - best.1 - }; - let (mut t1, mut t2) = (lp(p1), lp(p2)); - if p.closed { - if (t1 - t2).abs() < 1e-9 { - return Err(EngineError::Other("Cannot break a closed polyline at a single point.".into())); - } - // Like a circle: the stretch from the first point to the second, along the - // vertices, goes; what is left runs from the second point round to the first. - let doubled: Vec = segs.iter().chain(segs.iter()).copied().collect(); - let end = if t1 > t2 { t1 } else { t1 + total }; - vec![with_z(lwpoly(sub_polyline(&doubled, t2, end), false), p.elevation)] - } else { - if t1 > t2 { - std::mem::swap(&mut t1, &mut t2); - } - let mut v = Vec::new(); - if t1 > 1e-12 { - v.push(with_z(lwpoly(sub_polyline(&segs, 0.0, t1), false), p.elevation)); - } - if t2 < total - 1e-12 { - v.push(with_z(lwpoly(sub_polyline(&segs, t2, total), false), p.elevation)); - } - v - } - } - _ => return Err(EngineError::Other("Cannot break that object.".into())), - }; - let common = e.common.clone(); - let space = s.space(); - let d = s.doc_mut()?; - d.remove_entity(h); - let mut out = Vec::new(); - for k in pieces { - let nh = d.new_handle(); - if let Some(st) = d.space_mut(&space) { - st.push(Entity { handle: nh, common: common.clone(), kind: k }); - out.push(nh); - } - } - Ok(out) -} - -fn run_break(s: &mut Session, p: &Value) -> Result { - let h = targets(s, p)?.first().copied().ok_or_else(|| bad("break", "`handle` is required"))?; - let p1 = point_req("break", p, "p1")?; - let p2 = point_param(p, "p2").unwrap_or(p1); - let r = break_entity(s, h, p1, p2)?; - Ok(json!({ "handles": r.iter().map(|h| h.hex()).collect::>() })) -} - -fn run_breakat(s: &mut Session, p: &Value) -> Result { - let h = targets(s, p)?.first().copied().ok_or_else(|| bad("breakatpoint", "`handle` is required"))?; - let at = point_req("breakatpoint", p, "at")?; - let r = break_entity(s, h, at, at)?; - Ok(json!({ "handles": r.iter().map(|h| h.hex()).collect::>() })) -} - -/// Join lines/arcs/polylines that share endpoints into one polyline (or merge collinear lines). -pub(crate) fn join(s: &mut Session, hs: &[Handle]) -> Result> { - if let Some(h) = super::modify2::join_curves(s, hs)? { - return Ok(Some(h)); - } - if hs.iter().any(|h| super::curves::is_locked(s, *h)) { - return Err(EngineError::Other("An object is on a locked layer.".into())); - } - let d = s.doc()?; - let mut segs: Vec = Vec::new(); - // Source entity of each segment in `segs`, so only entities that end up in the chain are removed. - let mut owners: Vec = Vec::new(); - let mut common = None; - for h in hs { - let Some(e) = d.entity(*h) else { continue }; - common.get_or_insert_with(|| e.common.clone()); - match &e.kind { - EntityKind::Line(l) => segs.push(Segment::Line(Line::new(l.a.xy(), l.b.xy()))), - EntityKind::Arc(a) => segs.push(Segment::Arc { arc: Arc::new(a.center.xy(), a.radius, a.start, a.end), ccw: true }), - EntityKind::LwPolyline(p) if !p.closed => segs.extend(Polyline { vertices: p.vertices.clone(), closed: false }.segments()), - _ => {} - } - owners.resize(segs.len(), *h); - } - if segs.len() < 2 { - return Err(EngineError::Other("Select at least two objects to join.".into())); - } - // Chain greedily from the first segment. - let mut chain = vec![segs.remove(0)]; - let mut used = vec![owners.remove(0)]; - let tol = 1e-6; - loop { - let start = chain.first().map(Segment::start).unwrap_or_default(); - let end = chain.last().map(Segment::end).unwrap_or_default(); - let Some(i) = - segs.iter().position(|s| s.start().near(end, tol) || s.end().near(end, tol) || s.start().near(start, tol) || s.end().near(start, tol)) - else { - break; - }; - let s0 = segs.remove(i); - used.push(owners.remove(i)); - if s0.start().near(end, tol) { - chain.push(s0); - } else if s0.end().near(end, tol) { - chain.push(s0.reversed()); - } else if s0.end().near(start, tol) { - chain.insert(0, s0); - } else { - chain.insert(0, s0.reversed()); - } - } - if chain.len() < 2 { - return Err(EngineError::Other("Objects are not connected.".into())); - } - let closed = chain.first().zip(chain.last()).is_some_and(|(a, b)| a.start().near(b.end(), tol)); - let mut vs: Vec = chain - .iter() - .map(|s| PolyVertex { - p: s.start(), - bulge: match s { - Segment::Line(_) => 0.0, - Segment::Arc { arc, ccw } => cadcraft_geom::arc_to_bulge(if *ccw { arc.sweep() } else { -arc.sweep() }), - }, - ..Default::default() - }) - .collect(); - if !closed && let Some(l) = chain.last() { - vs.push(PolyVertex::new(l.end())); - } - let space = s.space(); - let doc = s.doc_mut()?; - for h in hs.iter().filter(|h| used.contains(*h)) { - doc.remove_entity(*h); - } - let nh = doc.new_handle(); - if let Some(st) = doc.space_mut(&space) { - st.push(Entity { handle: nh, common: common.unwrap_or_default(), kind: lwpoly(vs, closed) }); - } - s.set_selection(Vec::new()); - Ok(Some(nh)) -} - -fn run_join(s: &mut Session, p: &Value) -> Result { - let hs = targets(s, p)?; - if p.get("close").and_then(Value::as_bool) == Some(true) { - let h = super::modify2::join_close(s, &hs)?; - return Ok(json!({ "handle": h.hex() })); - } - let r = join(s, &hs)?; - Ok(json!({ "handle": r.map(|h| h.hex()) })) -} - -// ---------------- interactive ---------------- - -#[derive(Clone, Copy, PartialEq)] -enum Op { - Erase, - Move, - Copy, - Rotate, - Scale, - Mirror, - Explode, - Join, -} - -/// Select objects, then collect the points/values the operation needs. -struct SelectThen { - op: Op, - sel: SelectPhase, - objs: Vec, - pts: Vec, - reference: bool, - /// ROTATE Reference: the reference angle (radians), once known. Its first point, and the - /// first of the two new-angle points, are kept in `ref_from`. - ref_angle: Option, - /// ROTATE Reference: the new angle is given by two points (the `Points` option). - ref_points: bool, - /// SCALE Reference: first point of a reference length given by two points. - ref_from: Option, - /// SCALE Reference: the reference length, once known. - ref_len: Option, - copy_mode: bool, - ask_erase: bool, - /// JOIN of a single arc or elliptical arc: the prompt asks for arcs to join to it, or cLose. - join_source: Option, -} - -impl SelectThen { - fn new(op: Op) -> Self { - SelectThen { - op, - sel: SelectPhase::default(), - objs: Vec::new(), - pts: Vec::new(), - reference: false, - ref_angle: None, - ref_points: false, - ref_from: None, - ref_len: None, - copy_mode: false, - ask_erase: false, - join_source: None, - } - } - fn apply(&mut self, s: &mut Session) -> Result { - // Immediate operations once selection is done. - match self.op { - Op::Erase => { - let n = erase(s, &self.objs)?; - let _ = n; - Ok(Step::Done) - } - Op::Explode => { - explode(s, &self.objs)?; - Ok(Step::Done) - } - Op::Join => { - if let [h] = self.objs[..] - && s.doc()?.entity(h).is_some_and(|e| super::modify2::closed_kind(&e.kind).is_some()) - { - self.join_source = Some(h); - return Ok(Step::Continue); - } - self.join_now(s) - } - _ => Ok(Step::Continue), - } - } - - /// JOIN the selected objects and report what they became. - fn join_now(&mut self, s: &mut Session) -> Result { - match join(s, &self.objs) { - Ok(h) => { - // Objects outside the joined chain are kept, so count only the ones JOIN consumed - // (and the source object an ellipse or spline join keeps). - let d = s.doc()?; - let n = self.objs.iter().filter(|o| d.entity(**o).is_none() || h == Some(**o)).count(); - let what = match h.and_then(|h| d.entity(h)).map(|e| &e.kind) { - Some(EntityKind::Spline(_)) => "spline", - Some(EntityKind::Ellipse(_)) => "ellipse", - _ => "polyline", - }; - s.echo(format!("{n} objects joined into 1 {what}")) - } - Err(e) => s.echo(e.to_string()), - } - Ok(Step::Done) - } - - /// JOIN of a single arc or elliptical arc: more arcs picked join to it on Enter, cLose makes - /// it its circle or full ellipse. - fn join_source_input(&mut self, s: &mut Session, src: Handle, i: Input) -> Result { - match i { - Input::Keyword(k) if k == "cLose" => { - let ellipse = matches!(s.doc()?.entity(src).map(|e| &e.kind), Some(EntityKind::Ellipse(_))); - super::modify2::join_close(s, &[src])?; - s.echo(if ellipse { "The elliptical arc is now an ellipse." } else { "The arc is now a circle." }); - Ok(Step::Done) - } - Input::Point(p) => { - if let Some(h) = super::curves::pick_at(s, p) - && !self.objs.contains(&h) - { - self.objs.push(h); - } - Ok(Step::Continue) - } - Input::Enter => self.join_now(s), - _ => Ok(Step::Continue), - } - } - - /// SCALE Reference: reference length (typed, or two points), then new length (typed, or a - /// point measured from the base point). The scale factor is new length / reference length. - fn scale_reference(&mut self, s: &mut Session, i: Input) -> Result { - let Some(base) = self.pts.first().copied() else { return Ok(Step::Done) }; - match (self.ref_len, self.ref_from, i) { - (None, None, Input::Point(p)) => { - self.ref_from = Some(p); - Ok(Step::Continue) - } - (None, None, Input::Text(t)) => { - self.ref_len = Some(require_length(number(&t))?); - Ok(Step::Continue) - } - (None, None, Input::Enter) => { - self.ref_len = Some(1.0); - Ok(Step::Continue) - } - (None, Some(a), Input::Point(p)) => { - self.ref_len = Some(require_length(Some(a.dist(p)))?); - Ok(Step::Continue) - } - (Some(r), _, Input::Point(p)) => self.scale_by(s, base, base.dist(p) / r), - (Some(r), _, Input::Text(t)) => { - let n = require_length(number(&t))?; - self.scale_by(s, base, n / r) - } - (_, _, Input::Enter) => Ok(Step::Done), - _ => Ok(Step::Continue), - } - } - - /// ROTATE Reference: reference angle (typed, or two points), then the new angle (typed, a - /// point measured from the base point, or two points after `Points`). The objects turn by the - /// new angle minus the reference angle. - fn rotate_reference(&mut self, s: &mut Session, i: Input) -> Result { - let Some(base) = self.pts.first().copied() else { return Ok(Step::Done) }; - let au = s.angle_settings(); - let angle = |t: &str| au.direction(t).filter(|a| a.is_finite()).ok_or_else(|| EngineError::Other("Requires an angle or point.".into())); - let two_points = |a: Vec2, p: Vec2| { - if a.near(p, 1e-12) { Err(EngineError::Other("The two points must differ.".into())) } else { Ok(a.angle_to(p)) } - }; - let Some(r) = self.ref_angle else { - match (self.ref_from, i) { - (None, Input::Point(p)) => self.ref_from = Some(p), - (None, Input::Text(t)) => self.ref_angle = Some(angle(&t)?), - (None, Input::Enter) => self.ref_angle = Some(0.0), - (Some(a), Input::Point(p)) => { - self.ref_angle = Some(two_points(a, p)?); - self.ref_from = None; - } - _ => {} - } - return Ok(Step::Continue); - }; - let new = match (self.ref_points, self.ref_from, i) { - (false, _, Input::Keyword(k)) if k == "Points" => { - self.ref_points = true; - return Ok(Step::Continue); - } - (false, _, Input::Point(p)) => base.angle_to(p), - (false, _, Input::Text(t)) => angle(&t)?, - (false, _, Input::Enter) => 0.0, - (true, None, Input::Point(p)) => { - self.ref_from = Some(p); - return Ok(Step::Continue); - } - (true, Some(a), Input::Point(p)) => two_points(a, p)?, - _ => return Ok(Step::Continue), - }; - transform_entities(s, &self.objs, &Mat3::rotate_about(base, new - r), self.copy_mode)?; - s.set_selection(Vec::new()); - Ok(Step::Done) - } - - /// The prompt while ROTATE Reference collects its angles. - fn rotate_reference_prompt(&self) -> Prompt { - match (self.ref_angle, self.ref_points, self.ref_from) { - (None, _, None) => Prompt::new("Specify the reference angle", Accept::POINT_OR_NUMBER).default("0"), - (None, _, Some(p)) | (Some(_), true, Some(p)) => Prompt::new("Specify second point", Accept::POINT).base(p), - (Some(_), false, _) => { - Prompt::new("Specify the new angle", Accept::POINT_OR_NUMBER).kw(&["Points"]).default("0").base_opt(self.pts.first().copied()) - } - (Some(_), true, None) => Prompt::new("Specify first point", Accept::POINT), - } - } - - /// Rubber band while ROTATE Reference collects its angles: the objects turned to the cursor - /// once the reference angle is known, else the line from the first point. - fn rotate_reference_preview(&self, s: &Session, c: Vec2) -> Vec { - let (Some(base), Ok(d)) = (self.pts.first().copied(), s.doc()) else { return Vec::new() }; - match (self.ref_angle, self.ref_points, self.ref_from) { - (Some(r), false, _) => { - let m = Mat3::rotate_about(base, base.angle_to(c) - r); - let mut out: Vec = self - .objs - .iter() - .take(500) - .filter_map(|h| d.entity(*h)) - .map(|e| { - let mut k = e.kind.clone(); - k.transform(&m); - k - }) - .collect(); - out.push(line(base, c)); - out - } - (_, _, Some(a)) => vec![line(a, c)], - _ => Vec::new(), - } - } - - fn scale_by(&mut self, s: &mut Session, base: Vec2, f: f64) -> Result { - let f = require_length(Some(f))?; - // The next SCALE offers this factor. - s.last_used.scale_factor = f; - transform_entities(s, &self.objs, &Mat3::scale_about(base, f), self.copy_mode)?; - s.set_selection(Vec::new()); - Ok(Step::Done) - } -} - -/// Objects whose text MIRROR keeps readable instead of mirroring it with the geometry. -fn keeps_reading_when_mirrored(k: &EntityKind) -> bool { - match k { - EntityKind::Insert(i) => !i.attribs.is_empty(), - k => matches!( - k, - EntityKind::Text(_) - | EntityKind::AttDef(_) - | EntityKind::MText(_) - | EntityKind::Dimension(_) - | EntityKind::MLeader(_) - | EntityKind::Table(_) - | EntityKind::Tolerance(_) - ), - } -} - -/// Text objects transformed for each frame of a MIRROR preview (on the CPU); more are not drawn. -const MAX_MIRROR_TEXT_PREVIEW: usize = 1000; - -/// A finite length greater than zero (rejects 0, negatives, NaN and infinity). -fn positive_length(v: Option) -> Option { - v.filter(|f| f.is_finite() && *f > 1e-12) -} - -/// [`positive_length`], or the error that makes the prompt ask again. -fn require_length(v: Option) -> Result { - positive_length(v).ok_or_else(|| EngineError::Other("Requires a positive number.".into())) -} - -impl Interactive for SelectThen { - fn name(&self) -> &'static str { - match self.op { - Op::Erase => "ERASE", - Op::Move => "MOVE", - Op::Copy => "COPY", - Op::Rotate => "ROTATE", - Op::Scale => "SCALE", - Op::Mirror => "MIRROR", - Op::Explode => "EXPLODE", - Op::Join => "JOIN", - } - } - fn begin(&mut self, s: &mut Session) -> Result { - self.sel = SelectPhase::begin(s); - if self.sel.done { - self.objs = self.sel.picked.clone(); - return self.apply(s); - } - Ok(Step::Continue) - } - fn prompt(&self, s: &Session) -> Prompt { - if !self.sel.done { - return self.sel.prompt(); - } - if self.ask_erase { - return Prompt::new("Erase source objects?", Accept::TEXT).kw(&["Yes", "No"]).default("No"); - } - if let Some(src) = self.join_source { - let ellipse = s.doc().is_ok_and(|d| matches!(d.entity(src).map(|e| &e.kind), Some(EntityKind::Ellipse(_)))); - let msg = if ellipse { "Select elliptical arcs to join to source" } else { "Select arcs to join to source" }; - return Prompt::new(msg, Accept::POINT).kw(&["cLose"]); - } - let k = self.pts.len(); - let base = self.pts.first().copied(); - if self.op == Op::Scale && self.reference { - return match (self.ref_len, self.ref_from) { - (Some(_), _) => Prompt::new("Specify new length", Accept::POINT_OR_NUMBER).base_opt(base), - (None, Some(p)) => Prompt::new("Specify second point", Accept::POINT).base(p), - (None, None) => Prompt::new("Specify reference length", Accept::POINT_OR_NUMBER).default("1"), - }; - } - if self.op == Op::Rotate && self.reference { - return self.rotate_reference_prompt(); - } - match (self.op, k) { - (Op::Move | Op::Copy, 0) => Prompt::new("Specify base point", Accept::POINT).kw(&["Displacement"]), - (Op::Move, _) => Prompt::new("Specify second point or ", Accept::POINT).base_opt(base), - (Op::Copy, _) => Prompt::new("Specify second point", Accept::POINT).kw(&["Array", "Exit", "Undo"]).base_opt(base), - (Op::Rotate | Op::Scale, 0) => Prompt::new("Specify base point", Accept::POINT), - // ROTATE and SCALE offer the angle/factor used last time. - (Op::Rotate, _) => { - let (au, ap) = s.doc().map(|d| (d.header.i64("AUNITS", 0), d.header.i64("AUPREC", 0))).unwrap_or((0, 0)); - // Offered as it would be typed: clockwise is positive when ANGDIR is 1. - let a = if s.angle_settings().clockwise { -s.last_used.rotate_angle } else { s.last_used.rotate_angle }; - Prompt::new("Specify rotation angle", Accept::POINT_OR_NUMBER) - .kw(&["Copy", "Reference"]) - .default(crate::units::format_angle(a, au, ap)) - .base_opt(base) - } - (Op::Scale, _) => Prompt::new("Specify scale factor", Accept::POINT_OR_NUMBER) - .kw(&["Copy", "Reference"]) - .default(format!("{:.4}", s.last_used.scale_factor)) - .base_opt(base), - (Op::Mirror, 0) => Prompt::new("Specify first point of mirror line", Accept::POINT), - (Op::Mirror, _) => Prompt::new("Specify second point of mirror line", Accept::POINT).base_opt(base), - _ => Prompt::new("", Accept::POINT), - } - } - fn input(&mut self, s: &mut Session, i: Input) -> Result { - if !self.sel.done { - match self.sel.feed(s, &i)? { - SelOutcome::More => return Ok(Step::Continue), - SelOutcome::Empty => return Ok(Step::Done), - SelOutcome::Done(hs) => { - self.objs = hs; - return self.apply(s); - } - } - } - if let Some(src) = self.join_source { - return self.join_source_input(s, src, i); - } - if self.ask_erase { - let yes = matches!(&i, Input::Keyword(k) if k == "Yes") || matches!(&i, Input::Text(t) if t.trim().to_ascii_lowercase().starts_with('y')); - let (a, b) = (self.pts[0], self.pts[1]); - transform_entities(s, &self.objs, &Mat3::mirror(a, b), !yes)?; - s.set_selection(Vec::new()); - return Ok(Step::Done); - } - let k = self.pts.len(); - if self.op == Op::Scale && self.reference { - return self.scale_reference(s, i); - } - if self.op == Op::Rotate && self.reference { - return self.rotate_reference(s, i); - } - match (self.op, k, i) { - (_, _, Input::Keyword(kw)) if kw == "Copy" => { - self.copy_mode = true; - s.echo("Rotating/scaling a copy of the selected objects."); - Ok(Step::Continue) - } - (Op::Scale | Op::Rotate, 1, Input::Keyword(kw)) if kw == "Reference" => { - self.reference = true; - Ok(Step::Continue) - } - (Op::Copy, _, Input::Keyword(kw)) if kw == "Exit" => { - s.set_selection(Vec::new()); - Ok(Step::Done) - } - (_, 0, Input::Point(p)) => { - self.pts.push(p); - Ok(Step::Continue) - } - (Op::Move, 1, Input::Point(p)) => { - let d = p - self.pts[0]; - transform_entities(s, &self.objs, &Mat3::translate(d), false)?; - s.set_selection(Vec::new()); - Ok(Step::Done) - } - (Op::Move, 1, Input::Enter) => { - let d = self.pts[0]; - transform_entities(s, &self.objs, &Mat3::translate(d), false)?; - s.set_selection(Vec::new()); - Ok(Step::Done) - } - (Op::Copy, _, Input::Point(p)) => { - let d = p - self.pts[0]; - transform_entities(s, &self.objs, &Mat3::translate(d), true)?; - Ok(Step::Continue) - } - (Op::Copy, _, Input::Enter) => { - s.set_selection(Vec::new()); - Ok(Step::Done) - } - (Op::Rotate, 1, inp @ (Input::Point(_) | Input::Text(_) | Input::Enter)) => { - let a = match inp { - Input::Point(p) => self.pts[0].angle_to(p), - Input::Text(t) => s - .angle_settings() - .rotation(&t) - .filter(|a| a.is_finite()) - .ok_or_else(|| EngineError::Other("Requires an angle or point.".into()))?, - _ => s.last_used.rotate_angle, - }; - s.last_used.rotate_angle = a; - transform_entities(s, &self.objs, &Mat3::rotate_about(self.pts[0], a), self.copy_mode)?; - s.set_selection(Vec::new()); - Ok(Step::Done) - } - (Op::Scale, 1, inp @ (Input::Point(_) | Input::Text(_) | Input::Enter)) => { - let f = match inp { - Input::Point(p) => self.pts[0].dist(p), - Input::Text(t) => { - number(&t).filter(|f| f.is_finite() && *f > 0.0).ok_or_else(|| EngineError::Other("Requires a positive number.".into()))? - } - _ => s.last_used.scale_factor, - }; - if f > 1e-12 && f.is_finite() { - s.last_used.scale_factor = f; - transform_entities(s, &self.objs, &Mat3::scale_about(self.pts[0], f), self.copy_mode)?; - } - s.set_selection(Vec::new()); - Ok(Step::Done) - } - (Op::Mirror, 1, Input::Point(p)) => { - if p.near(self.pts[0], 1e-12) { - return Err(EngineError::Other("Mirror line points must differ.".into())); - } - self.pts.push(p); - self.ask_erase = true; - Ok(Step::Continue) - } - (_, _, Input::Enter) => Ok(Step::Done), - _ => Ok(Step::Continue), - } - } - fn preview(&self, s: &Session, c: Vec2) -> Vec { - if self.op == Op::Rotate && self.reference { - return self.rotate_reference_preview(s, c); - } - if !self.sel.done { - return Vec::new(); - } - let Some(base) = self.pts.first().copied() else { return Vec::new() }; - let m = match self.op { - Op::Move | Op::Copy => Mat3::translate(c - base), - Op::Rotate => Mat3::rotate_about(base, base.angle_to(c)), - Op::Scale if !self.reference => Mat3::scale_about(base, base.dist(c).max(1e-9)), - Op::Scale => match self.ref_len.and_then(|r| positive_length(Some(base.dist(c) / r))) { - Some(f) => Mat3::scale_about(base, f), - None if self.ref_len.is_some() => return Vec::new(), - None => return self.ref_from.map(|a| vec![line(a, c)]).unwrap_or_default(), - }, - Op::Mirror if !self.ask_erase => Mat3::mirror(base, c), - _ => return Vec::new(), - }; - let Ok(d) = s.doc() else { return Vec::new() }; - let mut out: Vec = self - .objs - .iter() - .take(500) - .filter_map(|h| d.entity(*h)) - .map(|e| { - let mut k = e.kind.clone(); - k.transform(&m); - k - }) - .collect(); - out.push(line(base, c)); - out - } - fn drag(&self, s: &Session, c: Vec2) -> Option { - if (self.op == Op::Rotate && self.reference) || !self.sel.done { - return None; - } - let base = self.pts.first().copied()?; - let matrix = match self.op { - Op::Move | Op::Copy => Mat3::translate(c - base), - Op::Rotate => Mat3::rotate_about(base, base.angle_to(c)), - Op::Scale if !self.reference => Mat3::scale_about(base, base.dist(c).max(1e-9)), - Op::Scale => Mat3::scale_about(base, self.ref_len.and_then(|r| positive_length(Some(base.dist(c) / r)))?), - Op::Mirror if !self.ask_erase => Mat3::mirror(base, c), - _ => return None, - }; - let mut extra = vec![line(base, c)]; - if self.op != Op::Mirror { - return Some(crate::prompt::Drag { handles: self.objs.clone(), matrix, extra, clipboard: false }); - } - // Mirrored text keeps reading left to right (MIRRTEXT 0), which the matrix alone can't - // draw: those objects are transformed one by one, the rest is mirrored on the GPU. - let d = s.doc().ok()?; - let mut handles = Vec::with_capacity(self.objs.len()); - let mut texts = 0; - for h in &self.objs { - let Some(e) = d.entity(*h) else { continue }; - if !keeps_reading_when_mirrored(&e.kind) { - handles.push(*h); - } else if texts < MAX_MIRROR_TEXT_PREVIEW { - texts += 1; - let mut k = e.kind.clone(); - k.transform(&matrix); - extra.push(k); - } - } - Some(crate::prompt::Drag { handles, matrix, extra, clipboard: false }) - } -} - -/// An OFFSET option prompt opened from "Specify offset distance". -#[derive(Clone, Copy, PartialEq)] -enum OffsetAsk { - Erase, - Layer, -} - -struct OffsetM { - dist: Option, - distance_first: Option, - through: bool, - picked: Option, - ask: Option, - /// Multiple mode: each side point offsets the newest copy again, until Enter. - multiple: bool, - /// Undo: the drawing before each offset, with the object picked then. - undo: Vec<(std::sync::Arc, Option)>, -} - -impl OffsetM { - fn new(s: &Session) -> Self { - let _ = s; - OffsetM { dist: None, distance_first: None, through: false, picked: None, ask: None, multiple: false, undo: Vec::new() } - } - - fn set_distance(&mut self, s: &mut Session, distance: f64) -> Result<()> { - if !(distance.is_finite() && distance > 0.0) { - return Err(EngineError::Other("Requires a positive distance.".into())); - } - s.doc_mut()?.header.set_f64("OFFSETDIST", distance); - self.dist = Some(distance); - self.distance_first = None; - Ok(()) - } -} - -/// OFFSET's remembered options: erase the source (OFFSETERASE) and put copies on the current -/// layer instead of the source's (kept in the drawing header beside it). -fn offset_options(s: &Session) -> (bool, bool) { - s.doc().map(|d| (d.header.i64("OFFSETERASE", 0) != 0, d.header.i64("OFFSETLAYER", 0) != 0)).unwrap_or((false, false)) -} - -impl Interactive for OffsetM { - fn name(&self) -> &'static str { - "OFFSET" - } - /// Starts OFFSET, keeping a single preselected offsettable object (issue #1610) so the prompt - /// sequence goes distance then side instead of asking for the object again; any other - /// selection is cleared and the object loop asks for it. With PICKFIRST off the selection is - /// likewise ignored and cleared. - fn begin(&mut self, s: &mut Session) -> Result { - // Exactly one offsettable object selected before the command is taken at once, so only the - // side is asked for; the object loop stays for the ones after it (issue #1610). A multiple - // selection is left for the select prompt, as AutoCAD does. - let pre = s.selection(); - if s.settings.pickfirst - && let [h] = pre[..] - && s.doc()?.entity(h).is_some_and(|e| offsettable(&e.kind)) - { - self.picked = Some(h); - s.set_selection(vec![h]); - } else { - s.set_selection(Vec::new()); - } - let (erase, current) = offset_options(s); - s.echo(format!( - "Current settings: Erase source={} Layer={} OFFSETGAPTYPE=0", - if erase { "Yes" } else { "No" }, - if current { "Current" } else { "Source" } - )); - Ok(Step::Continue) - } - fn prompt(&self, s: &Session) -> Prompt { - let (erase, current) = offset_options(s); - match self.ask { - Some(OffsetAsk::Erase) => { - return Prompt::new("Erase source object after offsetting?", Accept::TEXT).kw(&["Yes", "No"]).default(if erase { - "Yes" - } else { - "No" - }); - } - Some(OffsetAsk::Layer) => { - return Prompt::new("Enter layer option for offset objects", Accept::TEXT).kw(&["Current", "Source"]).default(if current { - "Current" - } else { - "Source" - }); - } - None => {} - } - let last = s.doc().map(|d| d.header.f64("OFFSETDIST", -1.0)).unwrap_or(-1.0); - if self.dist.is_none() - && !self.through - && let Some(base) = self.distance_first - { - return Prompt::new("Specify second point", Accept::POINT).base(base); - } - match (self.dist.is_some() || self.through, self.picked) { - (false, _) => Prompt::new("Specify offset distance", Accept::POINT_OR_NUMBER).kw(&["Through", "Erase", "Layer"]).default(if last < 0.0 { - "Through".to_string() - } else { - format!("{last:.4}") - }), - (true, None) => Prompt::new("Select object to offset", Accept::POINT).kw(&["Exit", "Undo"]).default("Exit"), - (true, Some(_)) if self.multiple => { - let msg = if self.through { "Specify through point" } else { "Specify point on side to offset" }; - Prompt::new(msg, Accept::POINT).kw(&["Exit", "Undo"]).default("next object") - } - (true, Some(_)) if self.through => Prompt::new("Specify through point", Accept::POINT).kw(&["Exit", "Multiple", "Undo"]).default("Exit"), - (true, Some(_)) => Prompt::new("Specify point on side to offset", Accept::POINT).kw(&["Exit", "Multiple", "Undo"]).default("Exit"), - } - } - fn input(&mut self, s: &mut Session, i: Input) -> Result { - if let Some(ask) = self.ask { - let word = match &i { - Input::Keyword(k) | Input::Text(k) => k.trim().to_ascii_lowercase(), - Input::Enter => String::new(), - _ => return Ok(Step::Continue), - }; - let (key, yes, no) = match ask { - OffsetAsk::Erase => ("OFFSETERASE", "y", "n"), - OffsetAsk::Layer => ("OFFSETLAYER", "c", "s"), - }; - let v = if word.is_empty() { - None - } else if word.starts_with(yes) { - Some(1) - } else if word.starts_with(no) { - Some(0) - } else { - return Err(EngineError::Other("Invalid option keyword.".into())); - }; - if let Some(v) = v { - s.doc_mut()?.header.set_i64(key, v); - } - self.ask = None; - return Ok(Step::Continue); - } - if self.dist.is_none() && !self.through { - match i { - Input::Text(t) => { - let d = number(&t).ok_or_else(|| EngineError::Other("Requires a positive distance.".into()))?; - self.set_distance(s, d)?; - } - Input::Point(p) => { - if !p.is_finite() { - return Err(EngineError::Other("Invalid point.".into())); - } - match self.distance_first { - Some(base) => self.set_distance(s, base.dist(p))?, - None => self.distance_first = Some(p), - } - } - Input::Keyword(k) if k == "Through" => { - self.through = true; - self.distance_first = None; - } - Input::Keyword(k) if k == "Erase" => self.ask = Some(OffsetAsk::Erase), - Input::Keyword(k) if k == "Layer" => self.ask = Some(OffsetAsk::Layer), - Input::Enter => { - self.distance_first = None; - let last = s.doc()?.header.f64("OFFSETDIST", -1.0); - if last > 0.0 { - self.dist = Some(last); - } else { - self.through = true; - } - } - _ => {} - } - return Ok(Step::Continue); - } - match (self.picked, i) { - (_, Input::Keyword(k)) if k == "Exit" => { - s.set_selection(Vec::new()); - Ok(Step::Done) - } - (_, Input::Keyword(k)) if k == "Undo" => { - match self.undo.pop() { - Some((doc, picked)) => { - // Settings changed meanwhile stay. - let header = s.doc()?.header.clone(); - s.state_mut()?.doc = doc; - s.doc_mut()?.header = header; - self.picked = if self.multiple { picked } else { None }; - if self.picked.is_none() { - self.multiple = false; - } - s.set_selection(self.picked.into_iter().collect()); - } - None => s.echo("Nothing to undo."), - } - Ok(Step::Continue) - } - (Some(_), Input::Keyword(k)) if k == "Multiple" => { - self.multiple = true; - Ok(Step::Continue) - } - (Some(_), Input::Enter) if self.multiple => { - // : back to selecting. - self.multiple = false; - self.picked = None; - s.set_selection(Vec::new()); - Ok(Step::Continue) - } - (_, Input::Enter) => { - s.set_selection(Vec::new()); - Ok(Step::Done) - } - (None, Input::Point(p)) => { - let ap = s.pixel_size() * s.settings.pickbox.max(1.0) * 1.5; - let space = s.space(); - match crate::select::pick(s.doc()?, &space, p, ap) { - Some(h) => { - self.picked = Some(h); - s.set_selection(vec![h]); - } - None => s.echo("*Invalid selection*"), - } - Ok(Step::Continue) - } - (Some(h), Input::Point(p)) => { - let e = s.doc()?.entity(h).map(|e| (**e).clone()); - let mut made = None; - if let Some(e) = e { - let dist = match self.dist { - Some(d) => d, - None => { - let polys = crate::select::hit_polylines(s.doc()?, &e, 1e-4); - polys - .iter() - .flat_map(|pl| pl.windows(2).filter_map(|w| Some(Line::new(*w.first()?, *w.get(1)?).dist(p)))) - .fold(f64::INFINITY, f64::min) - } - }; - match offset_kind(&e.kind, dist, p) { - Some(k) => { - let before = s.state()?.doc.clone(); - let (erase, current) = offset_options(s); - made = Some(add_offset(s, &e, k, current, erase)?); - self.undo.push((before, Some(h))); - } - None => s.echo("Cannot offset that object."), - } - } - // Multiple mode carries on from the new copy. - self.picked = if self.multiple { made.or(self.picked) } else { None }; - s.set_selection(self.picked.into_iter().collect()); - Ok(Step::Continue) - } - _ => Ok(Step::Continue), - } - } - fn preview(&self, s: &Session, c: Vec2) -> Vec { - if let Some(base) = self.distance_first - && c.is_finite() - { - return vec![line(base, c)]; - } - let (Some(h), Ok(d)) = (self.picked, s.doc()) else { return Vec::new() }; - let Some(e) = d.entity(h) else { return Vec::new() }; - let dist = match self.dist { - Some(x) => x, - None => crate::select::entity_distance(d, e, c, 1e-3), - }; - offset_kind(&e.kind, dist, c).into_iter().collect() - } -} - -/// A FILLET/CHAMFER option prompt opened from the select prompts. -#[derive(Clone, Copy, PartialEq)] -enum FilletAsk { - /// "Enter Trim mode option [Trim/No trim]" (TRIMMODE). - Trim, - /// CHAMFER "Enter trim method [Distance/Angle]" (CHAMMODE). - Method, - /// CHAMFER Angle: the length on the first line (CHAMFERC), then the angle (CHAMFERD). - Length, - Angle, -} - -struct FilletM { - chamfer: bool, - first: Option<(Handle, Vec2)>, - /// Asking for the radius (FILLET) or a chamfer distance: 1 = first, 2 = second. - asking: u8, - polyline: bool, - ask: Option, - /// Multiple: keep asking for pairs until Enter. - multiple: bool, - /// Undo: the drawing before each fillet/chamfer made in this command. - undo: Vec>, -} - -impl FilletM { - fn new(_s: &Session, chamfer: bool) -> Self { - FilletM { chamfer, first: None, asking: 0, polyline: false, ask: None, multiple: false, undo: Vec::new() } - } - - /// One fillet/chamfer done: carry on in Multiple mode, else finish. - fn next(&mut self, s: &mut Session, before: std::sync::Arc) -> Step { - self.undo.push(before); - self.first = None; - self.polyline = false; - s.set_selection(Vec::new()); - if self.multiple { Step::Continue } else { Step::Done } - } -} - -/// TRIMMODE: FILLET and CHAMFER trim or extend the selected objects to the new corner (default). -fn trim_mode(s: &Session) -> bool { - s.doc().map(|d| d.header.i64("TRIMMODE", 1) != 0).unwrap_or(true) -} - -/// The chamfer CHAMFER uses now: two distances, or length and angle when CHAMMODE = 1. -fn chamfer_cut(s: &Session) -> Result { - let h = &s.doc()?.header; - Ok(if h.i64("CHAMMODE", 0) == 1 { - ChamferCut::Angle(h.f64("CHAMFERC", 0.0), h.f64("CHAMFERD", 0.0)) - } else { - ChamferCut::Dist(h.f64("CHAMFERA", 0.0), h.f64("CHAMFERB", 0.0)) - }) -} - -impl Interactive for FilletM { - fn name(&self) -> &'static str { - if self.chamfer { "CHAMFER" } else { "FILLET" } - } - fn begin(&mut self, s: &mut Session) -> Result { - s.set_selection(Vec::new()); - let trim = trim_mode(s); - let d = s.doc()?; - let msg = if self.chamfer { - let mode = if trim { "TRIM" } else { "NOTRIM" }; - if d.header.i64("CHAMMODE", 0) == 1 { - let (au, ap) = (d.header.i64("AUNITS", 0), d.header.i64("AUPREC", 0)); - let a = crate::units::format_angle(d.header.f64("CHAMFERD", 0.0), au, ap); - format!("({mode} mode) Current chamfer Length = {:.4}, Angle = {a}", d.header.f64("CHAMFERC", 0.0)) - } else { - format!("({mode} mode) Current chamfer Dist1 = {:.4}, Dist2 = {:.4}", d.header.f64("CHAMFERA", 0.0), d.header.f64("CHAMFERB", 0.0)) - } - } else { - format!("Current settings: Mode = {}, Radius = {:.4}", if trim { "TRIM" } else { "NOTRIM" }, d.header.f64("FILLETRAD", 0.0)) - }; - s.echo(msg); - Ok(Step::Continue) - } - fn prompt(&self, s: &Session) -> Prompt { - // The radius and distances are remembered in the drawing (FILLETRAD, CHAMFERA/B) and - // offered as defaults; the second chamfer distance defaults to the first. - if self.asking > 0 { - let hdr = |k: &str| s.doc().map(|d| d.header.f64(k, 0.0)).unwrap_or(0.0); - let (msg, v) = match (self.chamfer, self.asking) { - (false, _) => ("Specify fillet radius", hdr("FILLETRAD")), - (true, 1) => ("Specify first chamfer distance", hdr("CHAMFERA")), - (true, _) => ("Specify second chamfer distance", hdr("CHAMFERA")), - }; - return Prompt::new(msg, Accept::NUMBER).default(format!("{v:.4}")); - } - let hdr_i = |k: &str, dflt: i64| s.doc().map(|d| d.header.i64(k, dflt)).unwrap_or(dflt); - match self.ask { - Some(FilletAsk::Trim) => { - let cur = if trim_mode(s) { "Trim" } else { "No trim" }; - return Prompt::new("Enter Trim mode option", Accept::TEXT).kw(&["Trim", "No trim"]).default(cur); - } - Some(FilletAsk::Method) => { - let cur = if hdr_i("CHAMMODE", 0) == 1 { "Angle" } else { "Distance" }; - return Prompt::new("Enter trim method", Accept::TEXT).kw(&["Distance", "Angle"]).default(cur); - } - Some(FilletAsk::Length) => { - let v = s.doc().map(|d| d.header.f64("CHAMFERC", 0.0)).unwrap_or(0.0); - return Prompt::new("Specify chamfer length on the first line", Accept::NUMBER).default(format!("{v:.4}")); - } - Some(FilletAsk::Angle) => { - let (a, au, ap) = - s.doc().map(|d| (d.header.f64("CHAMFERD", 0.0), d.header.i64("AUNITS", 0), d.header.i64("AUPREC", 0))).unwrap_or((0.0, 0, 0)); - return Prompt::new("Specify chamfer angle from the first line", Accept::NUMBER).default(crate::units::format_angle(a, au, ap)); - } - None => {} - } - if self.polyline { - return Prompt::new("Select 2D polyline", Accept::POINT); - } - match (self.first, self.chamfer) { - (None, false) => Prompt::new("Select first object", Accept::POINT).kw(&["Undo", "Polyline", "Radius", "Trim", "Multiple"]), - (None, true) => { - Prompt::new("Select first line", Accept::POINT).kw(&["Undo", "Polyline", "Distance", "Angle", "Trim", "mEthod", "Multiple"]) - } - (Some(_), false) => Prompt::new("Select second object or shift-select to apply corner", Accept::POINT).kw(&["Radius"]), - (Some(_), true) => Prompt::new("Select second line or shift-select to apply corner", Accept::POINT).kw(&["Distance", "Angle", "Method"]), - } - } - fn input(&mut self, s: &mut Session, i: Input) -> Result { - if self.asking > 0 { - // Enter keeps the default. - let v = match &i { - Input::Text(t) => Some( - number(t).filter(|v| v.is_finite() && *v >= 0.0).ok_or_else(|| EngineError::Other("Requires a non-negative distance.".into()))?, - ), - Input::Enter => None, - _ => return Ok(Step::Continue), - }; - let d = s.doc_mut()?; - match (self.chamfer, self.asking) { - (false, _) => { - if let Some(v) = v { - d.header.set_f64("FILLETRAD", v); - } - self.asking = 0; - } - (true, 1) => { - if let Some(v) = v { - d.header.set_f64("CHAMFERA", v); - } - self.asking = 2; - } - (true, _) => { - let v = v.unwrap_or_else(|| d.header.f64("CHAMFERA", 0.0)); - d.header.set_f64("CHAMFERB", v); - self.asking = 0; - } - } - return Ok(Step::Continue); - } - if let Some(ask) = self.ask { - return self.option(s, ask, i); - } - match i { - Input::Keyword(k) if k == "Radius" || k == "Distance" => { - if self.chamfer { - s.doc_mut()?.header.set_i64("CHAMMODE", 0); - } - self.asking = 1; - Ok(Step::Continue) - } - Input::Keyword(k) if k == "Angle" && self.chamfer => { - s.doc_mut()?.header.set_i64("CHAMMODE", 1); - self.ask = Some(FilletAsk::Length); - Ok(Step::Continue) - } - Input::Keyword(k) if k == "mEthod" || k == "Method" => { - self.ask = Some(FilletAsk::Method); - Ok(Step::Continue) - } - Input::Keyword(k) if k == "Trim" => { - self.ask = Some(FilletAsk::Trim); - Ok(Step::Continue) - } - Input::Keyword(k) if k == "Multiple" => { - self.multiple = true; - Ok(Step::Continue) - } - Input::Keyword(k) if k == "Polyline" => { - self.polyline = true; - Ok(Step::Continue) - } - Input::Keyword(k) if k == "Undo" => { - match self.undo.pop() { - Some(doc) => { - // Settings changed meanwhile stay. - let header = s.doc()?.header.clone(); - s.state_mut()?.doc = doc; - s.doc_mut()?.header = header; - } - None => s.echo("Nothing to undo."), - } - self.first = None; - s.set_selection(Vec::new()); - Ok(Step::Continue) - } - Input::Point(p) => { - let ap = s.pixel_size() * s.settings.pickbox.max(1.0) * 1.5; - let space = s.space(); - let Some(h) = crate::select::pick(s.doc()?, &space, p, ap) else { - s.echo("*Invalid selection*"); - return Ok(Step::Continue); - }; - if self.polyline { - let before = s.state()?.doc.clone(); - if self.chamfer { - let n = chamfer_polyline(s, h, chamfer_cut(s)?)?; - s.echo(format!("{n} lines were chamfered")); - } else { - let r = s.doc()?.header.f64("FILLETRAD", 0.0); - let n = super::modify2::fillet_polyline(s, h, r)?; - s.echo(format!("{n} lines were filleted")); - } - return Ok(self.next(s, before)); - } - match self.first { - None => { - self.first = Some((h, p)); - s.set_selection(vec![h]); - Ok(Step::Continue) - } - Some((h1, p1)) => { - let r = s.doc()?.header.f64("FILLETRAD", 0.0); - let cut = if self.chamfer { Some(chamfer_cut(s)?) } else { None }; - let before = s.state()?.doc.clone(); - let trim = trim_mode(s); - match fillet_corner(s, h1, p1, h, p, r, cut, trim) { - Ok(_) => Ok(self.next(s, before)), - Err(e) => { - s.echo(e.to_string()); - self.first = None; - s.set_selection(Vec::new()); - Ok(Step::Continue) - } - } - } - } - } - Input::Enter => Ok(Step::Done), - _ => Ok(Step::Continue), - } - } -} - -impl FilletM { - /// Input at a Trim / Method / Length / Angle option prompt; Enter keeps the current value. - fn option(&mut self, s: &mut Session, ask: FilletAsk, i: Input) -> Result { - let text = match &i { - Input::Keyword(k) | Input::Text(k) => k.trim().to_ascii_lowercase(), - Input::Enter => String::new(), - _ => return Ok(Step::Continue), - }; - let invalid = || EngineError::Other("Invalid option keyword.".into()); - let flag = |yes: &str, no: &str| -> Result> { - if text.is_empty() { - Ok(None) - } else if text.starts_with(yes) { - Ok(Some(1)) - } else if text.starts_with(no) { - Ok(Some(0)) - } else { - Err(invalid()) - } - }; - let au = s.angle_settings(); - let d = s.doc_mut()?; - self.ask = None; - match ask { - FilletAsk::Trim => { - if let Some(v) = flag("t", "n")? { - d.header.set_i64("TRIMMODE", v); - } - } - FilletAsk::Method => { - if let Some(v) = flag("a", "d")? { - d.header.set_i64("CHAMMODE", v); - } - } - FilletAsk::Length => { - if !text.is_empty() { - let v = number(&text) - .filter(|v| v.is_finite() && *v >= 0.0) - .ok_or_else(|| EngineError::Other("Requires a non-negative distance.".into()))?; - d.header.set_f64("CHAMFERC", v); - } - self.ask = Some(FilletAsk::Angle); - } - FilletAsk::Angle => { - if !text.is_empty() { - let a = au - .amount(&text) - .filter(|a| a.is_finite() && *a >= 0.0 && *a < std::f64::consts::PI) - .ok_or_else(|| EngineError::Other("Requires an angle from 0 to 180 degrees.".into()))?; - d.header.set_f64("CHAMFERD", a); - } - } - } - Ok(Step::Continue) - } -} - -/// How CHAMFER cuts a corner: two distances, or a length on the first line and an angle -/// (radians) from it. -#[derive(Clone, Copy, Debug)] -enum ChamferCut { - Dist(f64, f64), - Angle(f64, f64), -} - -impl ChamferCut { - /// The distances back from the corner along the first and second lines, which leave it - /// along the unit directions `u1` and `u2`. - fn distances(self, u1: Vec2, u2: Vec2) -> Result<(f64, f64)> { - match self { - ChamferCut::Dist(a, b) => Ok((a, b)), - ChamferCut::Angle(len, ang) => { - // Triangle corner–t1–t2: the corner angle and the chamfer angle at t1 give t2 by - // the law of sines. - let corner = u1.dot(u2).clamp(-1.0, 1.0).acos(); - let s = (corner + ang).sin(); - let d2 = len * ang.sin() / s; - if corner + ang >= std::f64::consts::PI - 1e-9 || s.abs() < 1e-12 || !d2.is_finite() || d2 < 0.0 { - return Err(EngineError::Other("The chamfer angle does not fit this corner.".into())); - } - Ok((len, d2)) - } - } - } -} - -/// FILLET (`cut` None) or CHAMFER two objects, honouring TRIMMODE: with `trim` false the new -/// arc or line is added and both objects are left as they were. -#[allow(clippy::too_many_arguments)] -fn fillet_corner(s: &mut Session, h1: Handle, p1: Vec2, h2: Handle, p2: Vec2, r: f64, cut: Option, trim: bool) -> Result> { - let dist = cut.map(|c| move |u1: Vec2, u2: Vec2| c.distances(u1, u2)); - if let Some(made) = super::modify2::fillet_pline(s, h1, p1, h2, p2, r, dist.as_ref().map(|f| f as super::modify2::ChamferDistances), trim)? { - return Ok(made); - } - let d = s.doc()?; - let k1 = d.entity(h1).map(|e| e.kind.clone()); - let k2 = d.entity(h2).map(|e| e.kind.clone()); - let chamfer = match cut { - None => None, - Some(c) => { - let l = |h| d.entity(h).and_then(|e| as_line(e)).ok_or_else(|| EngineError::Other(CHAMFER_LINES_ONLY.into())); - let (l1, l2) = (l(h1)?, l(h2)?); - let (x, _, _) = line_line_infinite(l1.a, l1.b, l2.a, l2.b).ok_or_else(|| EngineError::Other("Lines are parallel.".into()))?; - // The direction along each line towards its picked side. - let dir = |l: &Line, p: Vec2| if (l.a - x).dot(p - x) >= (l.b - x).dot(p - x) { (l.a - x).normalized() } else { (l.b - x).normalized() }; - Some(c.distances(dir(&l1, p1), dir(&l2, p2))?) - } - }; - let made = fillet_lines(s, h1, p1, h2, p2, r, chamfer)?; - if !trim { - let doc = s.doc_mut()?; - for (h, k) in [(h1, k1), (h2, k2)] { - if let Some(k) = k { - doc.modify_entity(h, |e| e.kind = k)?; - } - } - } - Ok(made) -} - -/// CHAMFER Polyline: cut every corner between two straight segments of a 2D polyline. Returns -/// how many corners were chamfered. -fn chamfer_polyline(s: &mut Session, h: Handle, cut: ChamferCut) -> Result { - let d = s.doc()?; - let e = d.entity(h).ok_or_else(|| EngineError::Other("no such object".into()))?; - if d.layer(&e.common.layer).is_some_and(|l| l.locked) { - return Err(EngineError::Other("The object is on a locked layer.".into())); - } - let EntityKind::LwPolyline(pl) = &e.kind else { return Err(EngineError::Other("Select a 2D polyline.".into())) }; - let vs = &pl.vertices; - let n = vs.len(); - if n < 3 { - return Ok(0); - } - let at = |i: usize| vs.get(i % n).copied().unwrap_or_default(); - // Distances cut back from each corner along the incoming and outgoing segments. - let mut cuts = vec![(0.0f64, 0.0f64); n]; - for (i, c) in cuts.iter_mut().enumerate() { - if !pl.closed && (i == 0 || i + 1 == n) { - continue; - } - let (prev, cur, next) = (at(i + n - 1), at(i), at(i + 1)); - if prev.bulge.abs() > 1e-12 || cur.bulge.abs() > 1e-12 { - continue; - } - let u1 = (prev.p - cur.p).normalized(); - let u2 = (next.p - cur.p).normalized(); - if u1 == Vec2::ZERO || u2 == Vec2::ZERO || u1.dot(u2) < -1.0 + 1e-9 { - continue; - } - if let Ok(ab) = cut.distances(u1, u2) { - *c = ab; - } - } - // Drop chamfers that do not fit their segments. - for i in 0..n { - let j = (i + 1) % n; - let seg = at(i).p.dist(at(j).p); - let (out_i, in_j) = (cuts.get(i).map_or(0.0, |c| c.1), cuts.get(j).map_or(0.0, |c| c.0)); - if out_i + in_j > seg + 1e-9 { - for k in [i, j] { - if let Some(c) = cuts.get_mut(k) { - *c = (0.0, 0.0); - } - } - } - } - let mut out: Vec = Vec::with_capacity(n * 2); - let mut count = 0; - for i in 0..n { - let v = at(i); - let (a, b) = cuts.get(i).copied().unwrap_or_default(); - if a <= 1e-12 && b <= 1e-12 { - out.push(v); - continue; - } - let t1 = v.p + (at(i + n - 1).p - v.p).normalized() * a; - let t2 = v.p + (at(i + 1).p - v.p).normalized() * b; - out.push(PolyVertex { p: t1, bulge: 0.0, ..v }); - out.push(PolyVertex { p: t2, bulge: 0.0, ..v }); - count += 1; - } - let mut npl = pl.clone(); - npl.vertices = out; - s.doc_mut()?.modify_entity(h, |e| e.kind = EntityKind::LwPolyline(npl))?; - Ok(count) -} - -#[derive(Default)] -struct BreakM { - obj: Option<(Handle, Vec2)>, - /// The `First point` option: the next point replaces the selection pick as the first break point. - first_point: bool, -} - -impl Interactive for BreakM { - fn name(&self) -> &'static str { - "BREAK" - } - fn prompt(&self, _s: &Session) -> Prompt { - match self.obj { - None => Prompt::new("Select object", Accept::POINT), - Some(_) if self.first_point => Prompt::new("Specify first break point", Accept::POINT), - Some(_) => Prompt::new("Specify second break point", Accept::POINT).kw(&["First point"]), - } - } - fn input(&mut self, s: &mut Session, i: Input) -> Result { - match (self.obj, i) { - (None, Input::Point(p)) => { - let ap = s.pixel_size() * s.settings.pickbox.max(1.0) * 1.5; - let space = s.space(); - match crate::select::pick(s.doc()?, &space, p, ap) { - Some(h) => { - self.obj = Some((h, p)); - s.set_selection(vec![h]); - } - None => s.echo("*Invalid selection*"), - } - Ok(Step::Continue) - } - (Some(_), Input::Keyword(k)) if k == "First point" => { - self.first_point = true; - Ok(Step::Continue) - } - (Some((h, _)), Input::Point(p1)) if self.first_point => { - self.obj = Some((h, p1)); - self.first_point = false; - Ok(Step::Continue) - } - (Some((h, p1)), Input::Point(p2)) => { - break_entity(s, h, p1, p2)?; - s.set_selection(Vec::new()); - Ok(Step::Done) - } - (_, Input::Enter) => Ok(Step::Done), - _ => Ok(Step::Continue), - } - } -} - -/// MOVE and COPY: [`SelectThen`], plus the Displacement option at the base point prompt -/// (the keyword, or Enter): the typed point is the displacement itself. -struct DisplaceM { - inner: SelectThen, - displacement: bool, -} - -impl DisplaceM { - fn new(op: Op) -> Self { - DisplaceM { inner: SelectThen::new(op), displacement: false } - } - /// At "Specify base point": objects selected, no point yet. - fn at_base(&self) -> bool { - self.inner.sel.done && self.inner.pts.is_empty() && !self.displacement - } -} - -impl Interactive for DisplaceM { - fn name(&self) -> &'static str { - self.inner.name() - } - fn begin(&mut self, s: &mut Session) -> Result { - self.inner.begin(s) - } - fn prompt(&self, s: &Session) -> Prompt { - if self.displacement { - return Prompt::new("Specify displacement", Accept::POINT).default("0,0"); - } - if self.at_base() { - return Prompt::new("Specify base point", Accept::POINT).kw(&["Displacement"]).default("Displacement"); - } - self.inner.prompt(s) - } - fn input(&mut self, s: &mut Session, i: Input) -> Result { - if self.displacement { - let d = match i { - Input::Point(p) => p, - Input::Enter => Vec2::ZERO, - _ => return Ok(Step::Continue), - }; - if d != Vec2::ZERO { - transform_entities(s, &self.inner.objs, &Mat3::translate(d), self.inner.op == Op::Copy)?; - } - s.set_selection(Vec::new()); - return Ok(Step::Done); - } - if self.at_base() && (matches!(&i, Input::Enter) || matches!(&i, Input::Keyword(k) if k == "Displacement")) { - self.displacement = true; - return Ok(Step::Continue); - } - self.inner.input(s, i) - } - fn preview(&self, s: &Session, c: Vec2) -> Vec { - if self.displacement { - return Vec::new(); - } - self.inner.preview(s, c) - } - fn drag(&self, s: &Session, c: Vec2) -> Option { - if self.displacement { - return None; - } - self.inner.drag(s, c) - } -} - pub(crate) fn _unused(_: Circle) {} diff --git a/crates/engine/src/cmd/modify/break_join.rs b/crates/engine/src/cmd/modify/break_join.rs new file mode 100644 index 000000000..16892c199 --- /dev/null +++ b/crates/engine/src/cmd/modify/break_join.rs @@ -0,0 +1,277 @@ +use super::trim::{param_on, sub_polyline}; +use super::*; + +/// Gives a rebuilt arc piece its source centre Z, or a rebuilt polyline piece its source elevation. +fn with_z(mut k: EntityKind, z: f64) -> EntityKind { + match &mut k { + EntityKind::Arc(a) => a.center.z = z, + EntityKind::LwPolyline(p) => p.elevation = z, + _ => {} + } + k +} + +/// Break `h` between two points (or at one point when p1 == p2). +pub(crate) fn break_entity(s: &mut Session, h: Handle, p1: Vec2, p2: Vec2) -> Result> { + if crate::cmd::curves::is_locked(s, h) { + return Err(EngineError::Other("The object is on a locked layer.".into())); + } + let e = s.doc()?.entity(h).map(|e| (**e).clone()).ok_or_else(|| EngineError::Other("no such object".into()))?; + let pieces: Vec = match &e.kind { + EntityKind::Line(l) => { + let ln = Line::new(l.a.xy(), l.b.xy()); + let (mut t1, mut t2) = (ln.param_of(p1).clamp(0.0, 1.0), ln.param_of(p2).clamp(0.0, 1.0)); + if t1 > t2 { + std::mem::swap(&mut t1, &mut t2); + } + let at3 = |t: f64| ln.at(t).to3(l.a.z + (l.b.z - l.a.z) * t); + let mut v = Vec::new(); + if t1 > 1e-12 { + v.push(EntityKind::Line(cadcraft_doc::Line { a: l.a, b: at3(t1) })); + } + if t2 < 1.0 - 1e-12 { + v.push(EntityKind::Line(cadcraft_doc::Line { a: at3(t2), b: l.b })); + } + v + } + EntityKind::Circle(c) => { + if p1.near(p2, 1e-12) { + return Err(EngineError::Other("Arc cannot be full 360 degrees.".into())); + } + let a1 = c.center.xy().angle_to(p1); + let a2 = c.center.xy().angle_to(p2); + // AutoCAD removes the CCW portion from the first to the second point. + vec![with_z(arc(&Arc::new(c.center.xy(), c.radius, a2, a1)), c.center.z)] + } + EntityKind::Arc(a) => { + let ga = Arc::new(a.center.xy(), a.radius, a.start, a.end); + let par = |p: Vec2| norm_angle(ga.center.angle_to(p) - ga.start).min(ga.sweep()); + let (mut t1, mut t2) = (par(p1), par(p2)); + if t1 > t2 { + std::mem::swap(&mut t1, &mut t2); + } + let mut v = Vec::new(); + if t1 > 1e-12 { + v.push(with_z(arc(&Arc::new(ga.center, ga.radius, ga.start, ga.start + t1)), a.center.z)); + } + if t2 < ga.sweep() - 1e-12 { + v.push(with_z(arc(&Arc::new(ga.center, ga.radius, ga.start + t2, ga.end)), a.center.z)); + } + v + } + EntityKind::LwPolyline(p) => { + let pl = Polyline { vertices: p.vertices.clone(), closed: p.closed }; + let segs = pl.segments(); + let total: f64 = segs.iter().map(Segment::len).sum(); + let lp = |q: Vec2| { + let mut acc = 0.0; + let mut best = (f64::INFINITY, 0.0); + for s in &segs { + let c = s.closest(q); + let dd = c.dist(q); + if dd < best.0 { + best = (dd, acc + param_on(s, c) * s.len()); + } + acc += s.len(); + } + best.1 + }; + let (mut t1, mut t2) = (lp(p1), lp(p2)); + if p.closed { + if (t1 - t2).abs() < 1e-9 { + return Err(EngineError::Other("Cannot break a closed polyline at a single point.".into())); + } + // Like a circle: the stretch from the first point to the second, along the + // vertices, goes; what is left runs from the second point round to the first. + let doubled: Vec = segs.iter().chain(segs.iter()).copied().collect(); + let end = if t1 > t2 { t1 } else { t1 + total }; + vec![with_z(lwpoly(sub_polyline(&doubled, t2, end), false), p.elevation)] + } else { + if t1 > t2 { + std::mem::swap(&mut t1, &mut t2); + } + let mut v = Vec::new(); + if t1 > 1e-12 { + v.push(with_z(lwpoly(sub_polyline(&segs, 0.0, t1), false), p.elevation)); + } + if t2 < total - 1e-12 { + v.push(with_z(lwpoly(sub_polyline(&segs, t2, total), false), p.elevation)); + } + v + } + } + _ => return Err(EngineError::Other("Cannot break that object.".into())), + }; + let common = e.common.clone(); + let space = s.space(); + let d = s.doc_mut()?; + d.remove_entity(h); + let mut out = Vec::new(); + for k in pieces { + let nh = d.new_handle(); + if let Some(st) = d.space_mut(&space) { + st.push(Entity { handle: nh, common: common.clone(), kind: k }); + out.push(nh); + } + } + Ok(out) +} + +pub(super) fn run_break(s: &mut Session, p: &Value) -> Result { + let h = targets(s, p)?.first().copied().ok_or_else(|| bad("break", "`handle` is required"))?; + let p1 = point_req("break", p, "p1")?; + let p2 = point_param(p, "p2").unwrap_or(p1); + let r = break_entity(s, h, p1, p2)?; + Ok(json!({ "handles": r.iter().map(|h| h.hex()).collect::>() })) +} + +pub(super) fn run_breakat(s: &mut Session, p: &Value) -> Result { + let h = targets(s, p)?.first().copied().ok_or_else(|| bad("breakatpoint", "`handle` is required"))?; + let at = point_req("breakatpoint", p, "at")?; + let r = break_entity(s, h, at, at)?; + Ok(json!({ "handles": r.iter().map(|h| h.hex()).collect::>() })) +} + +/// Join lines/arcs/polylines that share endpoints into one polyline (or merge collinear lines). +pub(crate) fn join(s: &mut Session, hs: &[Handle]) -> Result> { + if let Some(h) = crate::cmd::modify2::join_curves(s, hs)? { + return Ok(Some(h)); + } + if hs.iter().any(|h| crate::cmd::curves::is_locked(s, *h)) { + return Err(EngineError::Other("An object is on a locked layer.".into())); + } + let d = s.doc()?; + let mut segs: Vec = Vec::new(); + // Source entity of each segment in `segs`, so only entities that end up in the chain are removed. + let mut owners: Vec = Vec::new(); + let mut common = None; + for h in hs { + let Some(e) = d.entity(*h) else { continue }; + common.get_or_insert_with(|| e.common.clone()); + match &e.kind { + EntityKind::Line(l) => segs.push(Segment::Line(Line::new(l.a.xy(), l.b.xy()))), + EntityKind::Arc(a) => segs.push(Segment::Arc { arc: Arc::new(a.center.xy(), a.radius, a.start, a.end), ccw: true }), + EntityKind::LwPolyline(p) if !p.closed => segs.extend(Polyline { vertices: p.vertices.clone(), closed: false }.segments()), + _ => {} + } + owners.resize(segs.len(), *h); + } + if segs.len() < 2 { + return Err(EngineError::Other("Select at least two objects to join.".into())); + } + // Chain greedily from the first segment. + let mut chain = vec![segs.remove(0)]; + let mut used = vec![owners.remove(0)]; + let tol = 1e-6; + loop { + let start = chain.first().map(Segment::start).unwrap_or_default(); + let end = chain.last().map(Segment::end).unwrap_or_default(); + let Some(i) = + segs.iter().position(|s| s.start().near(end, tol) || s.end().near(end, tol) || s.start().near(start, tol) || s.end().near(start, tol)) + else { + break; + }; + let s0 = segs.remove(i); + used.push(owners.remove(i)); + if s0.start().near(end, tol) { + chain.push(s0); + } else if s0.end().near(end, tol) { + chain.push(s0.reversed()); + } else if s0.end().near(start, tol) { + chain.insert(0, s0); + } else { + chain.insert(0, s0.reversed()); + } + } + if chain.len() < 2 { + return Err(EngineError::Other("Objects are not connected.".into())); + } + let closed = chain.first().zip(chain.last()).is_some_and(|(a, b)| a.start().near(b.end(), tol)); + let mut vs: Vec = chain + .iter() + .map(|s| PolyVertex { + p: s.start(), + bulge: match s { + Segment::Line(_) => 0.0, + Segment::Arc { arc, ccw } => cadcraft_geom::arc_to_bulge(if *ccw { arc.sweep() } else { -arc.sweep() }), + }, + ..Default::default() + }) + .collect(); + if !closed && let Some(l) = chain.last() { + vs.push(PolyVertex::new(l.end())); + } + let space = s.space(); + let doc = s.doc_mut()?; + for h in hs.iter().filter(|h| used.contains(*h)) { + doc.remove_entity(*h); + } + let nh = doc.new_handle(); + if let Some(st) = doc.space_mut(&space) { + st.push(Entity { handle: nh, common: common.unwrap_or_default(), kind: lwpoly(vs, closed) }); + } + s.set_selection(Vec::new()); + Ok(Some(nh)) +} + +pub(super) fn run_join(s: &mut Session, p: &Value) -> Result { + let hs = targets(s, p)?; + if p.get("close").and_then(Value::as_bool) == Some(true) { + let h = crate::cmd::modify2::join_close(s, &hs)?; + return Ok(json!({ "handle": h.hex() })); + } + let r = join(s, &hs)?; + Ok(json!({ "handle": r.map(|h| h.hex()) })) +} + +#[derive(Default)] +pub(super) struct BreakM { + obj: Option<(Handle, Vec2)>, + /// The `First point` option: the next point replaces the selection pick as the first break point. + first_point: bool, +} + +impl Interactive for BreakM { + fn name(&self) -> &'static str { + "BREAK" + } + fn prompt(&self, _s: &Session) -> Prompt { + match self.obj { + None => Prompt::new("Select object", Accept::POINT), + Some(_) if self.first_point => Prompt::new("Specify first break point", Accept::POINT), + Some(_) => Prompt::new("Specify second break point", Accept::POINT).kw(&["First point"]), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + match (self.obj, i) { + (None, Input::Point(p)) => { + let ap = s.pixel_size() * s.settings.pickbox.max(1.0) * 1.5; + let space = s.space(); + match crate::select::pick(s.doc()?, &space, p, ap) { + Some(h) => { + self.obj = Some((h, p)); + s.set_selection(vec![h]); + } + None => s.echo("*Invalid selection*"), + } + Ok(Step::Continue) + } + (Some(_), Input::Keyword(k)) if k == "First point" => { + self.first_point = true; + Ok(Step::Continue) + } + (Some((h, _)), Input::Point(p1)) if self.first_point => { + self.obj = Some((h, p1)); + self.first_point = false; + Ok(Step::Continue) + } + (Some((h, p1)), Input::Point(p2)) => { + break_entity(s, h, p1, p2)?; + s.set_selection(Vec::new()); + Ok(Step::Done) + } + (_, Input::Enter) => Ok(Step::Done), + _ => Ok(Step::Continue), + } + } +} diff --git a/crates/engine/src/cmd/modify/explode.rs b/crates/engine/src/cmd/modify/explode.rs new file mode 100644 index 000000000..3b25ccfee --- /dev/null +++ b/crates/engine/src/cmd/modify/explode.rs @@ -0,0 +1,207 @@ +use super::*; + +// ---------------- explode / array / order / break / join ---------------- + +fn explode_block_ref(d: &cadcraft_doc::Drawing, e: &Entity, ins: &cadcraft_doc::Insert) -> Option> { + if let Some(items) = crate::cmd::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) { + let mut se = match d.anno_draw(e) { + cadcraft_doc::AnnoDraw::Scaled(f) => cadcraft_doc::anno_scaled(e, f), + _ => e.clone(), + }; + se.common.anno_scales.clear(); + return explode_kind(d, &se); + } + match &e.kind { + EntityKind::LwPolyline(p) => { + let pl = Polyline { vertices: p.vertices.clone(), closed: p.closed }; + 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( + p.points + .windows(2) + .filter_map(|w| { + let kind = EntityKind::Line(cadcraft_doc::Line { a: *w.first()?, b: *w.get(1)? }); + Some(Entity { handle: Handle(0), common: e.common.clone(), kind }) + }) + .collect(), + ), + EntityKind::MLine(m) => Some(crate::cmd::draw2::mline::explode(e, m)), + EntityKind::MText(m) => Some(explode_mtext(e, m)), + // A region explodes into its boundary's lines and arcs. + EntityKind::Region(r) => Some(segment_entities(e, r.segments())), + EntityKind::Dimension(dm) => Some(explode_dimension(d, e, dm)), + EntityKind::Tolerance(t) => Some(crate::cmd::tolerance::explode(d, e, t)), + _ => None, + } +} + +pub(super) fn explode(s: &mut Session, hs: &[Handle]) -> Result> { + explode_with(s, hs, explode_kind) +} + +/// Replace each of `hs` (off locked layers) by the pieces `pieces` gives for it; objects it gives +/// none for are left alone. EXPLODE, and BURST with its own pieces for block references. +pub(crate) fn explode_with( + s: &mut Session, + hs: &[Handle], + pieces: impl Fn(&cadcraft_doc::Drawing, &Entity) -> Option>, +) -> Result> { + let space = s.space(); + let mut out = Vec::new(); + for h in hs { + if crate::cmd::curves::is_locked(s, *h) { + continue; + } + let Some(e) = s.doc()?.entity(*h).map(|e| (**e).clone()) else { continue }; + let Some(parts) = pieces(s.doc()?, &e) else { continue }; + // The pieces of an object are new objects: its xdata goes with it. A block reference's + // pieces are copies of the block's objects and keep theirs. + let own_pieces = !matches!(e.kind, EntityKind::Insert(_)); + let d = s.doc_mut()?; + d.remove_entity(*h); + for mut p in parts { + if own_pieces { + p.common.xdata.clear(); + } + p.handle = d.new_handle(); + out.push(p.handle); + if let Some(st) = d.space_mut(&space) { + st.push(p); + } + } + } + s.set_selection(Vec::new()); + Ok(out) +} + +pub(super) fn run_explode(s: &mut Session, p: &Value) -> Result { + let hs = targets(s, p)?; + let r = explode(s, &hs)?; + Ok(json!({ "handles": r.iter().map(|h| h.hex()).collect::>() })) +} diff --git a/crates/engine/src/cmd/modify/fillet.rs b/crates/engine/src/cmd/modify/fillet.rs new file mode 100644 index 000000000..01d701817 --- /dev/null +++ b/crates/engine/src/cmd/modify/fillet.rs @@ -0,0 +1,571 @@ +use super::*; + +// ---------------- fillet / chamfer ---------------- + +/// What CHAMFER takes (as in AutoCAD, not arcs, circles, ellipses or splines). +const CHAMFER_LINES_ONLY: &str = + "CHAMFER works on lines (and polylines with the Polyline option); it cannot chamfer arcs, circles, ellipses or splines."; + +fn as_line(e: &Entity) -> Option { + match &e.kind { + EntityKind::Line(l) => Some(Line::new(l.a.xy(), l.b.xy())), + _ => None, + } +} + +/// Fillet (or chamfer) two lines. `p1`/`p2` mark the kept side of each line. +pub(crate) fn fillet_lines( + s: &mut Session, + h1: Handle, + p1: Vec2, + h2: Handle, + p2: Vec2, + radius: f64, + chamfer: Option<(f64, f64)>, +) -> Result> { + if crate::cmd::curves::is_locked(s, h1) || crate::cmd::curves::is_locked(s, h2) { + return Err(EngineError::Other("The object is on a locked layer.".into())); + } + let d = s.doc()?; + let e1 = d.entity(h1).map(|e| (**e).clone()).ok_or_else(|| EngineError::Other("no such object".into()))?; + let e2 = d.entity(h2).map(|e| (**e).clone()).ok_or_else(|| EngineError::Other("no such object".into()))?; + let (Some(l1), Some(l2)) = (as_line(&e1), as_line(&e2)) else { + if chamfer.is_none() { + return crate::cmd::modify2::fillet_curves(s, h1, p1, h2, p2, radius); + } + return Err(EngineError::Other(CHAMFER_LINES_ONLY.into())); + }; + let (x, _, _) = line_line_infinite(l1.a, l1.b, l2.a, l2.b).ok_or_else(|| EngineError::Other("Lines are parallel.".into()))?; + // Keep the far end on the picked side of the corner. + let keep = |l: &Line, p: Vec2| -> Vec2 { + let ta = (l.a - x).dot(p - x); + let tb = (l.b - x).dot(p - x); + if ta >= tb { l.a } else { l.b } + }; + let k1 = keep(&l1, p1); + let k2 = keep(&l2, p2); + let u1 = (k1 - x).normalized(); + let u2 = (k2 - x).normalized(); + let mut new_arc = None; + let (t1, t2) = if let Some((d1, d2)) = chamfer { + (x + u1 * d1, x + u2 * d2) + } else if radius <= 1e-12 { + (x, x) + } else { + let half = u1.dot(u2).clamp(-1.0, 1.0).acos() / 2.0; + if half.sin().abs() < 1e-12 { + return Err(EngineError::Other("Lines are parallel.".into())); + } + let dist = radius / half.tan(); + let t1 = x + u1 * dist; + let t2 = x + u2 * dist; + let bis = (u1 + u2).normalized(); + let c = x + bis * (radius / half.sin()); + let a1 = c.angle_to(t1); + let a2 = c.angle_to(t2); + let ar = if ccw_sweep(a1, a2) <= std::f64::consts::PI { Arc::new(c, radius, a1, a2) } else { Arc::new(c, radius, a2, a1) }; + new_arc = Some(ar); + (t1, t2) + }; + let common = e1.common.clone(); + let doc = s.doc_mut()?; + doc.modify_entity(h1, |e| e.kind = line(k1, t1))?; + doc.modify_entity(h2, |e| e.kind = line(k2, t2))?; + let space = s.space(); + let doc = s.doc_mut()?; + let extra = match (chamfer, new_arc) { + (Some(_), _) if !t1.near(t2, 1e-12) => Some(line(t1, t2)), + (None, Some(a)) => Some(arc(&a)), + _ => None, + }; + if let Some(k) = extra { + let nh = doc.new_handle(); + if let Some(st) = doc.space_mut(&space) { + st.push(Entity { handle: nh, common, kind: k }); + } + return Ok(Some(nh)); + } + Ok(None) +} + +fn h_param(p: &Value, k: &str) -> Option { + p.get(k).and_then(|v| v.as_str().and_then(Handle::parse_hex).or_else(|| v.as_u64().map(Handle))) +} + +pub(super) fn run_fillet(s: &mut Session, p: &Value) -> Result { + if bool_or(p, "polyline", false) { + let h = h_param(p, "handle").or_else(|| h_param(p, "h1")).ok_or_else(|| bad("fillet", "`handle` (polyline) is required"))?; + let r = f64_or(p, "radius", s.doc()?.header.f64("FILLETRAD", 0.0)); + let n = crate::cmd::modify2::fillet_polyline(s, h, r)?; + return Ok(json!({ "filleted": n })); + } + let h1 = h_param(p, "h1").ok_or_else(|| bad("fillet", "`h1` is required"))?; + let h2 = h_param(p, "h2").ok_or_else(|| bad("fillet", "`h2` is required"))?; + let r = f64_or(p, "radius", s.doc()?.header.f64("FILLETRAD", 0.0)); + let p1 = point_param(p, "p1").unwrap_or_default(); + let p2 = point_param(p, "p2").unwrap_or_default(); + let a = fillet_corner(s, h1, p1, h2, p2, r, None, bool_or(p, "trim", true))?; + Ok(json!({ "arc": a.map(|h| h.hex()) })) +} + +pub(super) fn run_chamfer(s: &mut Session, p: &Value) -> Result { + let cut = match (p.get("length").and_then(Value::as_f64), p.get("angle").and_then(Value::as_f64)) { + (Some(len), Some(deg)) if len.is_finite() && len >= 0.0 && deg.is_finite() && (0.0..180.0).contains(°) => { + ChamferCut::Angle(len, deg.to_radians()) + } + (None, None) => { + let d1 = f64_or(p, "d1", s.doc()?.header.f64("CHAMFERA", 0.0)); + ChamferCut::Dist(d1, f64_or(p, "d2", d1)) + } + _ => return Err(bad("chamfer", "`length` (>= 0) and `angle` (degrees, 0 to 180) go together")), + }; + if bool_or(p, "polyline", false) { + let h = h_param(p, "handle").or_else(|| h_param(p, "h1")).ok_or_else(|| bad("chamfer", "`handle` (polyline) is required"))?; + return Ok(json!({ "chamfered": chamfer_polyline(s, h, cut)? })); + } + let h1 = h_param(p, "h1").ok_or_else(|| bad("chamfer", "`h1` is required"))?; + let h2 = h_param(p, "h2").ok_or_else(|| bad("chamfer", "`h2` is required"))?; + let p1 = point_param(p, "p1").unwrap_or_default(); + let p2 = point_param(p, "p2").unwrap_or_default(); + let a = fillet_corner(s, h1, p1, h2, p2, 0.0, Some(cut), bool_or(p, "trim", true))?; + Ok(json!({ "line": a.map(|h| h.hex()) })) +} + +/// A FILLET/CHAMFER option prompt opened from the select prompts. +#[derive(Clone, Copy, PartialEq)] +enum FilletAsk { + /// "Enter Trim mode option [Trim/No trim]" (TRIMMODE). + Trim, + /// CHAMFER "Enter trim method [Distance/Angle]" (CHAMMODE). + Method, + /// CHAMFER Angle: the length on the first line (CHAMFERC), then the angle (CHAMFERD). + Length, + Angle, +} + +pub(super) struct FilletM { + chamfer: bool, + first: Option<(Handle, Vec2)>, + /// Asking for the radius (FILLET) or a chamfer distance: 1 = first, 2 = second. + asking: u8, + polyline: bool, + ask: Option, + /// Multiple: keep asking for pairs until Enter. + multiple: bool, + /// Undo: the drawing before each fillet/chamfer made in this command. + undo: Vec>, +} + +impl FilletM { + pub(super) fn new(_s: &Session, chamfer: bool) -> Self { + FilletM { chamfer, first: None, asking: 0, polyline: false, ask: None, multiple: false, undo: Vec::new() } + } + + /// One fillet/chamfer done: carry on in Multiple mode, else finish. + fn next(&mut self, s: &mut Session, before: std::sync::Arc) -> Step { + self.undo.push(before); + self.first = None; + self.polyline = false; + s.set_selection(Vec::new()); + if self.multiple { Step::Continue } else { Step::Done } + } +} + +/// TRIMMODE: FILLET and CHAMFER trim or extend the selected objects to the new corner (default). +fn trim_mode(s: &Session) -> bool { + s.doc().map(|d| d.header.i64("TRIMMODE", 1) != 0).unwrap_or(true) +} + +/// The chamfer CHAMFER uses now: two distances, or length and angle when CHAMMODE = 1. +fn chamfer_cut(s: &Session) -> Result { + let h = &s.doc()?.header; + Ok(if h.i64("CHAMMODE", 0) == 1 { + ChamferCut::Angle(h.f64("CHAMFERC", 0.0), h.f64("CHAMFERD", 0.0)) + } else { + ChamferCut::Dist(h.f64("CHAMFERA", 0.0), h.f64("CHAMFERB", 0.0)) + }) +} + +impl Interactive for FilletM { + fn name(&self) -> &'static str { + if self.chamfer { "CHAMFER" } else { "FILLET" } + } + fn begin(&mut self, s: &mut Session) -> Result { + s.set_selection(Vec::new()); + let trim = trim_mode(s); + let d = s.doc()?; + let msg = if self.chamfer { + let mode = if trim { "TRIM" } else { "NOTRIM" }; + if d.header.i64("CHAMMODE", 0) == 1 { + let (au, ap) = (d.header.i64("AUNITS", 0), d.header.i64("AUPREC", 0)); + let a = crate::units::format_angle(d.header.f64("CHAMFERD", 0.0), au, ap); + format!("({mode} mode) Current chamfer Length = {:.4}, Angle = {a}", d.header.f64("CHAMFERC", 0.0)) + } else { + format!("({mode} mode) Current chamfer Dist1 = {:.4}, Dist2 = {:.4}", d.header.f64("CHAMFERA", 0.0), d.header.f64("CHAMFERB", 0.0)) + } + } else { + format!("Current settings: Mode = {}, Radius = {:.4}", if trim { "TRIM" } else { "NOTRIM" }, d.header.f64("FILLETRAD", 0.0)) + }; + s.echo(msg); + Ok(Step::Continue) + } + fn prompt(&self, s: &Session) -> Prompt { + // The radius and distances are remembered in the drawing (FILLETRAD, CHAMFERA/B) and + // offered as defaults; the second chamfer distance defaults to the first. + if self.asking > 0 { + let hdr = |k: &str| s.doc().map(|d| d.header.f64(k, 0.0)).unwrap_or(0.0); + let (msg, v) = match (self.chamfer, self.asking) { + (false, _) => ("Specify fillet radius", hdr("FILLETRAD")), + (true, 1) => ("Specify first chamfer distance", hdr("CHAMFERA")), + (true, _) => ("Specify second chamfer distance", hdr("CHAMFERA")), + }; + return Prompt::new(msg, Accept::NUMBER).default(format!("{v:.4}")); + } + let hdr_i = |k: &str, dflt: i64| s.doc().map(|d| d.header.i64(k, dflt)).unwrap_or(dflt); + match self.ask { + Some(FilletAsk::Trim) => { + let cur = if trim_mode(s) { "Trim" } else { "No trim" }; + return Prompt::new("Enter Trim mode option", Accept::TEXT).kw(&["Trim", "No trim"]).default(cur); + } + Some(FilletAsk::Method) => { + let cur = if hdr_i("CHAMMODE", 0) == 1 { "Angle" } else { "Distance" }; + return Prompt::new("Enter trim method", Accept::TEXT).kw(&["Distance", "Angle"]).default(cur); + } + Some(FilletAsk::Length) => { + let v = s.doc().map(|d| d.header.f64("CHAMFERC", 0.0)).unwrap_or(0.0); + return Prompt::new("Specify chamfer length on the first line", Accept::NUMBER).default(format!("{v:.4}")); + } + Some(FilletAsk::Angle) => { + let (a, au, ap) = + s.doc().map(|d| (d.header.f64("CHAMFERD", 0.0), d.header.i64("AUNITS", 0), d.header.i64("AUPREC", 0))).unwrap_or((0.0, 0, 0)); + return Prompt::new("Specify chamfer angle from the first line", Accept::NUMBER).default(crate::units::format_angle(a, au, ap)); + } + None => {} + } + if self.polyline { + return Prompt::new("Select 2D polyline", Accept::POINT); + } + match (self.first, self.chamfer) { + (None, false) => Prompt::new("Select first object", Accept::POINT).kw(&["Undo", "Polyline", "Radius", "Trim", "Multiple"]), + (None, true) => { + Prompt::new("Select first line", Accept::POINT).kw(&["Undo", "Polyline", "Distance", "Angle", "Trim", "mEthod", "Multiple"]) + } + (Some(_), false) => Prompt::new("Select second object or shift-select to apply corner", Accept::POINT).kw(&["Radius"]), + (Some(_), true) => Prompt::new("Select second line or shift-select to apply corner", Accept::POINT).kw(&["Distance", "Angle", "Method"]), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + if self.asking > 0 { + // Enter keeps the default. + let v = match &i { + Input::Text(t) => Some( + number(t).filter(|v| v.is_finite() && *v >= 0.0).ok_or_else(|| EngineError::Other("Requires a non-negative distance.".into()))?, + ), + Input::Enter => None, + _ => return Ok(Step::Continue), + }; + let d = s.doc_mut()?; + match (self.chamfer, self.asking) { + (false, _) => { + if let Some(v) = v { + d.header.set_f64("FILLETRAD", v); + } + self.asking = 0; + } + (true, 1) => { + if let Some(v) = v { + d.header.set_f64("CHAMFERA", v); + } + self.asking = 2; + } + (true, _) => { + let v = v.unwrap_or_else(|| d.header.f64("CHAMFERA", 0.0)); + d.header.set_f64("CHAMFERB", v); + self.asking = 0; + } + } + return Ok(Step::Continue); + } + if let Some(ask) = self.ask { + return self.option(s, ask, i); + } + match i { + Input::Keyword(k) if k == "Radius" || k == "Distance" => { + if self.chamfer { + s.doc_mut()?.header.set_i64("CHAMMODE", 0); + } + self.asking = 1; + Ok(Step::Continue) + } + Input::Keyword(k) if k == "Angle" && self.chamfer => { + s.doc_mut()?.header.set_i64("CHAMMODE", 1); + self.ask = Some(FilletAsk::Length); + Ok(Step::Continue) + } + Input::Keyword(k) if k == "mEthod" || k == "Method" => { + self.ask = Some(FilletAsk::Method); + Ok(Step::Continue) + } + Input::Keyword(k) if k == "Trim" => { + self.ask = Some(FilletAsk::Trim); + Ok(Step::Continue) + } + Input::Keyword(k) if k == "Multiple" => { + self.multiple = true; + Ok(Step::Continue) + } + Input::Keyword(k) if k == "Polyline" => { + self.polyline = true; + Ok(Step::Continue) + } + Input::Keyword(k) if k == "Undo" => { + match self.undo.pop() { + Some(doc) => { + // Settings changed meanwhile stay. + let header = s.doc()?.header.clone(); + s.state_mut()?.doc = doc; + s.doc_mut()?.header = header; + } + None => s.echo("Nothing to undo."), + } + self.first = None; + s.set_selection(Vec::new()); + Ok(Step::Continue) + } + Input::Point(p) => { + let ap = s.pixel_size() * s.settings.pickbox.max(1.0) * 1.5; + let space = s.space(); + let Some(h) = crate::select::pick(s.doc()?, &space, p, ap) else { + s.echo("*Invalid selection*"); + return Ok(Step::Continue); + }; + if self.polyline { + let before = s.state()?.doc.clone(); + if self.chamfer { + let n = chamfer_polyline(s, h, chamfer_cut(s)?)?; + s.echo(format!("{n} lines were chamfered")); + } else { + let r = s.doc()?.header.f64("FILLETRAD", 0.0); + let n = crate::cmd::modify2::fillet_polyline(s, h, r)?; + s.echo(format!("{n} lines were filleted")); + } + return Ok(self.next(s, before)); + } + match self.first { + None => { + self.first = Some((h, p)); + s.set_selection(vec![h]); + Ok(Step::Continue) + } + Some((h1, p1)) => { + let r = s.doc()?.header.f64("FILLETRAD", 0.0); + let cut = if self.chamfer { Some(chamfer_cut(s)?) } else { None }; + let before = s.state()?.doc.clone(); + let trim = trim_mode(s); + match fillet_corner(s, h1, p1, h, p, r, cut, trim) { + Ok(_) => Ok(self.next(s, before)), + Err(e) => { + s.echo(e.to_string()); + self.first = None; + s.set_selection(Vec::new()); + Ok(Step::Continue) + } + } + } + } + } + Input::Enter => Ok(Step::Done), + _ => Ok(Step::Continue), + } + } +} + +impl FilletM { + /// Input at a Trim / Method / Length / Angle option prompt; Enter keeps the current value. + fn option(&mut self, s: &mut Session, ask: FilletAsk, i: Input) -> Result { + let text = match &i { + Input::Keyword(k) | Input::Text(k) => k.trim().to_ascii_lowercase(), + Input::Enter => String::new(), + _ => return Ok(Step::Continue), + }; + let invalid = || EngineError::Other("Invalid option keyword.".into()); + let flag = |yes: &str, no: &str| -> Result> { + if text.is_empty() { + Ok(None) + } else if text.starts_with(yes) { + Ok(Some(1)) + } else if text.starts_with(no) { + Ok(Some(0)) + } else { + Err(invalid()) + } + }; + let au = s.angle_settings(); + let d = s.doc_mut()?; + self.ask = None; + match ask { + FilletAsk::Trim => { + if let Some(v) = flag("t", "n")? { + d.header.set_i64("TRIMMODE", v); + } + } + FilletAsk::Method => { + if let Some(v) = flag("a", "d")? { + d.header.set_i64("CHAMMODE", v); + } + } + FilletAsk::Length => { + if !text.is_empty() { + let v = number(&text) + .filter(|v| v.is_finite() && *v >= 0.0) + .ok_or_else(|| EngineError::Other("Requires a non-negative distance.".into()))?; + d.header.set_f64("CHAMFERC", v); + } + self.ask = Some(FilletAsk::Angle); + } + FilletAsk::Angle => { + if !text.is_empty() { + let a = au + .amount(&text) + .filter(|a| a.is_finite() && *a >= 0.0 && *a < std::f64::consts::PI) + .ok_or_else(|| EngineError::Other("Requires an angle from 0 to 180 degrees.".into()))?; + d.header.set_f64("CHAMFERD", a); + } + } + } + Ok(Step::Continue) + } +} + +/// How CHAMFER cuts a corner: two distances, or a length on the first line and an angle +/// (radians) from it. +#[derive(Clone, Copy, Debug)] +enum ChamferCut { + Dist(f64, f64), + Angle(f64, f64), +} + +impl ChamferCut { + /// The distances back from the corner along the first and second lines, which leave it + /// along the unit directions `u1` and `u2`. + fn distances(self, u1: Vec2, u2: Vec2) -> Result<(f64, f64)> { + match self { + ChamferCut::Dist(a, b) => Ok((a, b)), + ChamferCut::Angle(len, ang) => { + // Triangle corner–t1–t2: the corner angle and the chamfer angle at t1 give t2 by + // the law of sines. + let corner = u1.dot(u2).clamp(-1.0, 1.0).acos(); + let s = (corner + ang).sin(); + let d2 = len * ang.sin() / s; + if corner + ang >= std::f64::consts::PI - 1e-9 || s.abs() < 1e-12 || !d2.is_finite() || d2 < 0.0 { + return Err(EngineError::Other("The chamfer angle does not fit this corner.".into())); + } + Ok((len, d2)) + } + } + } +} + +/// FILLET (`cut` None) or CHAMFER two objects, honouring TRIMMODE: with `trim` false the new +/// arc or line is added and both objects are left as they were. +#[allow(clippy::too_many_arguments)] +fn fillet_corner(s: &mut Session, h1: Handle, p1: Vec2, h2: Handle, p2: Vec2, r: f64, cut: Option, trim: bool) -> Result> { + let dist = cut.map(|c| move |u1: Vec2, u2: Vec2| c.distances(u1, u2)); + if let Some(made) = + crate::cmd::modify2::fillet_pline(s, h1, p1, h2, p2, r, dist.as_ref().map(|f| f as crate::cmd::modify2::ChamferDistances), trim)? + { + return Ok(made); + } + let d = s.doc()?; + let k1 = d.entity(h1).map(|e| e.kind.clone()); + let k2 = d.entity(h2).map(|e| e.kind.clone()); + let chamfer = match cut { + None => None, + Some(c) => { + let l = |h| d.entity(h).and_then(|e| as_line(e)).ok_or_else(|| EngineError::Other(CHAMFER_LINES_ONLY.into())); + let (l1, l2) = (l(h1)?, l(h2)?); + let (x, _, _) = line_line_infinite(l1.a, l1.b, l2.a, l2.b).ok_or_else(|| EngineError::Other("Lines are parallel.".into()))?; + // The direction along each line towards its picked side. + let dir = |l: &Line, p: Vec2| if (l.a - x).dot(p - x) >= (l.b - x).dot(p - x) { (l.a - x).normalized() } else { (l.b - x).normalized() }; + Some(c.distances(dir(&l1, p1), dir(&l2, p2))?) + } + }; + let made = fillet_lines(s, h1, p1, h2, p2, r, chamfer)?; + if !trim { + let doc = s.doc_mut()?; + for (h, k) in [(h1, k1), (h2, k2)] { + if let Some(k) = k { + doc.modify_entity(h, |e| e.kind = k)?; + } + } + } + Ok(made) +} + +/// CHAMFER Polyline: cut every corner between two straight segments of a 2D polyline. Returns +/// how many corners were chamfered. +fn chamfer_polyline(s: &mut Session, h: Handle, cut: ChamferCut) -> Result { + let d = s.doc()?; + let e = d.entity(h).ok_or_else(|| EngineError::Other("no such object".into()))?; + if d.layer(&e.common.layer).is_some_and(|l| l.locked) { + return Err(EngineError::Other("The object is on a locked layer.".into())); + } + let EntityKind::LwPolyline(pl) = &e.kind else { return Err(EngineError::Other("Select a 2D polyline.".into())) }; + let vs = &pl.vertices; + let n = vs.len(); + if n < 3 { + return Ok(0); + } + let at = |i: usize| vs.get(i % n).copied().unwrap_or_default(); + // Distances cut back from each corner along the incoming and outgoing segments. + let mut cuts = vec![(0.0f64, 0.0f64); n]; + for (i, c) in cuts.iter_mut().enumerate() { + if !pl.closed && (i == 0 || i + 1 == n) { + continue; + } + let (prev, cur, next) = (at(i + n - 1), at(i), at(i + 1)); + if prev.bulge.abs() > 1e-12 || cur.bulge.abs() > 1e-12 { + continue; + } + let u1 = (prev.p - cur.p).normalized(); + let u2 = (next.p - cur.p).normalized(); + if u1 == Vec2::ZERO || u2 == Vec2::ZERO || u1.dot(u2) < -1.0 + 1e-9 { + continue; + } + if let Ok(ab) = cut.distances(u1, u2) { + *c = ab; + } + } + // Drop chamfers that do not fit their segments. + for i in 0..n { + let j = (i + 1) % n; + let seg = at(i).p.dist(at(j).p); + let (out_i, in_j) = (cuts.get(i).map_or(0.0, |c| c.1), cuts.get(j).map_or(0.0, |c| c.0)); + if out_i + in_j > seg + 1e-9 { + for k in [i, j] { + if let Some(c) = cuts.get_mut(k) { + *c = (0.0, 0.0); + } + } + } + } + let mut out: Vec = Vec::with_capacity(n * 2); + let mut count = 0; + for i in 0..n { + let v = at(i); + let (a, b) = cuts.get(i).copied().unwrap_or_default(); + if a <= 1e-12 && b <= 1e-12 { + out.push(v); + continue; + } + let t1 = v.p + (at(i + n - 1).p - v.p).normalized() * a; + let t2 = v.p + (at(i + 1).p - v.p).normalized() * b; + out.push(PolyVertex { p: t1, bulge: 0.0, ..v }); + out.push(PolyVertex { p: t2, bulge: 0.0, ..v }); + count += 1; + } + let mut npl = pl.clone(); + npl.vertices = out; + s.doc_mut()?.modify_entity(h, |e| e.kind = EntityKind::LwPolyline(npl))?; + Ok(count) +} diff --git a/crates/engine/src/cmd/modify/offset.rs b/crates/engine/src/cmd/modify/offset.rs new file mode 100644 index 000000000..67bdd7ec7 --- /dev/null +++ b/crates/engine/src/cmd/modify/offset.rs @@ -0,0 +1,497 @@ +use super::*; + +/// Whether OFFSET can work on this kind of object at all. +fn offsettable(k: &EntityKind) -> bool { + matches!( + k, + EntityKind::Line(_) + | EntityKind::Circle(_) + | EntityKind::Arc(_) + | EntityKind::Ellipse(_) + | EntityKind::LwPolyline(_) + | EntityKind::Spline(_) + | EntityKind::XLine(_) + | EntityKind::Ray(_) + ) +} + +/// Offset an entity by `dist` towards `side`. Returns the new geometry. +pub(crate) fn offset_kind(k: &EntityKind, dist: f64, side: Vec2) -> Option { + match k { + EntityKind::Line(l) => { + let ln = Line::new(l.a.xy(), l.b.xy()); + let sgn = if ln.side(side) >= 0.0 { 1.0 } else { -1.0 }; + let o = ln.offset(dist * sgn); + Some(line(o.a, o.b)) + } + EntityKind::Circle(c) => { + let inside = side.dist(c.center.xy()) < c.radius; + let r = if inside { c.radius - dist } else { c.radius + dist }; + (r > 1e-12).then(|| circle(c.center.xy(), r)) + } + EntityKind::Arc(a) => { + let inside = side.dist(a.center.xy()) < a.radius; + let r = if inside { a.radius - dist } else { a.radius + dist }; + (r > 1e-12).then(|| arc(&Arc::new(a.center.xy(), r, a.start, a.end))) + } + EntityKind::Ellipse(e) => { + // Approximate with a polyline offset of the tessellation. + let ge = cadcraft_geom::Ellipse { center: e.center.xy(), major: e.major.xy(), ratio: e.ratio, start: e.start, end: e.end }; + // Inside = on the centre side of the full ellipse, even for an open arc. + let inside = { + let a = e.major.xy().len(); + let b = a * e.ratio.abs(); + let u = e.major.xy().normalized(); + let d = side - e.center.xy(); + let (x, y) = (d.dot(u) * b, d.dot(u.perp()) * a); + x * x + y * y < (a * b) * (a * b) + }; + // offset_points shifts along the left normal, which points into a counter-clockwise ellipse. + let mut pts = Vec::new(); + ge.tessellate(e.major.xy().len() * 1e-3, &mut pts); + let off = offset_points(&pts, if inside { dist } else { -dist }, ge.is_full()); + Some(lwpoly(off.into_iter().map(PolyVertex::new).collect(), ge.is_full())) + } + EntityKind::LwPolyline(p) => { + let pl = Polyline { vertices: p.vertices.clone(), closed: p.closed }; + // Side: compare with the nearest segment. + let segs = pl.segments(); + let nearest = segs.iter().min_by(|a, b| a.closest(side).dist(side).total_cmp(&b.closest(side).dist(side)))?; + let c = nearest.closest(side); + let t = nearest.tangent(0.5); + let left = t.cross(side - c) > 0.0; + let d = if left { dist } else { -dist }; + offset_polyline(&pl, d).map(|vs| { + let mut k = lwpoly(vs, p.closed); + if let EntityKind::LwPolyline(n) = &mut k { + n.const_width = p.const_width; + } + k + }) + } + EntityKind::Spline(sp) => { + let pts = sp.tessellate(1e-3); + let n = pts.len(); + let mid = pts.get(n / 2).copied()?; + let next = pts.get((n / 2 + 1).min(n.saturating_sub(1))).copied()?; + let left = (next - mid).cross(side - mid) > 0.0; + let off = offset_points(&pts, if left { dist } else { -dist }, sp.closed); + // A closed spline stays closed (periodic), smooth across its start point. + Some(EntityKind::Spline(cadcraft_geom::Spline::from_fit(&decimate(&off, 40), sp.closed))) + } + EntityKind::XLine(r) | EntityKind::Ray(r) => { + let ln = Line::new(r.base.xy(), r.base.xy() + r.dir.xy()); + let sgn = if ln.side(side) >= 0.0 { 1.0 } else { -1.0 }; + let o = ln.offset(dist * sgn); + let rl = cadcraft_doc::RayLine { base: v3(o.a), dir: r.dir }; + Some(if matches!(k, EntityKind::XLine(_)) { EntityKind::XLine(rl) } else { EntityKind::Ray(rl) }) + } + _ => None, + } +} + +fn decimate(pts: &[Vec2], max: usize) -> Vec { + if pts.len() <= max { + return pts.to_vec(); + } + let step = pts.len() as f64 / max as f64; + let mut v: Vec = (0..max).filter_map(|i| pts.get((i as f64 * step) as usize).copied()).collect(); + if let Some(l) = pts.last() { + v.push(*l); + } + v +} + +fn offset_points(pts: &[Vec2], d: f64, closed: bool) -> Vec { + let n = pts.len(); + let mut out = Vec::with_capacity(n); + for i in 0..n { + let p = pts.get(i).copied().unwrap_or_default(); + let prev = if i > 0 { + pts.get(i - 1) + } else if closed { + pts.get(n.saturating_sub(2)) + } else { + None + }; + let next = if i + 1 < n { + pts.get(i + 1) + } else if closed { + pts.get(1) + } else { + None + }; + let t = match (prev, next) { + (Some(a), Some(b)) => (*b - *a).normalized(), + (None, Some(b)) => (*b - p).normalized(), + (Some(a), None) => (p - *a).normalized(), + _ => Vec2::X, + }; + out.push(p + t.perp() * d); + } + out +} + +/// Offset a polyline: offset each segment, then join neighbours at their intersections. +pub(crate) fn offset_polyline(pl: &Polyline, d: f64) -> Option> { + let segs: Vec = pl.segments().iter().filter_map(|s| s.offset(d)).collect(); + if segs.is_empty() { + return None; + } + let n = segs.len(); + // Where each offset segment starts after joining it to the previous one. + let mut starts: Vec = Vec::with_capacity(n); + for i in 0..n { + let s = segs.get(i)?; + starts.push(if i == 0 && !pl.closed { + s.start() + } else { + let prev = segs.get((i + n - 1) % n)?; + join_point(prev, s).unwrap_or(s.start()) + }); + } + let mut verts: Vec = Vec::new(); + for i in 0..n { + let s = segs.get(i)?; + let start = *starts.get(i)?; + let end = if i + 1 < n { + *starts.get(i + 1)? + } else if pl.closed { + *starts.first()? + } else { + s.end() + }; + let bulge = match s { + Segment::Line(_) => 0.0, + Segment::Arc { arc, ccw } => { + // The joined endpoints may have moved along the circle: take the sweep between them. + let sweep = if start.near(end, 1e-9) { + arc.sweep() + } else if *ccw { + ccw_sweep(arc.center.angle_to(start), arc.center.angle_to(end)) + } else { + ccw_sweep(arc.center.angle_to(end), arc.center.angle_to(start)) + }; + cadcraft_geom::arc_to_bulge(if *ccw { sweep } else { -sweep }) + } + }; + verts.push(PolyVertex { p: start, bulge, start_width: 0.0, end_width: 0.0 }); + } + if !pl.closed { + verts.push(PolyVertex::new(segs.last()?.end())); + } + Some(verts) +} + +fn join_point(a: &Segment, b: &Segment) -> Option { + if a.end().near(b.start(), 1e-9) { + return Some(b.start()); + } + let hits = intersect_ext(a, b, true); + hits.into_iter().min_by(|x, y| x.dist(a.end()).total_cmp(&y.dist(a.end()))) +} + +pub(super) fn run_offset(s: &mut Session, p: &Value) -> Result { + let h = targets(s, p)?.first().copied().ok_or_else(|| bad("offset", "`handle` is required"))?; + let dist = f64_req("offset", p, "distance")?; + let side = point_req("offset", p, "side")?; + let e = s.doc()?.entity(h).map(|e| (**e).clone()).ok_or_else(|| bad("offset", "no such object"))?; + let k = offset_kind(&e.kind, dist.abs(), side).ok_or_else(|| bad("offset", "cannot offset that object"))?; + let current_layer = match str_param(p, "layer").map(str::to_ascii_lowercase).as_deref() { + None | Some("source") => false, + Some("current") => true, + Some(_) => return Err(bad("offset", "`layer` is \"source\" or \"current\"")), + }; + let nh = add_offset(s, &e, k, current_layer, bool_or(p, "erase", false))?; + Ok(json!({ "handle": nh.hex() })) +} + +/// Add the offset copy `k` of `src` (on the current layer instead of the source's when +/// `current_layer`), then erase the source when `erase` and its layer is not locked. +fn add_offset(s: &mut Session, src: &Entity, k: EntityKind, current_layer: bool, erase: bool) -> Result { + let space = s.space(); + let d = s.doc_mut()?; + let mut common = src.common.clone(); + if current_layer { + common.layer = d.header.str("CLAYER", "0"); + } + let nh = d.new_handle(); + if let Some(st) = d.space_mut(&space) { + st.push(Entity { handle: nh, common, kind: k }); + } + let locked = d.layer(&src.common.layer).is_some_and(|l| l.locked); + if erase && !locked { + d.remove_entity(src.handle); + } + Ok(nh) +} + +/// An OFFSET option prompt opened from "Specify offset distance". +#[derive(Clone, Copy, PartialEq)] +enum OffsetAsk { + Erase, + Layer, +} + +pub(super) struct OffsetM { + dist: Option, + distance_first: Option, + through: bool, + picked: Option, + ask: Option, + /// Multiple mode: each side point offsets the newest copy again, until Enter. + multiple: bool, + /// Undo: the drawing before each offset, with the object picked then. + undo: Vec<(std::sync::Arc, Option)>, +} + +impl OffsetM { + pub(super) fn new(s: &Session) -> Self { + let _ = s; + OffsetM { dist: None, distance_first: None, through: false, picked: None, ask: None, multiple: false, undo: Vec::new() } + } + + fn set_distance(&mut self, s: &mut Session, distance: f64) -> Result<()> { + if !(distance.is_finite() && distance > 0.0) { + return Err(EngineError::Other("Requires a positive distance.".into())); + } + s.doc_mut()?.header.set_f64("OFFSETDIST", distance); + self.dist = Some(distance); + self.distance_first = None; + Ok(()) + } +} + +/// OFFSET's remembered options: erase the source (OFFSETERASE) and put copies on the current +/// layer instead of the source's (kept in the drawing header beside it). +fn offset_options(s: &Session) -> (bool, bool) { + s.doc().map(|d| (d.header.i64("OFFSETERASE", 0) != 0, d.header.i64("OFFSETLAYER", 0) != 0)).unwrap_or((false, false)) +} + +impl Interactive for OffsetM { + fn name(&self) -> &'static str { + "OFFSET" + } + /// Starts OFFSET, keeping a single preselected offsettable object (issue #1610) so the prompt + /// sequence goes distance then side instead of asking for the object again; any other + /// selection is cleared and the object loop asks for it. With PICKFIRST off the selection is + /// likewise ignored and cleared. + fn begin(&mut self, s: &mut Session) -> Result { + // Exactly one offsettable object selected before the command is taken at once, so only the + // side is asked for; the object loop stays for the ones after it (issue #1610). A multiple + // selection is left for the select prompt, as AutoCAD does. + let pre = s.selection(); + if s.settings.pickfirst + && let [h] = pre[..] + && s.doc()?.entity(h).is_some_and(|e| offsettable(&e.kind)) + { + self.picked = Some(h); + s.set_selection(vec![h]); + } else { + s.set_selection(Vec::new()); + } + let (erase, current) = offset_options(s); + s.echo(format!( + "Current settings: Erase source={} Layer={} OFFSETGAPTYPE=0", + if erase { "Yes" } else { "No" }, + if current { "Current" } else { "Source" } + )); + Ok(Step::Continue) + } + fn prompt(&self, s: &Session) -> Prompt { + let (erase, current) = offset_options(s); + match self.ask { + Some(OffsetAsk::Erase) => { + return Prompt::new("Erase source object after offsetting?", Accept::TEXT).kw(&["Yes", "No"]).default(if erase { + "Yes" + } else { + "No" + }); + } + Some(OffsetAsk::Layer) => { + return Prompt::new("Enter layer option for offset objects", Accept::TEXT).kw(&["Current", "Source"]).default(if current { + "Current" + } else { + "Source" + }); + } + None => {} + } + let last = s.doc().map(|d| d.header.f64("OFFSETDIST", -1.0)).unwrap_or(-1.0); + if self.dist.is_none() + && !self.through + && let Some(base) = self.distance_first + { + return Prompt::new("Specify second point", Accept::POINT).base(base); + } + match (self.dist.is_some() || self.through, self.picked) { + (false, _) => Prompt::new("Specify offset distance", Accept::POINT_OR_NUMBER).kw(&["Through", "Erase", "Layer"]).default(if last < 0.0 { + "Through".to_string() + } else { + format!("{last:.4}") + }), + (true, None) => Prompt::new("Select object to offset", Accept::POINT).kw(&["Exit", "Undo"]).default("Exit"), + (true, Some(_)) if self.multiple => { + let msg = if self.through { "Specify through point" } else { "Specify point on side to offset" }; + Prompt::new(msg, Accept::POINT).kw(&["Exit", "Undo"]).default("next object") + } + (true, Some(_)) if self.through => Prompt::new("Specify through point", Accept::POINT).kw(&["Exit", "Multiple", "Undo"]).default("Exit"), + (true, Some(_)) => Prompt::new("Specify point on side to offset", Accept::POINT).kw(&["Exit", "Multiple", "Undo"]).default("Exit"), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + if let Some(ask) = self.ask { + let word = match &i { + Input::Keyword(k) | Input::Text(k) => k.trim().to_ascii_lowercase(), + Input::Enter => String::new(), + _ => return Ok(Step::Continue), + }; + let (key, yes, no) = match ask { + OffsetAsk::Erase => ("OFFSETERASE", "y", "n"), + OffsetAsk::Layer => ("OFFSETLAYER", "c", "s"), + }; + let v = if word.is_empty() { + None + } else if word.starts_with(yes) { + Some(1) + } else if word.starts_with(no) { + Some(0) + } else { + return Err(EngineError::Other("Invalid option keyword.".into())); + }; + if let Some(v) = v { + s.doc_mut()?.header.set_i64(key, v); + } + self.ask = None; + return Ok(Step::Continue); + } + if self.dist.is_none() && !self.through { + match i { + Input::Text(t) => { + let d = number(&t).ok_or_else(|| EngineError::Other("Requires a positive distance.".into()))?; + self.set_distance(s, d)?; + } + Input::Point(p) => { + if !p.is_finite() { + return Err(EngineError::Other("Invalid point.".into())); + } + match self.distance_first { + Some(base) => self.set_distance(s, base.dist(p))?, + None => self.distance_first = Some(p), + } + } + Input::Keyword(k) if k == "Through" => { + self.through = true; + self.distance_first = None; + } + Input::Keyword(k) if k == "Erase" => self.ask = Some(OffsetAsk::Erase), + Input::Keyword(k) if k == "Layer" => self.ask = Some(OffsetAsk::Layer), + Input::Enter => { + self.distance_first = None; + let last = s.doc()?.header.f64("OFFSETDIST", -1.0); + if last > 0.0 { + self.dist = Some(last); + } else { + self.through = true; + } + } + _ => {} + } + return Ok(Step::Continue); + } + match (self.picked, i) { + (_, Input::Keyword(k)) if k == "Exit" => { + s.set_selection(Vec::new()); + Ok(Step::Done) + } + (_, Input::Keyword(k)) if k == "Undo" => { + match self.undo.pop() { + Some((doc, picked)) => { + // Settings changed meanwhile stay. + let header = s.doc()?.header.clone(); + s.state_mut()?.doc = doc; + s.doc_mut()?.header = header; + self.picked = if self.multiple { picked } else { None }; + if self.picked.is_none() { + self.multiple = false; + } + s.set_selection(self.picked.into_iter().collect()); + } + None => s.echo("Nothing to undo."), + } + Ok(Step::Continue) + } + (Some(_), Input::Keyword(k)) if k == "Multiple" => { + self.multiple = true; + Ok(Step::Continue) + } + (Some(_), Input::Enter) if self.multiple => { + // : back to selecting. + self.multiple = false; + self.picked = None; + s.set_selection(Vec::new()); + Ok(Step::Continue) + } + (_, Input::Enter) => { + s.set_selection(Vec::new()); + Ok(Step::Done) + } + (None, Input::Point(p)) => { + let ap = s.pixel_size() * s.settings.pickbox.max(1.0) * 1.5; + let space = s.space(); + match crate::select::pick(s.doc()?, &space, p, ap) { + Some(h) => { + self.picked = Some(h); + s.set_selection(vec![h]); + } + None => s.echo("*Invalid selection*"), + } + Ok(Step::Continue) + } + (Some(h), Input::Point(p)) => { + let e = s.doc()?.entity(h).map(|e| (**e).clone()); + let mut made = None; + if let Some(e) = e { + let dist = match self.dist { + Some(d) => d, + None => { + let polys = crate::select::hit_polylines(s.doc()?, &e, 1e-4); + polys + .iter() + .flat_map(|pl| pl.windows(2).filter_map(|w| Some(Line::new(*w.first()?, *w.get(1)?).dist(p)))) + .fold(f64::INFINITY, f64::min) + } + }; + match offset_kind(&e.kind, dist, p) { + Some(k) => { + let before = s.state()?.doc.clone(); + let (erase, current) = offset_options(s); + made = Some(add_offset(s, &e, k, current, erase)?); + self.undo.push((before, Some(h))); + } + None => s.echo("Cannot offset that object."), + } + } + // Multiple mode carries on from the new copy. + self.picked = if self.multiple { made.or(self.picked) } else { None }; + s.set_selection(self.picked.into_iter().collect()); + Ok(Step::Continue) + } + _ => Ok(Step::Continue), + } + } + fn preview(&self, s: &Session, c: Vec2) -> Vec { + if let Some(base) = self.distance_first + && c.is_finite() + { + return vec![line(base, c)]; + } + let (Some(h), Ok(d)) = (self.picked, s.doc()) else { return Vec::new() }; + let Some(e) = d.entity(h) else { return Vec::new() }; + let dist = match self.dist { + Some(x) => x, + None => crate::select::entity_distance(d, e, c, 1e-3), + }; + offset_kind(&e.kind, dist, c).into_iter().collect() + } +} diff --git a/crates/engine/src/cmd/modify/select_then.rs b/crates/engine/src/cmd/modify/select_then.rs new file mode 100644 index 000000000..75264990c --- /dev/null +++ b/crates/engine/src/cmd/modify/select_then.rs @@ -0,0 +1,593 @@ +use super::break_join::join; +use super::explode::explode; +use super::transform::{erase, transform_entities}; +use super::*; + +// ---------------- interactive ---------------- + +#[derive(Clone, Copy, PartialEq)] +pub(super) enum Op { + Erase, + Move, + Copy, + Rotate, + Scale, + Mirror, + Explode, + Join, +} + +/// Select objects, then collect the points/values the operation needs. +pub(super) struct SelectThen { + op: Op, + sel: SelectPhase, + objs: Vec, + pts: Vec, + reference: bool, + /// ROTATE Reference: the reference angle (radians), once known. Its first point, and the + /// first of the two new-angle points, are kept in `ref_from`. + ref_angle: Option, + /// ROTATE Reference: the new angle is given by two points (the `Points` option). + ref_points: bool, + /// SCALE Reference: first point of a reference length given by two points. + ref_from: Option, + /// SCALE Reference: the reference length, once known. + ref_len: Option, + copy_mode: bool, + ask_erase: bool, + /// JOIN of a single arc or elliptical arc: the prompt asks for arcs to join to it, or cLose. + join_source: Option, +} + +impl SelectThen { + pub(super) fn new(op: Op) -> Self { + SelectThen { + op, + sel: SelectPhase::default(), + objs: Vec::new(), + pts: Vec::new(), + reference: false, + ref_angle: None, + ref_points: false, + ref_from: None, + ref_len: None, + copy_mode: false, + ask_erase: false, + join_source: None, + } + } + fn apply(&mut self, s: &mut Session) -> Result { + // Immediate operations once selection is done. + match self.op { + Op::Erase => { + let n = erase(s, &self.objs)?; + let _ = n; + Ok(Step::Done) + } + Op::Explode => { + explode(s, &self.objs)?; + Ok(Step::Done) + } + Op::Join => { + if let [h] = self.objs[..] + && s.doc()?.entity(h).is_some_and(|e| crate::cmd::modify2::closed_kind(&e.kind).is_some()) + { + self.join_source = Some(h); + return Ok(Step::Continue); + } + self.join_now(s) + } + _ => Ok(Step::Continue), + } + } + + /// JOIN the selected objects and report what they became. + fn join_now(&mut self, s: &mut Session) -> Result { + match join(s, &self.objs) { + Ok(h) => { + // Objects outside the joined chain are kept, so count only the ones JOIN consumed + // (and the source object an ellipse or spline join keeps). + let d = s.doc()?; + let n = self.objs.iter().filter(|o| d.entity(**o).is_none() || h == Some(**o)).count(); + let what = match h.and_then(|h| d.entity(h)).map(|e| &e.kind) { + Some(EntityKind::Spline(_)) => "spline", + Some(EntityKind::Ellipse(_)) => "ellipse", + _ => "polyline", + }; + s.echo(format!("{n} objects joined into 1 {what}")) + } + Err(e) => s.echo(e.to_string()), + } + Ok(Step::Done) + } + + /// JOIN of a single arc or elliptical arc: more arcs picked join to it on Enter, cLose makes + /// it its circle or full ellipse. + fn join_source_input(&mut self, s: &mut Session, src: Handle, i: Input) -> Result { + match i { + Input::Keyword(k) if k == "cLose" => { + let ellipse = matches!(s.doc()?.entity(src).map(|e| &e.kind), Some(EntityKind::Ellipse(_))); + crate::cmd::modify2::join_close(s, &[src])?; + s.echo(if ellipse { "The elliptical arc is now an ellipse." } else { "The arc is now a circle." }); + Ok(Step::Done) + } + Input::Point(p) => { + if let Some(h) = crate::cmd::curves::pick_at(s, p) + && !self.objs.contains(&h) + { + self.objs.push(h); + } + Ok(Step::Continue) + } + Input::Enter => self.join_now(s), + _ => Ok(Step::Continue), + } + } + + /// SCALE Reference: reference length (typed, or two points), then new length (typed, or a + /// point measured from the base point). The scale factor is new length / reference length. + fn scale_reference(&mut self, s: &mut Session, i: Input) -> Result { + let Some(base) = self.pts.first().copied() else { return Ok(Step::Done) }; + match (self.ref_len, self.ref_from, i) { + (None, None, Input::Point(p)) => { + self.ref_from = Some(p); + Ok(Step::Continue) + } + (None, None, Input::Text(t)) => { + self.ref_len = Some(require_length(number(&t))?); + Ok(Step::Continue) + } + (None, None, Input::Enter) => { + self.ref_len = Some(1.0); + Ok(Step::Continue) + } + (None, Some(a), Input::Point(p)) => { + self.ref_len = Some(require_length(Some(a.dist(p)))?); + Ok(Step::Continue) + } + (Some(r), _, Input::Point(p)) => self.scale_by(s, base, base.dist(p) / r), + (Some(r), _, Input::Text(t)) => { + let n = require_length(number(&t))?; + self.scale_by(s, base, n / r) + } + (_, _, Input::Enter) => Ok(Step::Done), + _ => Ok(Step::Continue), + } + } + + /// ROTATE Reference: reference angle (typed, or two points), then the new angle (typed, a + /// point measured from the base point, or two points after `Points`). The objects turn by the + /// new angle minus the reference angle. + fn rotate_reference(&mut self, s: &mut Session, i: Input) -> Result { + let Some(base) = self.pts.first().copied() else { return Ok(Step::Done) }; + let au = s.angle_settings(); + let angle = |t: &str| au.direction(t).filter(|a| a.is_finite()).ok_or_else(|| EngineError::Other("Requires an angle or point.".into())); + let two_points = |a: Vec2, p: Vec2| { + if a.near(p, 1e-12) { Err(EngineError::Other("The two points must differ.".into())) } else { Ok(a.angle_to(p)) } + }; + let Some(r) = self.ref_angle else { + match (self.ref_from, i) { + (None, Input::Point(p)) => self.ref_from = Some(p), + (None, Input::Text(t)) => self.ref_angle = Some(angle(&t)?), + (None, Input::Enter) => self.ref_angle = Some(0.0), + (Some(a), Input::Point(p)) => { + self.ref_angle = Some(two_points(a, p)?); + self.ref_from = None; + } + _ => {} + } + return Ok(Step::Continue); + }; + let new = match (self.ref_points, self.ref_from, i) { + (false, _, Input::Keyword(k)) if k == "Points" => { + self.ref_points = true; + return Ok(Step::Continue); + } + (false, _, Input::Point(p)) => base.angle_to(p), + (false, _, Input::Text(t)) => angle(&t)?, + (false, _, Input::Enter) => 0.0, + (true, None, Input::Point(p)) => { + self.ref_from = Some(p); + return Ok(Step::Continue); + } + (true, Some(a), Input::Point(p)) => two_points(a, p)?, + _ => return Ok(Step::Continue), + }; + transform_entities(s, &self.objs, &Mat3::rotate_about(base, new - r), self.copy_mode)?; + s.set_selection(Vec::new()); + Ok(Step::Done) + } + + /// The prompt while ROTATE Reference collects its angles. + fn rotate_reference_prompt(&self) -> Prompt { + match (self.ref_angle, self.ref_points, self.ref_from) { + (None, _, None) => Prompt::new("Specify the reference angle", Accept::POINT_OR_NUMBER).default("0"), + (None, _, Some(p)) | (Some(_), true, Some(p)) => Prompt::new("Specify second point", Accept::POINT).base(p), + (Some(_), false, _) => { + Prompt::new("Specify the new angle", Accept::POINT_OR_NUMBER).kw(&["Points"]).default("0").base_opt(self.pts.first().copied()) + } + (Some(_), true, None) => Prompt::new("Specify first point", Accept::POINT), + } + } + + /// Rubber band while ROTATE Reference collects its angles: the objects turned to the cursor + /// once the reference angle is known, else the line from the first point. + fn rotate_reference_preview(&self, s: &Session, c: Vec2) -> Vec { + let (Some(base), Ok(d)) = (self.pts.first().copied(), s.doc()) else { return Vec::new() }; + match (self.ref_angle, self.ref_points, self.ref_from) { + (Some(r), false, _) => { + let m = Mat3::rotate_about(base, base.angle_to(c) - r); + let mut out: Vec = self + .objs + .iter() + .take(500) + .filter_map(|h| d.entity(*h)) + .map(|e| { + let mut k = e.kind.clone(); + k.transform(&m); + k + }) + .collect(); + out.push(line(base, c)); + out + } + (_, _, Some(a)) => vec![line(a, c)], + _ => Vec::new(), + } + } + + fn scale_by(&mut self, s: &mut Session, base: Vec2, f: f64) -> Result { + let f = require_length(Some(f))?; + // The next SCALE offers this factor. + s.last_used.scale_factor = f; + transform_entities(s, &self.objs, &Mat3::scale_about(base, f), self.copy_mode)?; + s.set_selection(Vec::new()); + Ok(Step::Done) + } +} + +/// Objects whose text MIRROR keeps readable instead of mirroring it with the geometry. +fn keeps_reading_when_mirrored(k: &EntityKind) -> bool { + match k { + EntityKind::Insert(i) => !i.attribs.is_empty(), + k => matches!( + k, + EntityKind::Text(_) + | EntityKind::AttDef(_) + | EntityKind::MText(_) + | EntityKind::Dimension(_) + | EntityKind::MLeader(_) + | EntityKind::Table(_) + | EntityKind::Tolerance(_) + ), + } +} + +/// Text objects transformed for each frame of a MIRROR preview (on the CPU); more are not drawn. +const MAX_MIRROR_TEXT_PREVIEW: usize = 1000; + +/// A finite length greater than zero (rejects 0, negatives, NaN and infinity). +fn positive_length(v: Option) -> Option { + v.filter(|f| f.is_finite() && *f > 1e-12) +} + +/// [`positive_length`], or the error that makes the prompt ask again. +fn require_length(v: Option) -> Result { + positive_length(v).ok_or_else(|| EngineError::Other("Requires a positive number.".into())) +} + +impl Interactive for SelectThen { + fn name(&self) -> &'static str { + match self.op { + Op::Erase => "ERASE", + Op::Move => "MOVE", + Op::Copy => "COPY", + Op::Rotate => "ROTATE", + Op::Scale => "SCALE", + Op::Mirror => "MIRROR", + Op::Explode => "EXPLODE", + Op::Join => "JOIN", + } + } + fn begin(&mut self, s: &mut Session) -> Result { + self.sel = SelectPhase::begin(s); + if self.sel.done { + self.objs = self.sel.picked.clone(); + return self.apply(s); + } + Ok(Step::Continue) + } + fn prompt(&self, s: &Session) -> Prompt { + if !self.sel.done { + return self.sel.prompt(); + } + if self.ask_erase { + return Prompt::new("Erase source objects?", Accept::TEXT).kw(&["Yes", "No"]).default("No"); + } + if let Some(src) = self.join_source { + let ellipse = s.doc().is_ok_and(|d| matches!(d.entity(src).map(|e| &e.kind), Some(EntityKind::Ellipse(_)))); + let msg = if ellipse { "Select elliptical arcs to join to source" } else { "Select arcs to join to source" }; + return Prompt::new(msg, Accept::POINT).kw(&["cLose"]); + } + let k = self.pts.len(); + let base = self.pts.first().copied(); + if self.op == Op::Scale && self.reference { + return match (self.ref_len, self.ref_from) { + (Some(_), _) => Prompt::new("Specify new length", Accept::POINT_OR_NUMBER).base_opt(base), + (None, Some(p)) => Prompt::new("Specify second point", Accept::POINT).base(p), + (None, None) => Prompt::new("Specify reference length", Accept::POINT_OR_NUMBER).default("1"), + }; + } + if self.op == Op::Rotate && self.reference { + return self.rotate_reference_prompt(); + } + match (self.op, k) { + (Op::Move | Op::Copy, 0) => Prompt::new("Specify base point", Accept::POINT).kw(&["Displacement"]), + (Op::Move, _) => Prompt::new("Specify second point or ", Accept::POINT).base_opt(base), + (Op::Copy, _) => Prompt::new("Specify second point", Accept::POINT).kw(&["Array", "Exit", "Undo"]).base_opt(base), + (Op::Rotate | Op::Scale, 0) => Prompt::new("Specify base point", Accept::POINT), + // ROTATE and SCALE offer the angle/factor used last time. + (Op::Rotate, _) => { + let (au, ap) = s.doc().map(|d| (d.header.i64("AUNITS", 0), d.header.i64("AUPREC", 0))).unwrap_or((0, 0)); + // Offered as it would be typed: clockwise is positive when ANGDIR is 1. + let a = if s.angle_settings().clockwise { -s.last_used.rotate_angle } else { s.last_used.rotate_angle }; + Prompt::new("Specify rotation angle", Accept::POINT_OR_NUMBER) + .kw(&["Copy", "Reference"]) + .default(crate::units::format_angle(a, au, ap)) + .base_opt(base) + } + (Op::Scale, _) => Prompt::new("Specify scale factor", Accept::POINT_OR_NUMBER) + .kw(&["Copy", "Reference"]) + .default(format!("{:.4}", s.last_used.scale_factor)) + .base_opt(base), + (Op::Mirror, 0) => Prompt::new("Specify first point of mirror line", Accept::POINT), + (Op::Mirror, _) => Prompt::new("Specify second point of mirror line", Accept::POINT).base_opt(base), + _ => Prompt::new("", Accept::POINT), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + if !self.sel.done { + match self.sel.feed(s, &i)? { + SelOutcome::More => return Ok(Step::Continue), + SelOutcome::Empty => return Ok(Step::Done), + SelOutcome::Done(hs) => { + self.objs = hs; + return self.apply(s); + } + } + } + if let Some(src) = self.join_source { + return self.join_source_input(s, src, i); + } + if self.ask_erase { + let yes = matches!(&i, Input::Keyword(k) if k == "Yes") || matches!(&i, Input::Text(t) if t.trim().to_ascii_lowercase().starts_with('y')); + let (a, b) = (self.pts[0], self.pts[1]); + transform_entities(s, &self.objs, &Mat3::mirror(a, b), !yes)?; + s.set_selection(Vec::new()); + return Ok(Step::Done); + } + let k = self.pts.len(); + if self.op == Op::Scale && self.reference { + return self.scale_reference(s, i); + } + if self.op == Op::Rotate && self.reference { + return self.rotate_reference(s, i); + } + match (self.op, k, i) { + (_, _, Input::Keyword(kw)) if kw == "Copy" => { + self.copy_mode = true; + s.echo("Rotating/scaling a copy of the selected objects."); + Ok(Step::Continue) + } + (Op::Scale | Op::Rotate, 1, Input::Keyword(kw)) if kw == "Reference" => { + self.reference = true; + Ok(Step::Continue) + } + (Op::Copy, _, Input::Keyword(kw)) if kw == "Exit" => { + s.set_selection(Vec::new()); + Ok(Step::Done) + } + (_, 0, Input::Point(p)) => { + self.pts.push(p); + Ok(Step::Continue) + } + (Op::Move, 1, Input::Point(p)) => { + let d = p - self.pts[0]; + transform_entities(s, &self.objs, &Mat3::translate(d), false)?; + s.set_selection(Vec::new()); + Ok(Step::Done) + } + (Op::Move, 1, Input::Enter) => { + let d = self.pts[0]; + transform_entities(s, &self.objs, &Mat3::translate(d), false)?; + s.set_selection(Vec::new()); + Ok(Step::Done) + } + (Op::Copy, _, Input::Point(p)) => { + let d = p - self.pts[0]; + transform_entities(s, &self.objs, &Mat3::translate(d), true)?; + Ok(Step::Continue) + } + (Op::Copy, _, Input::Enter) => { + s.set_selection(Vec::new()); + Ok(Step::Done) + } + (Op::Rotate, 1, inp @ (Input::Point(_) | Input::Text(_) | Input::Enter)) => { + let a = match inp { + Input::Point(p) => self.pts[0].angle_to(p), + Input::Text(t) => s + .angle_settings() + .rotation(&t) + .filter(|a| a.is_finite()) + .ok_or_else(|| EngineError::Other("Requires an angle or point.".into()))?, + _ => s.last_used.rotate_angle, + }; + s.last_used.rotate_angle = a; + transform_entities(s, &self.objs, &Mat3::rotate_about(self.pts[0], a), self.copy_mode)?; + s.set_selection(Vec::new()); + Ok(Step::Done) + } + (Op::Scale, 1, inp @ (Input::Point(_) | Input::Text(_) | Input::Enter)) => { + let f = match inp { + Input::Point(p) => self.pts[0].dist(p), + Input::Text(t) => { + number(&t).filter(|f| f.is_finite() && *f > 0.0).ok_or_else(|| EngineError::Other("Requires a positive number.".into()))? + } + _ => s.last_used.scale_factor, + }; + if f > 1e-12 && f.is_finite() { + s.last_used.scale_factor = f; + transform_entities(s, &self.objs, &Mat3::scale_about(self.pts[0], f), self.copy_mode)?; + } + s.set_selection(Vec::new()); + Ok(Step::Done) + } + (Op::Mirror, 1, Input::Point(p)) => { + if p.near(self.pts[0], 1e-12) { + return Err(EngineError::Other("Mirror line points must differ.".into())); + } + self.pts.push(p); + self.ask_erase = true; + Ok(Step::Continue) + } + (_, _, Input::Enter) => Ok(Step::Done), + _ => Ok(Step::Continue), + } + } + fn preview(&self, s: &Session, c: Vec2) -> Vec { + if self.op == Op::Rotate && self.reference { + return self.rotate_reference_preview(s, c); + } + if !self.sel.done { + return Vec::new(); + } + let Some(base) = self.pts.first().copied() else { return Vec::new() }; + let m = match self.op { + Op::Move | Op::Copy => Mat3::translate(c - base), + Op::Rotate => Mat3::rotate_about(base, base.angle_to(c)), + Op::Scale if !self.reference => Mat3::scale_about(base, base.dist(c).max(1e-9)), + Op::Scale => match self.ref_len.and_then(|r| positive_length(Some(base.dist(c) / r))) { + Some(f) => Mat3::scale_about(base, f), + None if self.ref_len.is_some() => return Vec::new(), + None => return self.ref_from.map(|a| vec![line(a, c)]).unwrap_or_default(), + }, + Op::Mirror if !self.ask_erase => Mat3::mirror(base, c), + _ => return Vec::new(), + }; + let Ok(d) = s.doc() else { return Vec::new() }; + let mut out: Vec = self + .objs + .iter() + .take(500) + .filter_map(|h| d.entity(*h)) + .map(|e| { + let mut k = e.kind.clone(); + k.transform(&m); + k + }) + .collect(); + out.push(line(base, c)); + out + } + fn drag(&self, s: &Session, c: Vec2) -> Option { + if (self.op == Op::Rotate && self.reference) || !self.sel.done { + return None; + } + let base = self.pts.first().copied()?; + let matrix = match self.op { + Op::Move | Op::Copy => Mat3::translate(c - base), + Op::Rotate => Mat3::rotate_about(base, base.angle_to(c)), + Op::Scale if !self.reference => Mat3::scale_about(base, base.dist(c).max(1e-9)), + Op::Scale => Mat3::scale_about(base, self.ref_len.and_then(|r| positive_length(Some(base.dist(c) / r)))?), + Op::Mirror if !self.ask_erase => Mat3::mirror(base, c), + _ => return None, + }; + let mut extra = vec![line(base, c)]; + if self.op != Op::Mirror { + return Some(crate::prompt::Drag { handles: self.objs.clone(), matrix, extra, clipboard: false }); + } + // Mirrored text keeps reading left to right (MIRRTEXT 0), which the matrix alone can't + // draw: those objects are transformed one by one, the rest is mirrored on the GPU. + let d = s.doc().ok()?; + let mut handles = Vec::with_capacity(self.objs.len()); + let mut texts = 0; + for h in &self.objs { + let Some(e) = d.entity(*h) else { continue }; + if !keeps_reading_when_mirrored(&e.kind) { + handles.push(*h); + } else if texts < MAX_MIRROR_TEXT_PREVIEW { + texts += 1; + let mut k = e.kind.clone(); + k.transform(&matrix); + extra.push(k); + } + } + Some(crate::prompt::Drag { handles, matrix, extra, clipboard: false }) + } +} + +/// MOVE and COPY: [`SelectThen`], plus the Displacement option at the base point prompt +/// (the keyword, or Enter): the typed point is the displacement itself. +pub(super) struct DisplaceM { + inner: SelectThen, + displacement: bool, +} + +impl DisplaceM { + pub(super) fn new(op: Op) -> Self { + DisplaceM { inner: SelectThen::new(op), displacement: false } + } + /// At "Specify base point": objects selected, no point yet. + fn at_base(&self) -> bool { + self.inner.sel.done && self.inner.pts.is_empty() && !self.displacement + } +} + +impl Interactive for DisplaceM { + fn name(&self) -> &'static str { + self.inner.name() + } + fn begin(&mut self, s: &mut Session) -> Result { + self.inner.begin(s) + } + fn prompt(&self, s: &Session) -> Prompt { + if self.displacement { + return Prompt::new("Specify displacement", Accept::POINT).default("0,0"); + } + if self.at_base() { + return Prompt::new("Specify base point", Accept::POINT).kw(&["Displacement"]).default("Displacement"); + } + self.inner.prompt(s) + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + if self.displacement { + let d = match i { + Input::Point(p) => p, + Input::Enter => Vec2::ZERO, + _ => return Ok(Step::Continue), + }; + if d != Vec2::ZERO { + transform_entities(s, &self.inner.objs, &Mat3::translate(d), self.inner.op == Op::Copy)?; + } + s.set_selection(Vec::new()); + return Ok(Step::Done); + } + if self.at_base() && (matches!(&i, Input::Enter) || matches!(&i, Input::Keyword(k) if k == "Displacement")) { + self.displacement = true; + return Ok(Step::Continue); + } + self.inner.input(s, i) + } + fn preview(&self, s: &Session, c: Vec2) -> Vec { + if self.displacement { + return Vec::new(); + } + self.inner.preview(s, c) + } + fn drag(&self, s: &Session, c: Vec2) -> Option { + if self.displacement { + return None; + } + self.inner.drag(s, c) + } +} diff --git a/crates/engine/src/cmd/modify/stretch.rs b/crates/engine/src/cmd/modify/stretch.rs new file mode 100644 index 000000000..5aa91bbef --- /dev/null +++ b/crates/engine/src/cmd/modify/stretch.rs @@ -0,0 +1,393 @@ +use super::transform::{delta_of, transform_entities}; +use super::*; + +/// An area whose vertices STRETCH moves: a window's box, or a polygon closed back to its first +/// point. +#[derive(Clone)] +enum StretchArea { + Box(cadcraft_geom::Bounds2), + Polygon(Vec), +} + +impl StretchArea { + fn contains(&self, p: Vec2) -> bool { + match self { + StretchArea::Box(bx) => bx.contains(p), + StretchArea::Polygon(poly) => cadcraft_geom::point_in_polygon(poly, p), + } + } +} + +/// Add to `hs` the visible multilines in `space` with a control vertex inside `area`. A crossing +/// selection tests the drawn element lines, which sit away from the control vertices by the +/// multiline's scale, so a small area around a vertex alone would miss them. +fn add_mline_vertex_hits(d: &cadcraft_doc::Drawing, space: &cadcraft_doc::Space, area: &StretchArea, hs: &mut Vec) { + let Some(store) = d.space(space) else { return }; + for e in store.iter() { + if let EntityKind::MLine(m) = &e.kind + && d.is_visible(e) + && !hs.contains(&e.handle) + && m.vertices.iter().any(|v| area.contains(v.p.xy())) + { + hs.push(e.handle); + } + } +} + +/// Move the vertices of `e` that lie inside any of the areas `wins` by `d` (STRETCH). +fn stretch_entity(e: &mut Entity, wins: &[StretchArea], d: Vec2) { + let inside = |p: Vec2| wins.iter().any(|a| a.contains(p)); + let mv = |p: &mut cadcraft_geom::Vec3| { + if inside(p.xy()) { + p.x += d.x; + p.y += d.y; + } + }; + match &mut e.kind { + EntityKind::Line(l) => { + mv(&mut l.a); + mv(&mut l.b); + } + EntityKind::LwPolyline(pl) => { + for v in &mut pl.vertices { + if inside(v.p) { + v.p += d; + } + } + } + EntityKind::MLine(m) => m.vertices.iter_mut().for_each(|v| mv(&mut v.p)), + EntityKind::Arc(a) => { + // Arcs keep their shape: move when the centre is inside. + if inside(a.center.xy()) { + a.center.x += d.x; + a.center.y += d.y; + } + } + EntityKind::Solid(so) | EntityKind::Trace(so) => so.corners.iter_mut().for_each(mv), + EntityKind::Dimension(dm) => { + for p in [&mut dm.p13, &mut dm.p14, &mut dm.defpt, &mut dm.text_mid] { + mv(p); + } + dm.block = None; + } + k => { + let g = k.grips(); + if g.first().is_some_and(|p| inside(*p)) { + k.transform(&Mat3::translate(d)); + } + } + } +} + +pub(super) fn run_stretch(s: &mut Session, p: &Value) -> Result { + let d = delta_of("stretch", p)?; + let space = s.space(); + let listed = |key: &str, least: usize| -> Result>> { + let Some(pts) = points_param(p, key) else { return Ok(None) }; + if pts.len() < least || pts.len() > crate::select::MAX_MODE_POINTS || pts.iter().any(|q| !q.is_finite()) { + return Err(bad("stretch", format!("`{key}` needs {least} to {} finite points", crate::select::MAX_MODE_POINTS))); + } + Ok(Some(pts)) + }; + // A fence marks no area: the objects it crosses move whole. + if let Some(fence) = listed("fence", 2)? { + let hs = crate::select::select_fence(s.doc()?, &space, &fence); + let moved = transform_entities(s, &hs, &Mat3::translate(d), false)?; + return Ok(json!({ "stretched": moved.len() })); + } + let (mut hs, area) = if let Some(poly) = listed("polygon", 3)? { + (crate::select::select_polygon(s.doc()?, &space, &poly, true), StretchArea::Polygon(poly)) + } else { + let w = points_param(p, "window").ok_or_else(|| bad("stretch", "`window` [[x,y],[x,y]], `polygon` or `fence` is required"))?; + let (Some(a), Some(b)) = (w.first(), w.get(1)) else { return Err(bad("stretch", "window needs 2 corners")) }; + let bx = cadcraft_geom::Bounds2::new(*a, *b); + (crate::select::select_window(s.doc()?, &space, bx, true), StretchArea::Box(bx)) + }; + add_mline_vertex_hits(s.doc()?, &space, &area, &mut hs); + hs.retain(|h| !crate::cmd::curves::is_locked(s, *h)); + let doc = s.doc_mut()?; + let areas = [area]; + for h in &hs { + doc.modify_entity(*h, |e| stretch_entity(e, &areas, d))?; + } + Ok(json!({ "stretched": hs.len() })) +} + +#[derive(Clone, Copy, Default, PartialEq)] +enum StretchPhase { + #[default] + Select, + Base, + Displacement, + Second, +} + +/// STRETCH: "Select objects" repeats until Enter. Crossing windows (implied right to left, or after +/// `C`), windows (left to right, or after `W`) and polygons (`CP`, `WP`) select objects and mark +/// the area whose vertices move; objects picked one at a time (or by a fence, `F`, or +/// All/Last/Previous) move whole. Then the base point or Displacement, and the second point. +#[derive(Default)] +pub(super) struct StretchM { + phase: StretchPhase, + objs: Vec, + /// Objects selected without a window: they move whole. + whole: Vec, + /// The selection windows and polygons: vertices inside any of them move. + windows: Vec, + removing: bool, + /// `C`, `W`, `BOX`, `CP`, `WP` or `F` was typed: the next points are that kind of selection. + mode: Option, + /// First corner of the window being drawn. + corner: Option, + base: Option, +} + +impl StretchM { + fn add(&mut self, s: &mut Session, hs: Vec, whole: bool) { + let n = hs.len(); + for h in hs { + if self.removing { + self.objs.retain(|o| *o != h); + self.whole.retain(|o| *o != h); + } else { + if !self.objs.contains(&h) { + self.objs.push(h); + } + if whole && !self.whole.contains(&h) { + self.whole.push(h); + } + } + } + if n > 0 { + s.echo(format!("{n} found, {} total", self.objs.len())); + } else { + s.echo("0 found"); + } + s.set_selection(self.objs.clone()); + } + + /// Select with a typed mode that has its points. With too few (a fence of one point, a polygon + /// of two) the mode is dropped. + fn finish_mode(&mut self, s: &mut Session, m: SelectMode) -> Result<()> { + let space = s.space(); + let Some(mut hs) = m.select(s.doc()?, &space) else { return Ok(()) }; + let crossing = match (m.kind, m.pts.first().zip(m.pts.get(1))) { + (SelectKind::Crossing | SelectKind::CPolygon, _) => true, + (SelectKind::Box, Some((a, b))) => b.x < a.x, + _ => false, + }; + let area = match (m.kind, m.pts.first().zip(m.pts.get(1))) { + (SelectKind::WPolygon | SelectKind::CPolygon, _) => Some(StretchArea::Polygon(m.pts)), + (SelectKind::Window | SelectKind::Crossing | SelectKind::Box, Some((a, b))) => { + Some(StretchArea::Box(cadcraft_geom::Bounds2::new(*a, *b))) + } + // A fence marks no area: what it crosses moves whole. + _ => None, + }; + let whole = area.is_none(); + if let (Some(a), true, false) = (&area, crossing, self.removing) { + add_mline_vertex_hits(s.doc()?, &space, a, &mut hs); + } + if let (Some(a), false) = (area, self.removing) { + self.windows.push(a); + } + self.add(s, hs, whole); + Ok(()) + } + + fn select(&mut self, s: &mut Session, i: Input) -> Result { + if let Some(mut m) = self.mode.take() { + match &i { + Input::Point(p) => { + if m.push(*p) { + self.finish_mode(s, m)?; + } else { + self.mode = Some(m); + } + return Ok(Step::Continue); + } + Input::Text(t) | Input::Keyword(t) if matches!(t.trim().to_ascii_lowercase().as_str(), "u" | "undo") => { + m.pts.pop(); + self.mode = Some(m); + return Ok(Step::Continue); + } + // Enter ends a fence or closes a polygon. + Input::Enter => { + self.finish_mode(s, m)?; + return Ok(Step::Continue); + } + // Anything else leaves the mode and is read as usual. + _ => {} + } + } + match i { + Input::Point(p) => match self.corner.take() { + Some(c) => { + let bx = cadcraft_geom::Bounds2::new(c, p); + let space = s.space(); + let mut hs = crate::select::select_window(s.doc()?, &space, bx, p.x < c.x); + if !self.removing { + if p.x < c.x { + add_mline_vertex_hits(s.doc()?, &space, &StretchArea::Box(bx), &mut hs); + } + self.windows.push(StretchArea::Box(bx)); + } + self.add(s, hs, false); + } + None => { + let aperture = s.pixel_size() * s.settings.pickbox.max(1.0) * 1.5; + let space = s.space(); + match crate::select::pick(s.doc()?, &space, p, aperture) { + Some(h) => self.add(s, vec![h], true), + None => self.corner = Some(p), + } + } + }, + Input::Pick(hs) => self.add(s, hs, true), + Input::Text(t) | Input::Keyword(t) => { + self.corner = None; + let d = s.doc()?; + let store = d.space(&s.space()); + let word = t.trim().to_ascii_lowercase(); + // `_C`, `_L`: the English name, as scripts write it. + let word = word.strip_prefix('_').unwrap_or(&word).to_string(); + if let Some(m) = SelectMode::from_keyword(&word).filter(|m| m.kind != SelectKind::Group) { + self.mode = Some(m); + return Ok(Step::Continue); + } + let picked = match word.as_str() { + "r" | "remove" => { + self.removing = true; + None + } + "a" | "add" => { + self.removing = false; + None + } + "all" => Some(store.map(|st| st.iter().filter(|e| d.is_visible(e)).map(|e| e.handle).collect()).unwrap_or_default()), + "l" | "last" => Some(store.and_then(|st| st.last()).map(|e| vec![e.handle]).unwrap_or_default()), + "p" | "previous" => Some(s.state()?.previous_selection.clone()), + _ => { + s.echo("*Invalid selection*"); + None + } + }; + if let Some(hs) = picked { + self.add(s, hs, true); + } + } + Input::Enter if self.corner.is_some() => self.corner = None, + Input::Enter if self.objs.is_empty() => return Ok(Step::Done), + Input::Enter => { + s.remember_selection(&self.objs); + self.phase = StretchPhase::Base; + } + _ => {} + } + Ok(Step::Continue) + } + + fn apply(&self, s: &mut Session, d: Vec2) -> Result { + let whole: Vec = self.objs.iter().filter(|h| self.whole.contains(h)).copied().collect(); + transform_entities(s, &whole, &Mat3::translate(d), false)?; + let doc = s.doc_mut()?; + for h in self.objs.iter().filter(|h| !self.whole.contains(h)) { + let locked = doc.entity(*h).and_then(|e| doc.layer(&e.common.layer)).is_some_and(|l| l.locked); + if !locked { + doc.modify_entity(*h, |e| stretch_entity(e, &self.windows, d))?; + } + } + s.set_selection(Vec::new()); + Ok(Step::Done) + } +} + +impl Interactive for StretchM { + fn name(&self) -> &'static str { + "STRETCH" + } + fn begin(&mut self, s: &mut Session) -> Result { + s.set_selection(Vec::new()); + s.echo("Select objects to stretch by crossing-window or crossing-polygon..."); + Ok(Step::Continue) + } + fn prompt(&self, _s: &Session) -> Prompt { + if let (StretchPhase::Select, Some(m)) = (self.phase, &self.mode) { + return m.prompt(); + } + match (self.phase, self.corner) { + (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"), + (StretchPhase::Second, _) => Prompt::new("Specify second point or ", Accept::POINT).base_opt(self.base), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + match (self.phase, i) { + (StretchPhase::Select, i) => self.select(s, i), + (StretchPhase::Base, Input::Point(p)) => { + self.base = Some(p); + self.phase = StretchPhase::Second; + Ok(Step::Continue) + } + (StretchPhase::Base, Input::Enter) => { + self.phase = StretchPhase::Displacement; + Ok(Step::Continue) + } + (StretchPhase::Base, Input::Keyword(k)) if k == "Displacement" => { + self.phase = StretchPhase::Displacement; + Ok(Step::Continue) + } + (StretchPhase::Displacement, Input::Point(p)) => self.apply(s, p), + (StretchPhase::Displacement, Input::Enter) => Ok(Step::Done), + (StretchPhase::Second, Input::Point(p)) => self.apply(s, p - self.base.unwrap_or(p)), + (StretchPhase::Second, Input::Enter) => self.apply(s, self.base.unwrap_or(Vec2::ZERO)), + _ => Ok(Step::Continue), + } + } + fn preview(&self, s: &Session, c: Vec2) -> Vec { + let rect = |a: Vec2| { + let (b, d) = (Vec2::new(c.x, a.y), Vec2::new(a.x, c.y)); + vec![line(a, b), line(b, c), line(c, d), line(d, a)] + }; + if let (StretchPhase::Select, Some(m)) = (self.phase, &self.mode) { + let Some(first) = m.pts.first().copied() else { return Vec::new() }; + if !matches!(m.kind, SelectKind::Fence | SelectKind::WPolygon | SelectKind::CPolygon) { + return rect(first); + } + // The fence or polygon so far, on to the cursor; a polygon closes back to its start. + let mut out: Vec = m.pts.windows(2).filter_map(|w| Some(line(*w.first()?, *w.get(1)?))).collect(); + out.extend(m.pts.last().map(|l| line(*l, c))); + if m.kind != SelectKind::Fence && m.pts.len() >= 2 { + out.push(line(c, first)); + } + return out; + } + match (self.phase, self.corner, self.base) { + (StretchPhase::Select, Some(a), _) => rect(a), + (StretchPhase::Second, _, Some(base)) => { + let Ok(doc) = s.doc() else { return Vec::new() }; + let m = Mat3::translate(c - base); + let mut out: Vec = self + .objs + .iter() + .take(500) + .filter_map(|h| doc.entity(*h)) + .map(|e| { + let mut e = (**e).clone(); + if self.whole.contains(&e.handle) { + e.kind.transform(&m); + } else { + stretch_entity(&mut e, &self.windows, c - base); + } + e.kind + }) + .collect(); + out.push(line(base, c)); + out + } + _ => Vec::new(), + } + } +} diff --git a/crates/engine/src/cmd/modify/transform.rs b/crates/engine/src/cmd/modify/transform.rs new file mode 100644 index 000000000..760e37cbc --- /dev/null +++ b/crates/engine/src/cmd/modify/transform.rs @@ -0,0 +1,106 @@ +use super::*; + +// ---------------- core operations ---------------- + +pub(crate) fn transform_entities(s: &mut Session, hs: &[Handle], m: &Mat3, copy: bool) -> Result> { + let d = s.doc_mut()?; + let mut out = Vec::new(); + for h in hs { + let Some(sp) = d.space_of(*h) else { continue }; + if copy { + let Some(e) = d.entity(*h).map(|e| (**e).clone()) else { continue }; + let nh = d.new_handle(); + let mut ne = Entity { handle: nh, ..e }; + ne.kind.transform(m); + if let Some(st) = d.space_mut(&sp) { + st.push(ne); + out.push(nh); + } + } else { + let locked = d.entity(*h).and_then(|e| d.layer(&e.common.layer)).is_some_and(|l| l.locked); + if locked { + continue; + } + d.modify_entity(*h, |e| e.kind.transform(m))?; + out.push(*h); + } + } + Ok(out) +} + +pub(super) fn erase(s: &mut Session, hs: &[Handle]) -> Result { + let d = s.doc_mut()?; + let mut n = 0; + for h in hs { + let locked = d.entity(*h).and_then(|e| d.layer(&e.common.layer)).is_some_and(|l| l.locked); + if !locked && d.remove_entity(*h).is_some() { + n += 1; + } + } + s.set_selection(Vec::new()); + Ok(n) +} + +pub(super) fn run_erase(s: &mut Session, p: &Value) -> Result { + let hs = targets(s, p)?; + let n = erase(s, &hs)?; + Ok(json!({ "erased": n })) +} + +pub(super) fn delta_of(cmd: &str, p: &Value) -> Result { + if let Some(d) = point_param(p, "delta") { + return Ok(d); + } + let a = point_req(cmd, p, "from")?; + let b = point_req(cmd, p, "to")?; + Ok(b - a) +} + +pub(super) fn run_move(s: &mut Session, p: &Value) -> Result { + let hs = targets(s, p)?; + let d = delta_of("move", p)?; + let moved = transform_entities(s, &hs, &Mat3::translate(d), false)?; + Ok(json!({ "moved": moved.len() })) +} + +pub(super) fn run_copy(s: &mut Session, p: &Value) -> Result { + let hs = targets(s, p)?; + let d = delta_of("copy", p)?; + let n = p.get("count").and_then(Value::as_u64).unwrap_or(1).clamp(1, 10_000); + let mut out = Vec::new(); + for k in 1..=n { + out.extend(transform_entities(s, &hs, &Mat3::translate(d * k as f64), true)?.into_iter().map(|h| h.hex())); + } + Ok(json!({ "handles": out })) +} + +pub(super) fn run_rotate(s: &mut Session, p: &Value) -> Result { + let hs = targets(s, p)?; + let base = point_req("rotate", p, "base")?; + let a = f64_req("rotate", p, "angle")?.to_radians(); + let r = transform_entities(s, &hs, &Mat3::rotate_about(base, a), bool_or(p, "copy", false))?; + Ok(json!({ "handles": r.iter().map(|h| h.hex()).collect::>() })) +} + +pub(super) fn run_scale(s: &mut Session, p: &Value) -> Result { + let hs = targets(s, p)?; + let base = point_req("scale", p, "base")?; + let f = f64_req("scale", p, "factor")?; + if f <= 0.0 { + return Err(bad("scale", "factor must be positive")); + } + let r = transform_entities(s, &hs, &Mat3::scale_about(base, f), bool_or(p, "copy", false))?; + Ok(json!({ "handles": r.iter().map(|h| h.hex()).collect::>() })) +} + +pub(super) fn run_mirror(s: &mut Session, p: &Value) -> Result { + let hs = targets(s, p)?; + let a = point_req("mirror", p, "p1")?; + let b = point_req("mirror", p, "p2")?; + if a.near(b, 1e-12) { + return Err(bad("mirror", "mirror line points coincide")); + } + let erase_src = bool_or(p, "erase", false); + let r = transform_entities(s, &hs, &Mat3::mirror(a, b), !erase_src)?; + Ok(json!({ "handles": r.iter().map(|h| h.hex()).collect::>() })) +} diff --git a/crates/engine/src/cmd/modify/trim.rs b/crates/engine/src/cmd/modify/trim.rs new file mode 100644 index 000000000..4caaadfb6 --- /dev/null +++ b/crates/engine/src/cmd/modify/trim.rs @@ -0,0 +1,343 @@ +use super::*; + +// ---------------- trim / extend ---------------- + +/// The cutting (TRIM) or boundary (EXTEND) edges. +#[derive(Clone, Copy, Default)] +pub(crate) struct Edges<'a> { + /// Only these objects; `None`: every visible object. + pub only: Option<&'a [Handle]>, + /// Implied edges (EDGEMODE 1): lines and the end segments of open polylines count as running + /// on without end, arcs as whole circles. + pub implied: bool, +} + +/// All primitives of the cutting edges (excluding `exclude`). +fn edge_segments(s: &Session, edges: Edges, exclude: Handle) -> Result> { + let d = s.doc()?; + let space = s.space(); + let store = d.space(&space).ok_or(EngineError::NoDocument)?; + let mut out = Vec::new(); + for e in store.iter() { + if e.handle == exclude || !d.is_visible(e) { + continue; + } + if let Some(es) = edges.only + && !es.contains(&e.handle) + { + continue; + } + if edges.implied + && let Some(segs) = implied_edge(&e.kind) + { + out.extend(segs); + continue; + } + for p in e.kind.prims() { + out.extend(prim_to_segments(&p)); + } + } + Ok(out) +} + +/// `k` as an implied edge (EDGEMODE 1), for lines, arcs and open polylines. +fn implied_edge(k: &EntityKind) -> Option> { + const FAR: f64 = 1e8; + let whole = |a: &Arc| Segment::Arc { arc: Arc { start: 0.0, end: TAU - 1e-12, ..*a }, ccw: true }; + // Run a segment on past its start and/or its end. + let run_on = |sg: Segment, back: bool, fwd: bool| match sg { + Segment::Line(l) => { + let d = (l.b - l.a).normalized(); + Segment::Line(Line::new(if back { l.a - d * FAR } else { l.a }, if fwd { l.b + d * FAR } else { l.b })) + } + Segment::Arc { arc, .. } => whole(&arc), + }; + match k { + EntityKind::Line(l) => Some(vec![run_on(Segment::Line(Line::new(l.a.xy(), l.b.xy())), true, true)]), + EntityKind::Arc(a) => Some(vec![whole(&Arc::new(a.center.xy(), a.radius, a.start, a.end))]), + EntityKind::LwPolyline(p) if !p.closed => { + let mut segs = Polyline { vertices: p.vertices.clone(), closed: false }.segments(); + let last = segs.len().checked_sub(1)?; + for (i, sg) in segs.iter_mut().enumerate() { + if i == 0 || i == last { + *sg = run_on(*sg, i == 0, i == last); + } + } + Some(segs) + } + _ => None, + } +} + +fn prim_to_segments(p: &Prim) -> Vec { + match p { + Prim::Seg(s) => vec![*s], + Prim::Circle(c) => vec![Segment::Arc { arc: Arc { center: c.center, radius: c.radius, start: 0.0, end: TAU - 1e-12 }, ccw: true }], + Prim::Infinite { base, dir, .. } => vec![Segment::Line(Line::new(*base - *dir * 1e8, *base + *dir * 1e8))], + Prim::Ellipse(e) => { + let mut pts = Vec::new(); + e.tessellate(e.major.len() * 1e-4, &mut pts); + pts.windows(2).filter_map(|w| Some(Segment::Line(Line::new(*w.first()?, *w.get(1)?)))).collect() + } + Prim::Spline(sp) => sp.tessellate(1e-4).windows(2).filter_map(|w| Some(Segment::Line(Line::new(*w.first()?, *w.get(1)?)))).collect(), + Prim::Fill(f) => { + let n = f.len(); + (0..n).filter_map(|i| Some(Segment::Line(Line::new(*f.get(i)?, *f.get((i + 1) % n)?)))).collect() + } + Prim::Point(_) => Vec::new(), + } +} + +/// Trim `h` at the piece containing `pick`. Returns the handles that replace it. +pub(crate) fn trim(s: &mut Session, h: Handle, pick: Vec2, edges: Edges) -> Result> { + if crate::cmd::curves::is_locked(s, h) { + return Err(EngineError::Other("The object is on a locked layer.".into())); + } + let e = s.doc()?.entity(h).map(|e| (**e).clone()).ok_or_else(|| EngineError::Other("no such object".into()))?; + let cut = edge_segments(s, edges, h)?; + let pieces: Vec = match &e.kind { + EntityKind::Line(l) => { + let ln = Line::new(l.a.xy(), l.b.xy()); + let mut ts: Vec = cut + .iter() + .flat_map(|c| intersect_ext(&Segment::Line(ln), c, false)) + .map(|x| ln.param_of(x)) + .filter(|t| *t > 1e-9 && *t < 1.0 - 1e-9) + .collect(); + ts.sort_by(f64::total_cmp); + let tp = ln.param_of(pick).clamp(0.0, 1.0); + let lo = ts.iter().copied().filter(|t| *t < tp).fold(0.0, f64::max); + let hi = ts.iter().copied().filter(|t| *t > tp).fold(1.0, f64::min); + if lo == 0.0 && hi == 1.0 { + return Err(EngineError::Other("Object does not intersect a cutting edge.".into())); + } + let mut v = Vec::new(); + if lo > 0.0 { + v.push(line(ln.a, ln.at(lo))); + } + if hi < 1.0 { + v.push(line(ln.at(hi), ln.b)); + } + v + } + EntityKind::Arc(a) => { + let ga = Arc::new(a.center.xy(), a.radius, a.start, a.end); + let seg = Segment::Arc { arc: ga, ccw: true }; + let sweep = ga.sweep(); + let param = |p: Vec2| norm_angle(ga.center.angle_to(p) - ga.start); + let mut ts: Vec = + cut.iter().flat_map(|c| intersect_ext(&seg, c, false)).map(param).filter(|t| *t > 1e-9 && *t < sweep - 1e-9).collect(); + ts.sort_by(f64::total_cmp); + let tp = param(pick).min(sweep); + let lo = ts.iter().copied().filter(|t| *t < tp).fold(0.0, f64::max); + let hi = ts.iter().copied().filter(|t| *t > tp).fold(sweep, f64::min); + if lo == 0.0 && hi == sweep { + return Err(EngineError::Other("Object does not intersect a cutting edge.".into())); + } + let mut v = Vec::new(); + if lo > 0.0 { + v.push(arc(&Arc::new(ga.center, ga.radius, ga.start, ga.start + lo))); + } + if hi < sweep { + v.push(arc(&Arc::new(ga.center, ga.radius, ga.start + hi, ga.end))); + } + v + } + EntityKind::Circle(c) => { + let full = Segment::Arc { arc: Arc { center: c.center.xy(), radius: c.radius, start: 0.0, end: TAU - 1e-12 }, ccw: true }; + let mut angs: Vec = cut.iter().flat_map(|x| intersect_ext(&full, x, false)).map(|p| c.center.xy().angle_to(p)).collect(); + angs.sort_by(f64::total_cmp); + angs.dedup_by(|a, b| (*a - *b).abs() < 1e-9); + if angs.len() < 2 { + return Err(EngineError::Other("Circle must intersect cutting edges at two or more points.".into())); + } + let ap = c.center.xy().angle_to(pick); + // The removed arc runs between the two intersections around the pick. + let n = angs.len(); + let mut keep = None; + for i in 0..n { + let a0 = angs[i]; + let a1 = angs[(i + 1) % n]; + if angle_in_sweep(ap, a0, a1) { + keep = Some(arc(&Arc::new(c.center.xy(), c.radius, a1, a0))); + } + } + keep.into_iter().collect() + } + EntityKind::LwPolyline(p) => trim_polyline(p, &cut, pick)?, + k => crate::cmd::modify2::trim_other(k, &cut, pick)?, + }; + let common = e.common.clone(); + let space = s.space(); + let d = s.doc_mut()?; + d.remove_entity(h); + let mut out = Vec::new(); + for k in pieces { + let nh = d.new_handle(); + if let Some(st) = d.space_mut(&space) { + st.push(Entity { handle: nh, common: common.clone(), kind: k }); + out.push(nh); + } + } + Ok(out) +} + +/// Sub-polyline between length parameters `a` and `b` (open polylines). +pub(super) fn sub_polyline(segs: &[Segment], a: f64, b: f64) -> Vec { + let mut out: Vec = Vec::new(); + let mut acc = 0.0; + for s in segs { + let len = s.len(); + let s0 = acc; + let s1 = acc + len; + acc = s1; + if s1 <= a + 1e-12 || s0 >= b - 1e-12 || len <= 1e-15 { + continue; + } + let t0 = ((a - s0) / len).clamp(0.0, 1.0); + let t1 = ((b - s0) / len).clamp(0.0, 1.0); + let p0 = s.at(t0); + let bulge = match s { + Segment::Line(_) => 0.0, + Segment::Arc { arc, ccw } => { + let sw = arc.sweep() * (t1 - t0); + cadcraft_geom::arc_to_bulge(if *ccw { sw } else { -sw }) + } + }; + if out.last().is_none_or(|l| !l.p.near(p0, 1e-9)) { + out.push(PolyVertex { p: p0, bulge, ..Default::default() }); + } else if let Some(l) = out.last_mut() { + l.bulge = bulge; + } + out.push(PolyVertex::new(s.at(t1))); + } + out +} + +fn trim_polyline(p: &cadcraft_doc::LwPolyline, cut: &[Segment], pick: Vec2) -> Result> { + let pl = Polyline { vertices: p.vertices.clone(), closed: p.closed }; + let segs = pl.segments(); + let total: f64 = segs.iter().map(Segment::len).sum(); + let mut ts: Vec = Vec::new(); + let mut acc = 0.0; + let mut tp = 0.0; + let mut best = f64::INFINITY; + for s in &segs { + let len = s.len(); + for x in cut.iter().flat_map(|c| intersect_ext(s, c, false)) { + ts.push(acc + param_on(s, x) * len); + } + let c = s.closest(pick); + if c.dist(pick) < best { + best = c.dist(pick); + tp = acc + param_on(s, c) * len; + } + acc += len; + } + ts.retain(|t| *t > 1e-9 && *t < total - 1e-9); + ts.sort_by(f64::total_cmp); + if ts.is_empty() { + return Err(EngineError::Other("Object does not intersect a cutting edge.".into())); + } + let lo = ts.iter().copied().filter(|t| *t < tp).fold(f64::NEG_INFINITY, f64::max); + let hi = ts.iter().copied().filter(|t| *t > tp).fold(f64::INFINITY, f64::min); + let mk = |vs: Vec| lwpoly(vs, false); + let mut out = Vec::new(); + if p.closed { + // Keep the complementary run from hi around to lo. + // The pick lies before the first cut or after the last: the piece wraps past the start + // vertex, so the run to keep is the stretch between the first and the last cut. + let (lo, hi) = ( + if lo.is_finite() { lo } else { ts.last().copied().unwrap_or(0.0) - total }, + if hi.is_finite() { hi } else { ts.first().copied().unwrap_or(total) + total }, + ); + let doubled: Vec = segs.iter().chain(segs.iter()).copied().collect(); + let (a, mut b) = if hi > total { (hi - total, lo) } else { (hi, lo + total) }; + // A single cut leaves the whole outline, opened there. + if b <= a + 1e-9 { + b += total; + } + out.push(mk(sub_polyline(&doubled, a, b))); + } else { + if lo.is_finite() { + out.push(mk(sub_polyline(&segs, 0.0, lo))); + } + if hi.is_finite() { + out.push(mk(sub_polyline(&segs, hi, total))); + } + } + Ok(out.into_iter().filter(|k| matches!(k, EntityKind::LwPolyline(l) if l.vertices.len() >= 2)).collect()) +} + +pub(super) fn param_on(s: &Segment, p: Vec2) -> f64 { + match s { + Segment::Line(l) => l.param_of(p).clamp(0.0, 1.0), + Segment::Arc { arc, ccw } => { + let t = norm_angle(arc.center.angle_to(p) - arc.start) / arc.sweep().max(1e-15); + let t = t.clamp(0.0, 1.0); + if *ccw { t } else { 1.0 - t } + } + } +} + +/// Extend the end of `h` nearest `pick` to the nearest boundary edge. +pub(crate) fn extend(s: &mut Session, h: Handle, pick: Vec2, edges: Edges) -> Result<()> { + if crate::cmd::curves::is_locked(s, h) { + return Err(EngineError::Other("The object is on a locked layer.".into())); + } + let e = s.doc()?.entity(h).map(|e| (**e).clone()).ok_or_else(|| EngineError::Other("no such object".into()))?; + let cut = edge_segments(s, edges, h)?; + let new_kind = match &e.kind { + EntityKind::Line(l) => { + let (a, b) = (l.a.xy(), l.b.xy()); + let from_b = pick.dist(b) < pick.dist(a); + let (fixed, end) = if from_b { (a, b) } else { (b, a) }; + let dir = (end - fixed).normalized(); + let ray = Segment::Line(Line::new(end, end + dir * 1e8)); + let best = cut + .iter() + .flat_map(|c| intersect_ext(&ray, c, false)) + .filter(|x| (*x - end).dot(dir) > 1e-9) + .min_by(|x, y| x.dist(end).total_cmp(&y.dist(end))); + let np = best.ok_or_else(|| EngineError::Other("Object does not intersect an edge.".into()))?; + if from_b { line(a, np) } else { line(np, b) } + } + EntityKind::Arc(a) => { + let ga = Arc::new(a.center.xy(), a.radius, a.start, a.end); + let from_end = pick.dist(ga.end_point()) < pick.dist(ga.start_point()); + let full = Segment::Arc { arc: Arc { start: 0.0, end: TAU - 1e-12, ..ga }, ccw: true }; + let hits: Vec = + cut.iter().flat_map(|c| intersect_ext(&full, c, false)).map(|x| ga.center.angle_to(x)).filter(|t| !ga.contains_angle(*t)).collect(); + let pick_ang = if from_end { + hits.iter().copied().min_by(|x, y| ccw_sweep(ga.end, *x).total_cmp(&ccw_sweep(ga.end, *y))) + } else { + hits.iter().copied().min_by(|x, y| ccw_sweep(*x, ga.start).total_cmp(&ccw_sweep(*y, ga.start))) + }; + let t = pick_ang.ok_or_else(|| EngineError::Other("Object does not intersect an edge.".into()))?; + if from_end { arc(&Arc::new(ga.center, ga.radius, ga.start, t)) } else { arc(&Arc::new(ga.center, ga.radius, t, ga.end)) } + } + EntityKind::LwPolyline(p) if !p.closed && p.vertices.len() >= 2 => { + let mut vs = p.vertices.clone(); + let n = vs.len(); + let from_end = vs.last().is_some_and(|l| pick.dist(l.p) < vs.first().map(|f| pick.dist(f.p)).unwrap_or(f64::INFINITY)); + let (i_end, i_prev) = if from_end { (n - 1, n - 2) } else { (0, 1) }; + let end = vs.get(i_end).map(|v| v.p).unwrap_or_default(); + let prev = vs.get(i_prev).map(|v| v.p).unwrap_or_default(); + let dir = (end - prev).normalized(); + let ray = Segment::Line(Line::new(end, end + dir * 1e8)); + let np = cut + .iter() + .flat_map(|c| intersect_ext(&ray, c, false)) + .filter(|x| (*x - end).dot(dir) > 1e-9) + .min_by(|x, y| x.dist(end).total_cmp(&y.dist(end))) + .ok_or_else(|| EngineError::Other("Object does not intersect an edge.".into()))?; + if let Some(v) = vs.get_mut(i_end) { + v.p = np; + } + lwpoly(vs, false) + } + k => crate::cmd::modify2::extend_other(k, &cut, pick)?, + }; + s.doc_mut()?.modify_entity(h, |e| e.kind = new_kind)?; + Ok(()) +} diff --git a/crates/engine/src/cmd/modify2.rs b/crates/engine/src/cmd/modify2.rs index 27327b13b..8d814ebe0 100644 --- a/crates/engine/src/cmd/modify2.rs +++ b/crates/engine/src/cmd/modify2.rs @@ -122,594 +122,8 @@ pub(crate) fn as_lwpoly(k: &EntityKind) -> Option { } } -/// Fit curve: a tangent-continuous pair of arcs (biarc) per segment through every vertex, -/// following the tangent directions given per vertex (Edit vertex Tangent). -pub(crate) fn fit_vertices(vs: &[PolyVertex], closed: bool, tans: &[Option]) -> Vec { - let pts: Vec = vs.iter().map(|v| v.p).collect(); - let n = pts.len(); - if n < 3 { - return vs.iter().map(|v| PolyVertex { bulge: 0.0, ..*v }).collect(); - } - let at = |i: usize| pts.get(i).copied().unwrap_or_default(); - let tangent = |i: usize| -> Vec2 { - let (prev, next) = if closed { - (at((i + n - 1) % n), at((i + 1) % n)) - } else if i == 0 { - (at(0), at(1)) - } else if i + 1 == n { - (at(n - 2), at(n - 1)) - } else { - (at(i - 1), at(i + 1)) - }; - let t = (next - prev).normalized(); - if t == Vec2::ZERO { Vec2::X } else { t } - }; - // Open ends: mirror the neighbouring tangent across the chord for a natural end. - let tan_at = |i: usize| -> Vec2 { - if let Some(Some(t)) = tans.get(i) { - return *t; - } - if closed || (i != 0 && i + 1 != n) { - return tangent(i); - } - let (a, b, tb) = if i == 0 { (at(0), at(1), tangent(1)) } else { (at(n - 1), at(n - 2), -tangent(n - 2)) }; - let c = (b - a).normalized(); - let m = c * (2.0 * tb.dot(c)) - tb; - let m = if i == 0 { m } else { -m }; - if m.len() < 1e-12 { c } else { m.normalized() } - }; - let count = if closed { n } else { n - 1 }; - let mut out = Vec::with_capacity(count * 2 + 1); - for i in 0..count { - let p0 = at(i); - let p1 = at((i + 1) % n); - let t0 = tan_at(i); - let t1 = tan_at((i + 1) % n); - let v = p1 - p0; - let tsum = t0 + t1; - let denom = 2.0 * (1.0 - t0.dot(t1)); - let d = if denom.abs() < 1e-12 { - let vt = v.dot(t1); - if vt.abs() < 1e-12 { v.len() / 2.0 } else { v.len2() / (4.0 * vt) } - } else { - let vt = v.dot(tsum); - (-vt + (vt * vt + denom * v.len2()).max(0.0).sqrt()) / denom - }; - let pm = (p0 + t0 * d + p1 - t1 * d) / 2.0; - let b1 = curves::bulge_from_tangent(p0, pm, t0); - let b2 = -curves::bulge_from_tangent(p1, pm, -t1); - let w = vs.get(i).copied().unwrap_or_default(); - out.push(PolyVertex { p: p0, bulge: if b1.is_finite() { b1 } else { 0.0 }, start_width: w.start_width, end_width: w.end_width }); - out.push(PolyVertex { p: pm, bulge: if b2.is_finite() { b2 } else { 0.0 }, start_width: w.end_width, end_width: w.end_width }); - } - if !closed { - out.push(PolyVertex::new(at(n - 1))); - } - out -} - -/// Spline curve: a cubic B-spline with the vertices as its frame (control points). -pub(crate) fn spline_vertices(vs: &[PolyVertex], closed: bool, segs: usize) -> Vec { - let pts: Vec = vs.iter().map(|v| v.p).collect(); - let n = pts.len(); - if n < 3 { - return vs.to_vec(); - } - let segs = segs.clamp(1, 64).min((MAX_GEN / n).max(1)); - let mut out: Vec = Vec::new(); - if closed { - // Uniform periodic cubic B-spline. - let at = |i: usize| pts.get(i % n).copied().unwrap_or_default(); - for i in 0..n { - let (p0, p1, p2, p3) = (at(i + n - 1), at(i), at(i + 1), at(i + 2)); - for k in 0..segs { - let t = k as f64 / segs as f64; - let (t2, t3) = (t * t, t * t * t); - let b0 = (1.0 - t).powi(3) / 6.0; - let b1 = (3.0 * t3 - 6.0 * t2 + 4.0) / 6.0; - let b2 = (-3.0 * t3 + 3.0 * t2 + 3.0 * t + 1.0) / 6.0; - let b3 = t3 / 6.0; - out.push(p0 * b0 + p1 * b1 + p2 * b2 + p3 * b3); - } - } - } else { - let sp = Spline::from_control(pts.clone(), 3); - let (lo, hi) = sp.domain(); - let m = (segs * (n - 1)).min(MAX_GEN); - for k in 0..=m { - out.push(sp.eval(lo + (hi - lo) * k as f64 / m as f64)); - } - } - out.into_iter().map(PolyVertex::new).collect() -} - -/// Join connected lines/arcs/open polylines onto the ends of polyline `h` (kept in place). -pub(crate) fn pedit_join(s: &mut Session, h: Handle, others: &[Handle], fuzz: f64) -> Result { - let e = curves::entity(s, h)?; - let mut base = as_lwpoly(&e.kind).ok_or_else(|| other("Select a polyline."))?; - if base.closed { - return Err(other("Cannot join to a closed polyline.")); - } - let mut chain = Chain::of(&EntityKind::LwPolyline(base.clone())).unwrap_or_default(); - let tol = fuzz.max(1e-6); - let mut pool: Vec<(Handle, Chain)> = Vec::new(); - for o in others { - if *o == h { - continue; - } - let Ok(oe) = curves::entity(s, *o) else { continue }; - let ok = matches!(&oe.kind, EntityKind::Line(_) | EntityKind::Arc(_)) || matches!(&oe.kind, EntityKind::LwPolyline(p) if !p.closed); - if ok && let Some(c) = Chain::of(&oe.kind) { - pool.push((*o, c)); - } - } - let mut joined = Vec::new(); - while let (Some(st), Some(en)) = (chain.start(), chain.end()) { - let Some(i) = pool - .iter() - .position(|(_, c)| c.start().zip(c.end()).is_some_and(|(a, b)| a.near(en, tol) || b.near(en, tol) || a.near(st, tol) || b.near(st, tol))) - else { - break; - }; - let (oh, c) = pool.remove(i); - let (Some(a), Some(b)) = (c.start(), c.end()) else { continue }; - if a.near(en, tol) { - chain.segs.extend(c.segs); - } else if b.near(en, tol) { - chain.segs.extend(c.reversed().segs); - } else if b.near(st, tol) { - let mut v = c.segs; - v.extend(chain.segs); - chain.segs = v; - } else { - let mut v = c.reversed().segs; - v.extend(chain.segs); - chain.segs = v; - } - joined.push(oh); - } - if joined.is_empty() { - return Ok(0); - } - chain.closed = chain.start().zip(chain.end()).is_some_and(|(a, b)| a.near(b, tol)) && chain.segs.len() > 1; - base.vertices = chain.vertices(); - base.closed = chain.closed; - let d = s.doc_mut()?; - d.modify_entity(h, |e| e.kind = EntityKind::LwPolyline(base))?; - for o in &joined { - d.remove_entity(*o); - } - Ok(joined.len()) -} - -fn pedit_apply(s: &mut Session, h: Handle, opt: &str, p: &Value) -> Result { - let e = curves::entity(s, h)?; - if curves::is_locked(s, h) { - return Err(other("The object is on a locked layer.")); - } - let mut pl = match &e.kind { - EntityKind::LwPolyline(pl) => pl.clone(), - EntityKind::Line(_) | EntityKind::Arc(_) if bool_or(p, "convert", true) => as_lwpoly(&e.kind).ok_or_else(|| other("Cannot convert."))?, - _ => return Err(other("Object selected is not a polyline.")), - }; - match opt { - "close" | "c" => pl.closed = pl.vertices.len() > 1, - "open" | "o" => pl.closed = false, - "width" | "w" => { - let w = f64_req("pedit", p, "width")?; - if w < 0.0 { - return Err(bad("pedit", "width must be non-negative")); - } - pl.const_width = w; - for v in &mut pl.vertices { - v.start_width = 0.0; - v.end_width = 0.0; - } - } - "fit" | "f" => pl.vertices = fit_vertices(&pl.vertices, pl.closed, &pedit_vertex::tangent_param(p, pl.vertices.len())?), - "spline" | "s" => { - let segs = s.doc()?.header.i64("SPLINESEGS", 8).clamp(1, 64) as usize; - pl.vertices = spline_vertices(&pl.vertices, pl.closed, segs); - } - "decurve" | "d" => pl.vertices.iter_mut().for_each(|v| v.bulge = 0.0), - "reverse" | "r" => pl.vertices = curves::reverse_vertices(&pl.vertices, pl.closed), - "ltypegen" | "l" => pl.plinegen = bool_or(p, "on", !pl.plinegen), - "join" | "j" => { - set_kind(s, h, EntityKind::LwPolyline(pl))?; - let others: Vec = p - .get("handles2") - .or_else(|| p.get("join")) - .and_then(Value::as_array) - .map(|a| a.iter().filter_map(curves::handle_value).collect()) - .unwrap_or_default(); - let n = pedit_join(s, h, &others, f64_or(p, "fuzz", 0.0))?; - return Ok(json!({ "handle": h.hex(), "joined": n })); - } - "vertex" | "e" => { - let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("pedit", "`index` is required"))? as usize; - let n = pl.vertices.len(); - if i >= n { - return Err(bad("pedit", "vertex index out of range")); - } - match str_param(p, "action").unwrap_or("move") { - "move" => { - let to = point_req("pedit", p, "to")?; - if let Some(v) = pl.vertices.get_mut(i) { - v.p = to; - } - } - "insert" => { - let to = point_req("pedit", p, "to")?; - if n >= MAX_GEN { - return Err(bad("pedit", "too many vertices")); - } - if let Some(v) = pl.vertices.get_mut(i) { - v.bulge = 0.0; - } - pl.vertices.insert(i + 1, PolyVertex::new(to)); - } - "straighten" => { - let j = p.get("index2").and_then(Value::as_u64).map_or(i, |j| j as usize); - pedit_vertex::straighten(&mut pl, i, j)?; - } - "break" => { - let j = p.get("index2").and_then(Value::as_u64).map_or(i, |j| j as usize); - let hs = pedit_vertex::break_poly(s, h, &pl, i, j)?; - return Ok(json!({ "handle": h.hex(), "handles": hs.iter().map(|h| h.hex()).collect::>() })); - } - // The display is always current; tangents only exist within an interactive PEDIT. - "regen" => {} - "width" => { - let sw = f64_or(p, "startWidth", 0.0).max(0.0); - let ew = f64_or(p, "endWidth", sw).max(0.0); - if let Some(v) = pl.vertices.get_mut(i) { - v.start_width = sw; - v.end_width = ew; - } - } - _ => return Err(bad("pedit", "action must be move, insert, straighten, break, width or regen")), - } - } - _ => return Err(bad("pedit", "unknown option")), - } - set_kind(s, h, EntityKind::LwPolyline(pl))?; - Ok(json!({ "handle": h.hex() })) -} - -fn run_pedit(s: &mut Session, p: &Value) -> Result { - let hs = targets(s, p)?; - if hs.is_empty() { - return Err(bad("pedit", "`handle` is required")); - } - let opt = str_param(p, "option").ok_or_else(|| bad("pedit", "`option` is required"))?.to_ascii_lowercase(); - let mut out = Vec::new(); - for h in hs { - out.push(pedit_apply(s, h, &opt, p)?); - } - Ok(if out.len() == 1 { out.pop().unwrap_or(Value::Null) } else { json!({ "results": out }) }) -} - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -enum PPhase { - #[default] - Select, - Convert, - Main, - Width, - Join, - Vertex, - VMove, - VInsert, - VWidthStart, - VWidthEnd, - /// The second marker of Break / Straighten. - VMark, - VTangent, -} - mod pedit_vertex; -#[derive(Default)] -struct PeditM { - phase: PPhase, - h: Option, - history: Vec, - original: Option>, - join: SelectPhase, - vertex: usize, - vwidth: f64, - /// Break / Straighten: the second marked vertex, and which of the two runs. - mark2: usize, - breaking: bool, - /// Edit vertex Tangent directions by vertex position, used by Fit. - tangents: Vec<(Vec2, Vec2)>, - /// Pieces split off by Break, with the history length after it (erased again by Undo). - split_off: Vec<(usize, Vec)>, -} - -impl PeditM { - fn poly(&self, s: &Session) -> Option { - let e = s.doc().ok()?.entity(self.h?)?.clone(); - match &e.kind { - EntityKind::LwPolyline(p) => Some(p.clone()), - _ => None, - } - } - fn snapshot(&mut self, s: &Session) { - if let Some(h) = self.h - && let Ok(e) = curves::entity(s, h) - { - self.history.push(e.kind); - } - } - fn apply(&mut self, s: &mut Session, opt: &str, p: Value) -> Result<()> { - let h = self.h.ok_or_else(|| other("No polyline."))?; - self.snapshot(s); - if let Err(e) = pedit_apply(s, h, opt, &p) { - self.history.pop(); - return Err(e); - } - Ok(()) - } -} - -impl Interactive for PeditM { - fn name(&self) -> &'static str { - "PEDIT" - } - fn begin(&mut self, s: &mut Session) -> Result { - let sel = s.selection(); - if let [h] = sel.as_slice() { - let k = curves::entity(s, *h)?.kind; - match k { - EntityKind::LwPolyline(_) => { - self.h = Some(*h); - self.original = self.poly(s).map(|p| p.vertices); - self.phase = PPhase::Main; - } - EntityKind::Line(_) | EntityKind::Arc(_) => { - self.h = Some(*h); - self.phase = PPhase::Convert; - } - _ => {} - } - } - Ok(Step::Continue) - } - fn prompt(&self, s: &Session) -> Prompt { - match self.phase { - PPhase::Select => Prompt::new("Select polyline or", Accept::POINT).kw(&["Multiple"]), - PPhase::Convert => { - Prompt::new("Object selected is not a polyline. Do you want to turn it into one?", curves::KW).kw(&["Yes", "No"]).default("Y") - } - PPhase::Main => { - let closed = self.poly(s).is_some_and(|p| p.closed); - let first = if closed { "Open" } else { "Close" }; - Prompt::new("Enter an option", curves::KW).kw(&[ - first, - "Join", - "Width", - "Edit vertex", - "Fit", - "Spline", - "Decurve", - "Ltype gen", - "Reverse", - "Undo", - ]) - } - PPhase::Width => Prompt::new("Specify new width for all segments", Accept::NUMBER), - PPhase::Join => self.join.prompt(), - PPhase::Vertex => Prompt::new("Enter a vertex editing option", curves::KW) - .kw(&["Next", "Previous", "Break", "Insert", "Move", "Regen", "Straighten", "Tangent", "Width", "eXit"]) - .default("N"), - PPhase::VMove => Prompt::new("Specify new location for marked vertex", Accept::POINT), - PPhase::VInsert => Prompt::new("Specify location for new vertex", Accept::POINT), - PPhase::VWidthStart => Prompt::new("Specify starting width for next segment", Accept::NUMBER), - PPhase::VWidthEnd => Prompt::new("Specify ending width for next segment", Accept::NUMBER).default(format!("{:.4}", self.vwidth)), - PPhase::VMark => Prompt::new("Enter an option", curves::KW).kw(&["Next", "Previous", "Go", "eXit"]).default("N"), - PPhase::VTangent => Prompt::new("Specify direction of vertex tangent", Accept::POINT_OR_NUMBER) - .base_opt(self.poly(s).and_then(|p| p.vertices.get(self.vertex).map(|v| v.p))), - } - } - fn input(&mut self, s: &mut Session, i: Input) -> Result { - let kw = |i: &Input| match i { - Input::Keyword(k) | Input::Text(k) => Some(k.trim().to_ascii_lowercase()), - _ => None, - }; - match self.phase { - PPhase::Select => match i { - Input::Point(p) => { - let h = curves::pick_at(s, p).ok_or_else(|| other("*Invalid selection*"))?; - let k = curves::entity(s, h)?.kind; - self.h = Some(h); - match k { - EntityKind::LwPolyline(pl) => { - self.original = Some(pl.vertices); - self.phase = PPhase::Main; - } - EntityKind::Line(_) | EntityKind::Arc(_) => self.phase = PPhase::Convert, - _ => { - self.h = None; - return Err(other("Object selected is not a polyline.")); - } - } - s.set_selection(vec![h]); - Ok(Step::Continue) - } - Input::Keyword(_) => Err(other("Select one polyline (Multiple is available through the JSON form).")), - Input::Enter => Ok(Step::Cancel), - _ => Ok(Step::Continue), - }, - PPhase::Convert => { - let no = kw(&i).is_some_and(|k| k.starts_with('n')); - if no { - return Ok(Step::Done); - } - let h = self.h.ok_or_else(|| other("No object."))?; - let k = curves::entity(s, h)?.kind; - let pl = as_lwpoly(&k).ok_or_else(|| other("Cannot convert."))?; - self.original = Some(pl.vertices.clone()); - set_kind(s, h, EntityKind::LwPolyline(pl))?; - self.phase = PPhase::Main; - Ok(Step::Continue) - } - PPhase::Main => { - let Some(k) = kw(&i) else { - if i == Input::Enter { - s.set_selection(Vec::new()); - return Ok(Step::Done); - } - return Ok(Step::Continue); - }; - match k.as_str() { - "open" | "o" => self.apply(s, "open", json!({}))?, - "close" | "c" => self.apply(s, "close", json!({}))?, - "join" | "j" => { - self.join = SelectPhase::default(); - self.phase = PPhase::Join; - } - "width" | "w" => self.phase = PPhase::Width, - "edit vertex" | "e" => { - self.vertex = 0; - self.phase = PPhase::Vertex; - } - "fit" | "f" => self.apply(s, "fit", json!({ "tangents": self.tangents_json(s) }))?, - "spline" | "s" => self.apply(s, "spline", json!({}))?, - "decurve" | "d" => { - // Within the session, Decurve restores the frame before Fit/Spline. - let h = self.h.ok_or_else(|| other("No polyline."))?; - self.snapshot(s); - match (self.poly(s), self.original.clone()) { - (Some(mut pl), Some(orig)) => { - pl.vertices = orig.into_iter().map(|v| PolyVertex { bulge: 0.0, ..v }).collect(); - set_kind(s, h, EntityKind::LwPolyline(pl))?; - } - _ => { - self.history.pop(); - self.apply(s, "decurve", json!({}))?; - } - } - } - "ltype gen" | "l" => self.apply(s, "ltypegen", json!({}))?, - "reverse" | "r" => self.apply(s, "reverse", json!({}))?, - "undo" | "u" => { - self.undo_split(s)?; - if let (Some(h), Some(k)) = (self.h, self.history.pop()) { - set_kind(s, h, k)?; - } - } - _ => return Err(other("Invalid option keyword.")), - } - Ok(Step::Continue) - } - PPhase::Width => { - if let Input::Text(t) = &i { - let w = number(t).filter(|w| *w >= 0.0).ok_or_else(|| other("Requires a non-negative width."))?; - self.apply(s, "width", json!({ "width": w }))?; - } - self.phase = PPhase::Main; - Ok(Step::Continue) - } - PPhase::Join => match self.join.feed(s, &i)? { - SelOutcome::More => Ok(Step::Continue), - SelOutcome::Empty => { - self.phase = PPhase::Main; - Ok(Step::Continue) - } - SelOutcome::Done(hs) => { - let h = self.h.ok_or_else(|| other("No polyline."))?; - self.snapshot(s); - let n = pedit_join(s, h, &hs, 0.0)?; - s.echo(format!("{n} segments added to polyline")); - s.set_selection(vec![h]); - self.phase = PPhase::Main; - Ok(Step::Continue) - } - }, - PPhase::Vertex => { - let n = self.poly(s).map(|p| p.vertices.len()).unwrap_or(0).max(1); - let k = kw(&i).unwrap_or_else(|| if i == Input::Enter { "n".into() } else { String::new() }); - match k.as_str() { - "next" | "n" => self.vertex = (self.vertex + 1) % n, - "previous" | "p" => self.vertex = (self.vertex + n - 1) % n, - "move" | "m" => self.phase = PPhase::VMove, - "insert" | "i" => self.phase = PPhase::VInsert, - "straighten" | "s" | "break" | "b" => { - self.breaking = k.starts_with('b'); - self.mark2 = self.vertex; - self.phase = PPhase::VMark; - } - "tangent" | "t" => self.phase = PPhase::VTangent, - // The display is always current. - "regen" | "r" => {} - "width" | "w" => self.phase = PPhase::VWidthStart, - "exit" | "x" => self.phase = PPhase::Main, - "" => {} - _ => return Err(other("Invalid option keyword.")), - } - Ok(Step::Continue) - } - PPhase::VMove | PPhase::VInsert => { - if let Input::Point(p) = i { - let action = if self.phase == PPhase::VMove { "move" } else { "insert" }; - self.apply(s, "vertex", json!({ "action": action, "index": self.vertex, "to": [p.x, p.y] }))?; - if action == "insert" { - self.vertex += 1; - } - } - self.phase = PPhase::Vertex; - Ok(Step::Continue) - } - PPhase::VWidthStart => { - if let Input::Text(t) = &i { - self.vwidth = number(t).filter(|w| *w >= 0.0).ok_or_else(|| other("Requires a non-negative width."))?; - self.phase = PPhase::VWidthEnd; - } else { - self.phase = PPhase::Vertex; - } - Ok(Step::Continue) - } - PPhase::VMark => { - let k = kw(&i).unwrap_or_else(|| if i == Input::Enter { "n".into() } else { String::new() }); - self.mark_input(s, &k)?; - Ok(Step::Continue) - } - PPhase::VTangent => { - self.tangent_input(s, &i)?; - Ok(Step::Continue) - } - PPhase::VWidthEnd => { - let ew = match &i { - Input::Text(t) => number(t).filter(|w| *w >= 0.0).ok_or_else(|| other("Requires a non-negative width."))?, - _ => self.vwidth, - }; - self.apply(s, "vertex", json!({ "action": "width", "index": self.vertex, "startWidth": self.vwidth, "endWidth": ew }))?; - self.phase = PPhase::Vertex; - Ok(Step::Continue) - } - } - } - fn preview(&self, s: &Session, c: Vec2) -> Vec { - if !matches!( - self.phase, - PPhase::Vertex | PPhase::VMove | PPhase::VInsert | PPhase::VWidthStart | PPhase::VWidthEnd | PPhase::VMark | PPhase::VTangent - ) { - return Vec::new(); - } - let Some(v) = self.poly(s).and_then(|p| p.vertices.get(self.vertex).copied()) else { return Vec::new() }; - let k = s.pixel_size() * 8.0; - let mut out = vec![line(v.p - Vec2::new(k, k), v.p + Vec2::new(k, k)), line(v.p - Vec2::new(k, -k), v.p + Vec2::new(k, -k))]; - if matches!(self.phase, PPhase::VMove | PPhase::VInsert | PPhase::VTangent) { - out.push(line(v.p, c)); - } - if let (PPhase::VMark, Some(m)) = (self.phase, self.poly(s).and_then(|p| p.vertices.get(self.mark2).copied())) { - out.extend([line(m.p - Vec2::new(k, k), m.p + Vec2::new(k, k)), line(m.p - Vec2::new(k, -k), m.p + Vec2::new(k, -k))]); - } - out - } -} - // ===================================================================================== // SPLINEDIT // ===================================================================================== @@ -717,1569 +131,31 @@ impl Interactive for PeditM { mod splinedit; use splinedit::{SplineditM, run_splinedit}; -// ===================================================================================== -// LENGTHEN -// ===================================================================================== - -#[derive(Clone, Copy, Debug, PartialEq)] -pub(crate) enum LenMode { - Delta(f64), - DeltaAngle(f64), - Percent(f64), - Total(f64), - TotalAngle(f64), - To(Vec2), -} - -/// Current length (and included angle for arcs) of a curve. -fn measure_len(k: &EntityKind) -> Option<(f64, Option)> { - match k { - EntityKind::Arc(a) => { - let g = Arc::new(a.center.xy(), a.radius, a.start, a.end); - Some((g.len(), Some(g.sweep()))) - } - EntityKind::Ellipse(e) => Some((ellipse_of(e).length(), None)), - EntityKind::Spline(sp) if sp.is_valid() => Some((sp.length(), None)), - _ => Chain::of(k).map(|c| (c.len(), None)), - } -} - -/// Lengthen a line, arc, open polyline, elliptical arc or open spline at the end nearest `pick`. -pub(crate) fn lengthen_kind(k: &EntityKind, pick: Option, mode: LenMode) -> Result { - let too_short = || other("Lengthening would make the object zero length or negative."); - match k { - EntityKind::Line(l) => { - let (a, b) = (l.a.xy(), l.b.xy()); - let at_b = pick.is_none_or(|p| p.dist(b) <= p.dist(a)); - let (fixed, end) = if at_b { (a, b) } else { (b, a) }; - let len = fixed.dist(end); - let dir = (end - fixed).normalized(); - if dir == Vec2::ZERO { - return Err(other("Zero-length line.")); - } - let nl = match mode { - LenMode::Delta(d) => len + d, - LenMode::Percent(p) => len * p / 100.0, - LenMode::Total(t) => t, - LenMode::To(q) => (q - fixed).dot(dir), - LenMode::DeltaAngle(_) | LenMode::TotalAngle(_) => return Err(other("Angle options apply to arcs.")), - }; - if !(nl > 1e-12 && nl.is_finite()) { - return Err(too_short()); - } - let ne = fixed + dir * nl; - let mut out = l.clone(); - if at_b { - out.b = ne.to3(l.b.z); - } else { - out.a = ne.to3(l.a.z); - } - Ok(EntityKind::Line(out)) - } - EntityKind::Arc(ar) => { - let g = Arc::new(ar.center.xy(), ar.radius, ar.start, ar.end); - let at_end = pick.is_none_or(|p| p.dist(g.end_point()) <= p.dist(g.start_point())); - let sw = g.sweep(); - let r = g.radius.max(1e-300); - let ns = match mode { - LenMode::Delta(d) => sw + d / r, - LenMode::DeltaAngle(a) => sw + a, - LenMode::Percent(p) => sw * p / 100.0, - LenMode::Total(t) => t / r, - LenMode::TotalAngle(a) => a, - LenMode::To(q) => { - let a = g.center.angle_to(q); - if at_end { ccw_sweep(g.start, a) } else { ccw_sweep(a, g.end) } - } - }; - if !(ns > 1e-12 && ns < TAU - 1e-9) { - return Err(other("The new arc would be zero length or a full circle.")); - } - let na = if at_end { Arc::new(g.center, g.radius, g.start, g.start + ns) } else { Arc::new(g.center, g.radius, g.end - ns, g.end) }; - Ok(EntityKind::Arc(cadcraft_doc::Arc { radius: na.radius, start: na.start, end: na.end, ..ar.clone() })) - } - EntityKind::LwPolyline(p) if !p.closed && p.vertices.len() >= 2 => { - let total = Polyline { vertices: p.vertices.clone(), closed: false }.len(); - let mut vs = p.vertices.clone(); - let n = vs.len(); - let (first, last) = (vs.first().map(|v| v.p).unwrap_or_default(), vs.last().map(|v| v.p).unwrap_or_default()); - let at_end = pick.is_none_or(|q| q.dist(last) <= q.dist(first)); - if !at_end { - vs = curves::reverse_vertices(&vs, false); - } - let seg_start = vs.get(n - 2).copied().unwrap_or_default(); - if seg_start.bulge.abs() > 1e-12 { - return Err(other("The end segment is an arc; explode the polyline or use an open line end.")); - } - let end = vs.get(n - 1).map(|v| v.p).unwrap_or_default(); - let seg_len = seg_start.p.dist(end); - let dir = (end - seg_start.p).normalized(); - if dir == Vec2::ZERO { - return Err(other("Zero-length end segment.")); - } - let delta = match mode { - LenMode::Delta(d) => d, - LenMode::Percent(pc) => total * pc / 100.0 - total, - LenMode::Total(t) => t - total, - LenMode::To(q) => (q - seg_start.p).dot(dir) - seg_len, - LenMode::DeltaAngle(_) | LenMode::TotalAngle(_) => return Err(other("Angle options apply to arcs.")), - }; - let nl = seg_len + delta; - if !(nl > 1e-12 && nl.is_finite()) { - return Err(too_short()); - } - if let Some(v) = vs.get_mut(n - 1) { - v.p = seg_start.p + dir * nl; - } - if !at_end { - vs = curves::reverse_vertices(&vs, false); - } - Ok(EntityKind::LwPolyline(LwPolyline { vertices: vs, ..p.clone() })) - } - EntityKind::Ellipse(e) => curve_edits::lengthen_ellipse(e, pick, mode), - EntityKind::Spline(sp) => curve_edits::lengthen_spline(sp, pick, mode), - _ => Err(other("Select a line, arc, open polyline, elliptical arc or open spline.")), - } -} - -fn len_mode(p: &Value) -> Option { - let f = |k: &str| p.get(k).and_then(Value::as_f64).filter(|v| v.is_finite()); - if let Some(v) = f("delta") { - Some(LenMode::Delta(v)) - } else if let Some(v) = f("deltaAngle") { - Some(LenMode::DeltaAngle(v.to_radians())) - } else if let Some(v) = f("percent") { - Some(LenMode::Percent(v)) - } else if let Some(v) = f("total") { - Some(LenMode::Total(v)) - } else if let Some(v) = f("totalAngle") { - Some(LenMode::TotalAngle(v.to_radians())) - } else { - point_param(p, "to").map(LenMode::To) - } -} - -fn run_lengthen(s: &mut Session, p: &Value) -> Result { - let h = targets(s, p)?.first().copied().ok_or_else(|| bad("lengthen", "`handle` is required"))?; - let e = curves::entity(s, h)?; - let Some(mode) = len_mode(p) else { - let (l, a) = measure_len(&e.kind).ok_or_else(|| other("Cannot measure that object."))?; - return Ok(json!({ "length": l, "angle": a.map(f64::to_degrees) })); - }; - if curves::is_locked(s, h) { - return Err(other("The object is on a locked layer.")); - } - let k = lengthen_kind(&e.kind, point_param(p, "pick"), mode)?; - let l = measure_len(&k).map(|x| x.0).unwrap_or(0.0); - set_kind(s, h, k)?; - Ok(json!({ "handle": h.hex(), "length": l })) -} - -#[derive(Clone, Copy, Debug, Default, PartialEq)] -enum LPhase { - #[default] - Main, - Delta, - DeltaAngle, - Percent, - Total, - TotalAngle, - Change, - DynEnd(Handle, Vec2), -} - -#[derive(Default)] -struct LengthenM { - phase: LPhase, - mode: Option, - dynamic: bool, - history: Vec<(Handle, EntityKind)>, -} - -impl Interactive for LengthenM { - fn name(&self) -> &'static str { - "LENGTHEN" - } - fn begin(&mut self, s: &mut Session) -> Result { - s.set_selection(Vec::new()); - Ok(Step::Continue) - } - fn prompt(&self, _s: &Session) -> Prompt { - match self.phase { - LPhase::Main => { - Prompt::new("Select an object to measure or", Accept::POINT).kw(&["DElta", "Percent", "Total", "DYnamic"]).default("DYnamic") - } - LPhase::Delta => Prompt::new("Enter delta length or", Accept::NUMBER).kw(&["Angle"]).default("0.0000"), - LPhase::DeltaAngle => Prompt::new("Enter delta angle", Accept::NUMBER).default("0"), - LPhase::Percent => Prompt::new("Enter percentage length", Accept::NUMBER).default("100.0000"), - LPhase::Total => Prompt::new("Specify total length or", Accept::NUMBER).kw(&["Angle"]).default("1.0000"), - LPhase::TotalAngle => Prompt::new("Specify total angle", Accept::NUMBER).default("57"), - LPhase::Change => Prompt::new("Select an object to change or", Accept::POINT).kw(&["Undo"]), - LPhase::DynEnd(..) => Prompt::new("Specify new end point", Accept::POINT), - } - } - fn input(&mut self, s: &mut Session, i: Input) -> Result { - let num = |i: &Input| -> Option { if let Input::Text(t) = i { number(t) } else { None } }; - let au = s.angle_settings(); - let ang = |i: &Input| -> Option { if let Input::Text(t) = i { au.amount(t) } else { None } }; - match (self.phase, &i) { - (LPhase::Main, Input::Point(p)) => { - let h = curves::pick_at(s, *p).ok_or_else(|| other("*Invalid selection*"))?; - let k = curves::entity(s, h)?.kind; - match measure_len(&k) { - Some((l, Some(a))) => s.echo(format!("Current length: {l:.4}, included angle: {:.0}", a.to_degrees())), - Some((l, None)) => s.echo(format!("Current length: {l:.4}")), - None => s.echo("Cannot measure that object."), - } - Ok(Step::Continue) - } - (LPhase::Main, Input::Keyword(k)) => { - self.phase = match k.as_str() { - "DElta" => LPhase::Delta, - "Percent" => LPhase::Percent, - "Total" => LPhase::Total, - _ => { - self.dynamic = true; - LPhase::Change - } - }; - Ok(Step::Continue) - } - (LPhase::Main, Input::Enter) => { - self.dynamic = true; - self.phase = LPhase::Change; - Ok(Step::Continue) - } - (LPhase::Delta | LPhase::Total, Input::Keyword(_)) => { - self.phase = if self.phase == LPhase::Delta { LPhase::DeltaAngle } else { LPhase::TotalAngle }; - Ok(Step::Continue) - } - (LPhase::Delta, _) => { - self.mode = Some(LenMode::Delta(num(&i).ok_or_else(|| other("Requires a distance."))?)); - self.phase = LPhase::Change; - Ok(Step::Continue) - } - (LPhase::DeltaAngle, _) => { - self.mode = Some(LenMode::DeltaAngle(ang(&i).ok_or_else(|| other("Requires an angle."))?)); - self.phase = LPhase::Change; - Ok(Step::Continue) - } - (LPhase::Percent, _) => { - let v = num(&i).filter(|v| *v > 0.0).ok_or_else(|| other("Requires a positive percentage."))?; - self.mode = Some(LenMode::Percent(v)); - self.phase = LPhase::Change; - Ok(Step::Continue) - } - (LPhase::Total, _) => { - let v = num(&i).filter(|v| *v > 0.0).ok_or_else(|| other("Requires a positive length."))?; - self.mode = Some(LenMode::Total(v)); - self.phase = LPhase::Change; - Ok(Step::Continue) - } - (LPhase::TotalAngle, _) => { - self.mode = Some(LenMode::TotalAngle(ang(&i).ok_or_else(|| other("Requires an angle."))?)); - self.phase = LPhase::Change; - Ok(Step::Continue) - } - (LPhase::Change, Input::Point(p)) => { - let h = curves::pick_at(s, *p).ok_or_else(|| other("*Invalid selection*"))?; - if self.dynamic { - self.phase = LPhase::DynEnd(h, *p); - return Ok(Step::Continue); - } - let mode = self.mode.ok_or_else(|| other("No lengthen mode."))?; - let k = curves::entity(s, h)?.kind; - let nk = lengthen_kind(&k, Some(*p), mode)?; - set_kind(s, h, nk)?; - self.history.push((h, k)); - Ok(Step::Continue) - } - (LPhase::DynEnd(h, pick), Input::Point(p)) => { - let k = curves::entity(s, h)?.kind; - let nk = lengthen_kind(&k, Some(pick), LenMode::To(*p))?; - set_kind(s, h, nk)?; - self.history.push((h, k)); - self.phase = LPhase::Change; - Ok(Step::Continue) - } - (LPhase::Change, Input::Keyword(_)) => { - if let Some((h, k)) = self.history.pop() { - set_kind(s, h, k)?; - } - Ok(Step::Continue) - } - (_, Input::Enter) => Ok(Step::Done), - _ => Err(other("Invalid input.")), - } - } - fn preview(&self, s: &Session, c: Vec2) -> Vec { - if let LPhase::DynEnd(h, pick) = self.phase - && let Ok(e) = curves::entity(s, h) - { - return lengthen_kind(&e.kind, Some(pick), LenMode::To(c)).map(|k| vec![k]).unwrap_or_default(); - } - Vec::new() - } -} - -// ===================================================================================== -// ALIGN -// ===================================================================================== - -pub(crate) fn align_matrix(s1: Vec2, d1: Vec2, pair2: Option<(Vec2, Vec2)>, scale: bool) -> Mat3 { - let mut m = Mat3::translate(d1 - s1); - if let Some((s2, d2)) = pair2 { - let vs = s2 - s1; - let vd = d2 - d1; - if vs.len() > 1e-12 && vd.len() > 1e-12 { - m = Mat3::rotate_about(d1, vd.angle() - vs.angle()).then_before(m); - if scale { - m = Mat3::scale_about(d1, vd.len() / vs.len()).then_before(m); - } - } - } - m -} - -fn run_align(s: &mut Session, p: &Value) -> Result { - let hs = targets(s, p)?; - let s1 = point_req("align", p, "s1")?; - let d1 = point_req("align", p, "d1")?; - let pair2 = point_param(p, "s2").zip(point_param(p, "d2")); - let m = align_matrix(s1, d1, pair2, bool_or(p, "scale", false)); - let r = super::modify::transform_entities(s, &hs, &m, false)?; - Ok(json!({ "handles": r.iter().map(|h| h.hex()).collect::>() })) -} - -#[derive(Default)] -struct AlignM { - sel: SelectPhase, - objs: Vec, - pts: Vec, - ask_scale: bool, -} - -impl AlignM { - fn finish(&self, s: &mut Session, scale: bool) -> Result { - let (Some(s1), Some(d1)) = (self.pts.first().copied(), self.pts.get(1).copied()) else { return Ok(Step::Cancel) }; - let pair2 = self.pts.get(2).copied().zip(self.pts.get(3).copied()); - let m = align_matrix(s1, d1, pair2, scale); - super::modify::transform_entities(s, &self.objs, &m, false)?; - s.set_selection(Vec::new()); - Ok(Step::Done) - } -} - -impl Interactive for AlignM { - fn name(&self) -> &'static str { - "ALIGN" - } - fn begin(&mut self, s: &mut Session) -> Result { - self.sel = SelectPhase::begin(s); - if self.sel.done { - self.objs = self.sel.picked.clone(); - } - Ok(Step::Continue) - } - fn prompt(&self, _s: &Session) -> Prompt { - if !self.sel.done { - return self.sel.prompt(); - } - if self.ask_scale { - return Prompt::new("Scale objects based on alignment points?", curves::KW).kw(&["Yes", "No"]).default("N"); - } - match self.pts.len() { - 0 => Prompt::new("Specify first source point", Accept::POINT), - 1 => Prompt::new("Specify first destination point", Accept::POINT).base_opt(self.pts.first().copied()), - 2 => Prompt::new("Specify second source point", Accept::POINT), - 3 => Prompt::new("Specify second destination point", Accept::POINT).base_opt(self.pts.get(2).copied()), - _ => Prompt::new("Specify third source point or ", Accept::POINT), - } - } - fn input(&mut self, s: &mut Session, i: Input) -> Result { - if !self.sel.done { - return match self.sel.feed(s, &i)? { - SelOutcome::More => Ok(Step::Continue), - SelOutcome::Empty => Ok(Step::Cancel), - SelOutcome::Done(hs) => { - self.objs = hs; - Ok(Step::Continue) - } - }; - } - if self.ask_scale { - let yes = matches!(&i, Input::Keyword(k) | Input::Text(k) if k.to_ascii_lowercase().starts_with('y')); - return self.finish(s, yes); - } - match i { - Input::Point(p) => { - if self.pts.len() < 4 { - self.pts.push(p); - } else { - s.echo("Third pair ignored (2D align)."); - self.ask_scale = true; - } - Ok(Step::Continue) - } - Input::Enter => match self.pts.len() { - n if n >= 4 => { - self.ask_scale = true; - Ok(Step::Continue) - } - 2 | 3 => { - self.pts.truncate(2); - self.finish(s, false) - } - _ => Ok(Step::Cancel), - }, - _ => Ok(Step::Continue), - } - } - fn preview(&self, _s: &Session, c: Vec2) -> Vec { - match self.pts.as_slice() { - [a] => vec![line(*a, c)], - [_, _, a] => vec![line(*a, c)], - _ => Vec::new(), - } - } -} - -// ===================================================================================== -// BLEND -// ===================================================================================== - -/// Endpoint of a curve nearest `pick` and the outward unit tangent there. -fn curve_end(k: &EntityKind, pick: Vec2) -> Option<(Vec2, Vec2)> { - let c = Chain::of(k)?; - if c.closed { - return None; - } - let (a, b) = (c.start()?, c.end()?); - if pick.dist(b) <= pick.dist(a) { - let t = c.segs.last()?.tangent(1.0); - Some((b, t)) - } else { - let t = c.segs.first()?.tangent(0.0); - Some((a, -t)) - } -} - -pub(crate) fn blend_spline(e1: (Vec2, Vec2), e2: (Vec2, Vec2), smooth: bool) -> Option { - let (p1, t1) = e1; - let (p2, t2) = e2; - let d = p1.dist(p2); - if d < 1e-12 { - return None; - } - let ctrl = if smooth { - let k = d / 5.0; - vec![p1, p1 + t1 * k, p1 + t1 * (2.0 * k), p2 + t2 * (2.0 * k), p2 + t2 * k, p2] - } else { - let k = d / 3.0; - vec![p1, p1 + t1 * k, p2 + t2 * k, p2] - }; - let deg = ctrl.len() - 1; - Some(Spline::from_control(ctrl, deg)) -} - -fn blend(s: &mut Session, h1: Handle, p1: Vec2, h2: Handle, p2: Vec2, smooth: bool) -> Result { - let k1 = curves::entity(s, h1)?.kind; - let k2 = curves::entity(s, h2)?.kind; - let e1 = curve_end(&k1, p1).ok_or_else(|| other("Select an open line, arc, polyline, ellipse arc or spline."))?; - let e2 = curve_end(&k2, p2).ok_or_else(|| other("Select an open line, arc, polyline, ellipse arc or spline."))?; - let sp = blend_spline(e1, e2, smooth).ok_or_else(|| other("The ends coincide."))?; - s.add_entity(EntityKind::Spline(sp)) -} - -fn run_blend(s: &mut Session, p: &Value) -> Result { - let h1 = curves::handle_param(p, "h1").ok_or_else(|| bad("blend", "`h1` is required"))?; - let h2 = curves::handle_param(p, "h2").ok_or_else(|| bad("blend", "`h2` is required"))?; - let p1 = point_req("blend", p, "p1")?; - let p2 = point_req("blend", p, "p2")?; - let smooth = str_param(p, "continuity").is_some_and(|c| c.eq_ignore_ascii_case("smooth")); - let h = blend(s, h1, p1, h2, p2, smooth)?; - Ok(json!({ "handle": h.hex() })) -} - -#[derive(Default)] -struct BlendM { - first: Option<(Handle, Vec2)>, - smooth: bool, - asking: bool, -} - -impl Interactive for BlendM { - fn name(&self) -> &'static str { - "BLEND" - } - fn begin(&mut self, s: &mut Session) -> Result { - s.set_selection(Vec::new()); - s.echo(format!("Continuity = {}", if self.smooth { "Smooth" } else { "Tangent" })); - Ok(Step::Continue) - } - fn prompt(&self, _s: &Session) -> Prompt { - if self.asking { - return Prompt::new("Enter continuity", curves::KW).kw(&["Tangent", "Smooth"]).default("Tangent"); - } - match self.first { - None => Prompt::new("Select first object or", Accept::POINT).kw(&["CONtinuity"]), - Some(_) => Prompt::new("Select second point", Accept::POINT), - } - } - fn input(&mut self, s: &mut Session, i: Input) -> Result { - if self.asking { - if let Input::Keyword(k) | Input::Text(k) = &i { - self.smooth = k.to_ascii_lowercase().starts_with('s'); - } - self.asking = false; - return Ok(Step::Continue); - } - match i { - Input::Keyword(_) => { - self.asking = true; - Ok(Step::Continue) - } - Input::Point(p) => { - let h = curves::pick_at(s, p).ok_or_else(|| other("*Invalid selection*"))?; - match self.first { - None => { - let k = curves::entity(s, h)?.kind; - curve_end(&k, p).ok_or_else(|| other("Select an open curve."))?; - self.first = Some((h, p)); - Ok(Step::Continue) - } - Some((h1, p1)) => { - blend(s, h1, p1, h, p, self.smooth)?; - Ok(Step::Done) - } - } - } - Input::Enter => Ok(Step::Cancel), - _ => Ok(Step::Continue), - } - } -} - -// ===================================================================================== -// REVERSE / CHSPACE / FLATTEN (select, then act) -// ===================================================================================== - -pub(crate) fn reverse_kind(k: &EntityKind) -> Option { - Some(match k { - EntityKind::Line(l) => EntityKind::Line(cadcraft_doc::Line { a: l.b, b: l.a }), - EntityKind::LwPolyline(p) => EntityKind::LwPolyline(LwPolyline { vertices: curves::reverse_vertices(&p.vertices, p.closed), ..p.clone() }), - EntityKind::Polyline3d(p) => { - EntityKind::Polyline3d(cadcraft_doc::Polyline3d { points: p.points.iter().rev().copied().collect(), closed: p.closed }) - } - EntityKind::Spline(sp) => EntityKind::Spline(curves::reverse_spline(sp)), - _ => return None, - }) -} - -fn reverse(s: &mut Session, hs: &[Handle]) -> Result { - let mut n = 0; - for h in hs { - if curves::is_locked(s, *h) { - continue; - } - let Ok(e) = curves::entity(s, *h) else { continue }; - if let Some(k) = reverse_kind(&e.kind) { - set_kind(s, *h, k)?; - n += 1; - } - } - Ok(n) -} - -fn run_reverse(s: &mut Session, p: &Value) -> Result { - let hs = targets(s, p)?; - let n = reverse(s, &hs)?; - Ok(json!({ "reversed": n })) -} - -/// Zero every z coordinate; 3D polylines become 2D polylines. -pub(crate) fn flatten_kind(k: &mut EntityKind) { - let z = |p: &mut cadcraft_geom::Vec3| p.z = 0.0; - match k { - EntityKind::Line(l) => { - z(&mut l.a); - z(&mut l.b); - } - EntityKind::Point(p) => z(&mut p.p), - EntityKind::Circle(c) => z(&mut c.center), - EntityKind::Arc(a) => z(&mut a.center), - EntityKind::Ellipse(e) => { - z(&mut e.center); - z(&mut e.major); - } - EntityKind::Helix(h) => *k = EntityKind::Spline(h.spline()), - EntityKind::LwPolyline(p) => p.elevation = 0.0, - EntityKind::Polyline3d(p) => { - *k = lwpoly(p.points.iter().map(|q| PolyVertex::new(q.xy())).collect(), p.closed); - } - EntityKind::Ray(r) | EntityKind::XLine(r) => { - z(&mut r.base); - r.dir.z = 0.0; - r.dir = r.dir.normalized(); - } - EntityKind::Text(t) => { - z(&mut t.insert); - t.align_pt.iter_mut().for_each(z); - } - EntityKind::AttDef(a) => { - z(&mut a.text.insert); - a.text.align_pt.iter_mut().for_each(z); - if let Some(ml) = &mut a.multiline { - z(&mut ml.mtext.insert); - } - } - EntityKind::MText(t) => z(&mut t.insert), - EntityKind::Insert(i) => { - z(&mut i.insert); - for a in &mut i.attribs { - z(&mut a.text.insert); - a.text.align_pt.iter_mut().for_each(z); - if let Some(ml) = &mut a.multiline { - z(&mut ml.mtext.insert); - } - } - } - EntityKind::Dimension(d) => { - for p in [&mut d.defpt, &mut d.text_mid, &mut d.p13, &mut d.p14, &mut d.p15, &mut d.p16] { - z(p); - } - d.block = None; - } - EntityKind::Leader(l) => l.vertices.iter_mut().for_each(z), - EntityKind::MLine(m) => m.vertices.iter_mut().for_each(|v| z(&mut v.p)), - EntityKind::MLeader(m) => { - m.leaders.iter_mut().for_each(|l| l.iter_mut().for_each(z)); - z(&mut m.landing); - m.block.iter_mut().for_each(|b| z(&mut b.insert)); - } - EntityKind::Hatch(h) => h.elevation = 0.0, - EntityKind::Solid(so) | EntityKind::Trace(so) => so.corners.iter_mut().for_each(z), - EntityKind::Face3d(f) => f.corners.iter_mut().for_each(z), - EntityKind::Region(_) => {} - EntityKind::Image(i) => { - z(&mut i.insert); - i.u.z = 0.0; - i.v.z = 0.0; - } - EntityKind::Table(t) => z(&mut t.insert), - EntityKind::Tolerance(t) => { - z(&mut t.insert); - t.dir.z = 0.0; - } - EntityKind::Spline(_) | EntityKind::Viewport(_) | EntityKind::Wipeout(_) | EntityKind::Unknown(_) => {} - } -} - -fn flatten(s: &mut Session, hs: &[Handle]) -> Result { - let mut n = 0; - for h in hs { - if curves::is_locked(s, *h) { - continue; - } - if s.doc()?.entity(*h).is_none() { - continue; - } - s.doc_mut()?.modify_entity(*h, |e| { - flatten_kind(&mut e.kind); - e.common.thickness = 0.0; - })?; - n += 1; - } - Ok(n) -} - -fn run_flatten(s: &mut Session, p: &Value) -> Result { - let hs = targets(s, p)?; - let n = flatten(s, &hs)?; - Ok(json!({ "flattened": n })) -} - -/// Model↔paper transform through a viewport: paper = center + (model − viewCenter) / scale. -fn viewport_of(s: &Session, layout: &str, vp: Option) -> Option { - let d = s.doc().ok()?; - if let Some(h) = vp - && let Some(e) = d.entity(h) - && let EntityKind::Viewport(v) = &e.kind - { - return Some(v.clone()); - } - let st = d.space(&Space::Paper(layout.to_string()))?; - st.iter().find_map(|e| match &e.kind { - EntityKind::Viewport(v) if v.height > 1e-12 && v.view_height > 1e-12 && v.id != 1 => Some(v.clone()), - _ => None, - }) -} - -fn chspace(s: &mut Session, hs: &[Handle], to: Option<&str>, vp: Option) -> Result<(usize, Space, Space)> { - let from = s.space(); - let (target, layout) = match (&from, to) { - (Space::Model, t) => { - let name = match t { - Some(n) if !n.eq_ignore_ascii_case("model") => n.to_string(), - _ => { - let d = s.doc()?; - let mut ls: Vec<_> = d.layouts.iter().collect(); - ls.sort_by_key(|l| l.tab_order); - ls.first().map(|l| l.name.clone()).ok_or_else(|| other("No layout to move objects to."))? - } - }; - if s.doc()?.layout(&name).is_none() { - return Err(other(format!("Layout \"{name}\" not found."))); - } - let real = s.doc()?.layout(&name).map(|l| l.name.clone()).unwrap_or(name); - (Space::Paper(real.clone()), real) - } - (Space::Paper(n), _) => (Space::Model, n.clone()), - }; - let m = match viewport_of(s, &layout, vp) { - Some(v) => { - let k = v.view_height / v.height; - let to_paper = Mat3::translate(v.center.xy()).then_before(Mat3::scale(1.0 / k, 1.0 / k)).then_before(Mat3::translate(-v.view_center)); - if target == Space::Model { to_paper.inverse().unwrap_or(Mat3::IDENTITY) } else { to_paper } - } - None => Mat3::IDENTITY, - }; - let mut n = 0; - for h in hs { - if curves::is_locked(s, *h) { - continue; - } - let d = s.doc_mut()?; - if d.space_of(*h).as_ref() != Some(&from) { - continue; - } - let Some(e) = d.entity(*h).map(|e| (**e).clone()) else { continue }; - if matches!(e.kind, EntityKind::Viewport(_)) { - continue; - } - let mut e = e; - e.kind.transform(&m); - d.remove_entity(*h); - if let Some(st) = d.space_mut(&target) { - st.push(e); - n += 1; - } - } - s.set_selection(Vec::new()); - Ok((n, from, target)) -} - -fn run_chspace(s: &mut Session, p: &Value) -> Result { - let hs = targets(s, p)?; - let (n, _, target) = chspace(s, &hs, str_param(p, "to"), curves::handle_param(p, "viewport"))?; - Ok(json!({ "moved": n, "to": target.layout().unwrap_or("model") })) -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum SelKind { - Reverse, - Chspace, - Flatten, -} - -struct SelOp { - kind: SelKind, - sel: SelectPhase, -} - -impl SelOp { - fn new(kind: SelKind) -> Self { - SelOp { kind, sel: SelectPhase::default() } - } - fn act(&self, s: &mut Session, hs: &[Handle]) -> Result { - match self.kind { - SelKind::Reverse => { - let n = reverse(s, hs)?; - s.echo(format!("{n} object(s) direction has been reversed.")); - } - SelKind::Flatten => { - let n = flatten(s, hs)?; - s.echo(format!("{n} object(s) flattened.")); - } - SelKind::Chspace => { - let (n, from, to) = chspace(s, hs, None, None)?; - let nm = |sp: &Space| if *sp == Space::Model { "MODEL" } else { "PAPER" }; - s.echo(format!("{n} object(s) changed from {} space to {} space.", nm(&from), nm(&to))); - } - } - s.set_selection(Vec::new()); - Ok(Step::Done) - } -} - -impl Interactive for SelOp { - fn name(&self) -> &'static str { - match self.kind { - SelKind::Reverse => "REVERSE", - SelKind::Chspace => "CHSPACE", - SelKind::Flatten => "FLATTEN", - } - } - fn begin(&mut self, s: &mut Session) -> Result { - self.sel = SelectPhase::begin(s); - if self.sel.done { - let hs = self.sel.picked.clone(); - return self.act(s, &hs); - } - Ok(Step::Continue) - } - fn prompt(&self, _s: &Session) -> Prompt { - self.sel.prompt() - } - fn input(&mut self, s: &mut Session, i: Input) -> Result { - match self.sel.feed(s, &i)? { - SelOutcome::More => Ok(Step::Continue), - SelOutcome::Empty => Ok(Step::Cancel), - SelOutcome::Done(hs) => self.act(s, &hs), - } - } -} - -// ===================================================================================== -// NCOPY -// ===================================================================================== - -/// The leaf objects of a block reference in world coordinates (nested references expanded). -fn nested_leaves(d: &cadcraft_doc::Drawing, ins: &cadcraft_doc::Insert, m: &Mat3, depth: usize, out: &mut Vec<(Common, EntityKind)>) { - if depth >= MAX_BLOCK_DEPTH || out.len() >= MAX_GEN { - return; - } - let Some(b) = d.block(&ins.block) else { return }; - let mm = m.then_before(ins.transform(b.base.xy())); - for e in b.entities.iter() { - match &e.kind { - EntityKind::Insert(inner) => nested_leaves(d, inner, &mm, depth + 1, out), - EntityKind::AttDef(_) => {} - k => { - let mut k = k.clone(); - k.transform(&mm); - out.push((e.common.clone(), k)); - } - } - } -} - -fn leaves_of(s: &Session, h: Handle) -> Result> { - let e = curves::entity(s, h)?; - let EntityKind::Insert(ins) = &e.kind else { return Err(other("Select a block reference.")) }; - let mut out = Vec::new(); - nested_leaves(s.doc()?, ins, &Mat3::IDENTITY, 0, &mut out); - Ok(out) -} - -fn nearest_leaf(leaves: Vec<(Common, EntityKind)>, p: Vec2) -> Option<(Common, EntityKind)> { - leaves.into_iter().min_by(|a, b| curves::kind_distance(&a.1, p).total_cmp(&curves::kind_distance(&b.1, p))) -} - -fn place_copies(s: &mut Session, items: &[(Common, EntityKind)], delta: Vec2) -> Result> { - let space = s.space(); - let m = Mat3::translate(delta); - let mut out = Vec::new(); - for (c, k) in items { - let mut k = k.clone(); - k.transform(&m); - out.push(push_entity(s, &space, c.clone(), k)?); - } - Ok(out) -} - -fn run_ncopy(s: &mut Session, p: &Value) -> Result { - let h = targets(s, p)?.first().copied().ok_or_else(|| bad("ncopy", "`handle` (block reference) is required"))?; - let leaves = leaves_of(s, h)?; - let items: Vec<(Common, EntityKind)> = match point_param(p, "pick") { - Some(q) => nearest_leaf(leaves, q).into_iter().collect(), - None => leaves, - }; - if items.is_empty() { - return Err(other("The block reference has no nested objects.")); - } - let delta = match (point_param(p, "delta"), point_param(p, "from"), point_param(p, "to")) { - (Some(d), _, _) => d, - (None, Some(a), Some(b)) => b - a, - _ => Vec2::ZERO, - }; - let hs = place_copies(s, &items, delta)?; - Ok(json!({ "handles": hs.iter().map(|h| h.hex()).collect::>() })) -} - -#[derive(Default)] -struct NcopyM { - items: Vec<(Common, EntityKind)>, - selecting_done: bool, - base: Option, -} - -impl Interactive for NcopyM { - fn name(&self) -> &'static str { - "NCOPY" - } - fn begin(&mut self, s: &mut Session) -> Result { - s.set_selection(Vec::new()); - Ok(Step::Continue) - } - fn prompt(&self, _s: &Session) -> Prompt { - match (self.selecting_done, self.base) { - (false, _) => Prompt::new("Select nested objects to copy or", Accept::POINT).kw(&["Settings"]), - (true, None) => Prompt::new("Specify base point or", Accept::POINT).kw(&["Displacement", "Multiple"]).default("Displacement"), - (true, Some(b)) => Prompt::new("Specify second point or ", Accept::POINT).base(b), - } - } - fn input(&mut self, s: &mut Session, i: Input) -> Result { - if !self.selecting_done { - return match i { - Input::Point(p) => { - let h = curves::pick_at(s, p).ok_or_else(|| other("*Invalid selection*"))?; - let leaf = nearest_leaf(leaves_of(s, h)?, p).ok_or_else(|| other("Nothing nested there."))?; - s.echo(format!("Nested object: {}", leaf.1.type_name())); - if self.items.len() < MAX_GEN { - self.items.push(leaf); - } - Ok(Step::Continue) - } - Input::Keyword(_) => { - s.echo("Insert mode: Copy (nested objects are copied as independent objects)."); - Ok(Step::Continue) - } - Input::Enter => { - if self.items.is_empty() { - return Ok(Step::Cancel); - } - self.selecting_done = true; - Ok(Step::Continue) - } - _ => Ok(Step::Continue), - }; - } - match (self.base, i) { - (None, Input::Point(p)) => { - self.base = Some(p); - Ok(Step::Continue) - } - (None, Input::Enter | Input::Keyword(_)) => { - place_copies(s, &self.items, Vec2::ZERO)?; - Ok(Step::Done) - } - (Some(b), Input::Point(p)) => { - place_copies(s, &self.items, p - b)?; - Ok(Step::Done) - } - (Some(b), Input::Enter) => { - place_copies(s, &self.items, b)?; - Ok(Step::Done) - } - _ => Ok(Step::Continue), - } - } - fn preview(&self, _s: &Session, c: Vec2) -> Vec { - let Some(b) = self.base else { return Vec::new() }; - let m = Mat3::translate(c - b); - self.items - .iter() - .take(500) - .map(|(_, k)| { - let mut k = k.clone(); - k.transform(&m); - k - }) - .collect() - } -} - -// ===================================================================================== -// FILLET extensions (arcs, circles, polylines) -// ===================================================================================== - -/// How a fillet operand is trimmed: the new kind and the arrival direction at the tangent point. -fn trim_for_fillet(k: &EntityKind, pick: Vec2, t: Vec2) -> Option<(Option, Option)> { - match k { - EntityKind::Line(l) => { - let (a, b) = (l.a.xy(), l.b.xy()); - let keep = if (a - t).dot(pick - t) >= (b - t).dot(pick - t) { a } else { b }; - let dir = (t - keep).normalized(); - if dir == Vec2::ZERO { - return Some((None, None)); - } - Some((Some(if keep == a { line(a, t) } else { line(t, b) }), Some(dir))) - } - EntityKind::Arc(ar) => { - let g = Arc::new(ar.center.xy(), ar.radius, ar.start, ar.end); - let ta = g.center.angle_to(t); - let ccw_tan = (t - g.center).perp().normalized(); - let ap = g.center.angle_to(pick); - let keep_start_side = if g.contains_angle(ta) { - // Keep the side of the tangent point that holds the pick. - cadcraft_geom::angle_in_sweep(ap, g.start, ta) - } else { - // Extend the nearer end. - ccw_sweep(g.end, ta) <= ccw_sweep(ta, g.start) - }; - if keep_start_side { - let na = Arc::new(g.center, g.radius, g.start, ta); - Some((Some(arc(&na)), Some(ccw_tan))) - } else { - let na = Arc::new(g.center, g.radius, ta, g.end); - Some((Some(arc(&na)), Some(-ccw_tan))) - } - } - EntityKind::Circle(_) => Some((None, None)), - _ => None, - } -} - -/// Fillet two curves (lines, arcs, circles; elliptical arcs and splines in `curve_edits`) with -/// radius `r`. Returns the new arc, if any. -pub(crate) fn fillet_curves(s: &mut Session, h1: Handle, p1: Vec2, h2: Handle, p2: Vec2, r: f64) -> Result> { - if curves::is_locked(s, h1) || curves::is_locked(s, h2) { - return Err(other("The object is on a locked layer.")); - } - let e1 = curves::entity(s, h1)?; - let e2 = curves::entity(s, h2)?; - if [&e1, &e2].iter().any(|e| matches!(e.kind, EntityKind::Ellipse(_) | EntityKind::Spline(_))) { - return curve_edits::fillet_curvy(s, h1, p1, h2, p2, r, &e1, &e2); - } - let unsupported = || other("Fillet works on lines, arcs, circles, elliptical arcs and splines (and polylines with the Polyline option)."); - let o1 = TanObj::from_kind(&e1.kind, p1).filter(|_| matches!(e1.kind, EntityKind::Line(_) | EntityKind::Arc(_) | EntityKind::Circle(_))); - let o2 = TanObj::from_kind(&e2.kind, p2).filter(|_| matches!(e2.kind, EntityKind::Line(_) | EntityKind::Arc(_) | EntityKind::Circle(_))); - let (Some(o1), Some(o2)) = (o1, o2) else { return Err(unsupported()) }; - if h1 == h2 { - return Err(other("Cannot fillet an object to itself.")); - } - let (t1, t2, fc) = if r <= 1e-12 { - // Sharp corner: the intersection nearest the picks. - let hits: Vec = match (o1, o2) { - (TanObj::Line(a), TanObj::Line(b)) => line_line_infinite(a.a, a.b, b.a, b.b).map(|x| vec![x.0]).unwrap_or_default(), - (TanObj::Line(l), TanObj::Circle(c)) | (TanObj::Circle(c), TanObj::Line(l)) => line_circle(&l, &c).into_iter().map(|x| x.0).collect(), - (TanObj::Circle(a), TanObj::Circle(b)) => cadcraft_geom::circle_circle(&a, &b), - }; - let x = hits - .into_iter() - .min_by(|a, b| (a.dist(p1) + a.dist(p2)).total_cmp(&(b.dist(p1) + b.dist(p2)))) - .ok_or_else(|| other("The objects do not intersect."))?; - (x, x, None) - } else { - let c = curves::ttr(o1, p1, o2, p2, r).ok_or_else(|| other("Radius is too large."))?; - (o1.touch(&c), o2.touch(&c), Some(c)) - }; - let (n1, d1) = trim_for_fillet(&e1.kind, p1, t1).ok_or_else(unsupported)?; - let (n2, d2) = trim_for_fillet(&e2.kind, p2, t2).ok_or_else(unsupported)?; - let new_arc = fc.map(|c| { - let a1 = c.center.angle_to(t1); - let a2 = c.center.angle_to(t2); - let ccw1 = (t1 - c.center).perp().normalized(); - let ccw2 = (t2 - c.center).perp().normalized(); - let ccw = match (d1, d2) { - (Some(d), _) => ccw1.dot(d) > 0.0, - (None, Some(d)) => ccw2.dot(-d) > 0.0, - _ => ccw_sweep(a1, a2) <= std::f64::consts::PI, - }; - if ccw { Arc::new(c.center, c.radius, a1, a2) } else { Arc::new(c.center, c.radius, a2, a1) } - }); - if let Some(k) = n1 { - set_kind(s, h1, k)?; - } - if let Some(k) = n2 { - set_kind(s, h2, k)?; - } - match new_arc { - Some(a) => { - let space = s.doc()?.space_of(h1).unwrap_or_else(|| s.space()); - Ok(Some(push_entity(s, &space, e1.common.clone(), arc(&a))?)) - } - None => Ok(None), - } -} - -/// Fillet every corner between two straight segments of a polyline. Returns the count. -pub(crate) fn fillet_polyline(s: &mut Session, h: Handle, r: f64) -> Result { - if curves::is_locked(s, h) { - return Err(other("The object is on a locked layer.")); - } - let e = curves::entity(s, h)?; - let EntityKind::LwPolyline(pl) = &e.kind else { return Err(other("Select a 2D polyline.")) }; - let vs = &pl.vertices; - let n = vs.len(); - if n < 3 { - return Ok(0); - } - let at = |i: usize| vs.get(i % n).copied().unwrap_or_default(); - // Distance cut back from each corner (0 = not filleted). - let mut cut = vec![0.0f64; n]; - let mut turn = vec![0.0f64; n]; - for (i, c) in cut.iter_mut().enumerate() { - if !pl.closed && (i == 0 || i + 1 == n) { - continue; - } - let prev = at(i + n - 1); - let cur = at(i); - let next = at(i + 1); - if prev.bulge.abs() > 1e-12 || cur.bulge.abs() > 1e-12 { - continue; - } - let u1 = (prev.p - cur.p).normalized(); - let u2 = (next.p - cur.p).normalized(); - if u1 == Vec2::ZERO || u2 == Vec2::ZERO { - continue; - } - let half = u1.dot(u2).clamp(-1.0, 1.0).acos() / 2.0; - if half < 1e-9 || (std::f64::consts::FRAC_PI_2 - half).abs() < 1e-9 { - continue; - } - *c = if r <= 1e-12 { 0.0 } else { r / half.tan() }; - if let Some(t) = turn.get_mut(i) { - *t = (-u1).cross(u2).atan2((-u1).dot(u2)); - } - } - // Drop fillets that do not fit their segments. - for i in 0..n { - let j = (i + 1) % n; - let seg = at(i).p.dist(at(j).p); - let (ci, cj) = (cut.get(i).copied().unwrap_or(0.0), cut.get(j).copied().unwrap_or(0.0)); - if ci + cj > seg + 1e-9 { - if let Some(x) = cut.get_mut(i) { - *x = 0.0; - } - if let Some(x) = cut.get_mut(j) { - *x = 0.0; - } - } - } - let mut out: Vec = Vec::with_capacity(n * 2); - let mut count = 0; - for i in 0..n { - let v = at(i); - let c = cut.get(i).copied().unwrap_or(0.0); - if c <= 1e-12 { - out.push(v); - continue; - } - let prev = at(i + n - 1).p; - let next = at(i + 1).p; - let t1 = v.p + (prev - v.p).normalized() * c; - let t2 = v.p + (next - v.p).normalized() * c; - let b = cadcraft_geom::arc_to_bulge(turn.get(i).copied().unwrap_or(0.0)); - out.push(PolyVertex { p: t1, bulge: b, ..v }); - out.push(PolyVertex { p: t2, bulge: 0.0, ..v }); - count += 1; - } - set_kind(s, h, EntityKind::LwPolyline(LwPolyline { vertices: out, ..pl.clone() }))?; - Ok(count) -} - -// ===================================================================================== -// TRIM / EXTEND for ellipses and splines -// ===================================================================================== - -fn ellipse_of(e: &cadcraft_doc::Ellipse) -> Ellipse { - Ellipse { center: e.center.xy(), major: e.major.xy(), ratio: e.ratio, start: e.start, end: e.end } -} - -fn ellipse_kind(e: &Ellipse) -> EntityKind { - EntityKind::Ellipse(cadcraft_doc::Ellipse { center: v3(e.center), major: v3(e.major), ratio: e.ratio, start: e.start, end: e.end }) -} - -/// Intersections of a sampled curve `eval(t)` (parameters `ts`) with the cutting segments, -/// as curve parameters refined by bisection on the exact curve. -fn hits_on(eval: &dyn Fn(f64) -> Vec2, ts: &[f64], cut: &[Segment]) -> Vec { - let mut out = Vec::new(); - for w in ts.windows(2) { - let (Some(t0), Some(t1)) = (w.first().copied(), w.get(1).copied()) else { continue }; - let (a, b) = (eval(t0), eval(t1)); - let sg = Segment::Line(Line::new(a, b)); - for c in cut { - for x in intersect_ext(&sg, c, false) { - let f = Line::new(a, b).param_of(x).clamp(0.0, 1.0); - out.push(refine_hit(eval, c, t0, t1, t0 + (t1 - t0) * f)); - } - } - } - out -} - -/// Signed side of `p` relative to a cutting segment (line side, or inside/outside an arc's circle). -fn side_of(c: &Segment, p: Vec2) -> f64 { - match c { - Segment::Line(l) => l.side(p) / l.len().max(1e-300), - Segment::Arc { arc, .. } => p.dist(arc.center) - arc.radius, - } -} - -fn refine_hit(eval: &dyn Fn(f64) -> Vec2, c: &Segment, t0: f64, t1: f64, guess: f64) -> f64 { - let (mut a, mut b) = (t0, t1); - let (mut fa, fb) = (side_of(c, eval(a)), side_of(c, eval(b))); - if !(fa.is_finite() && fb.is_finite()) || fa * fb > 0.0 { - return guess; - } - for _ in 0..60 { - let m = (a + b) / 2.0; - let fm = side_of(c, eval(m)); - if fa * fm <= 0.0 { - b = m; - } else { - a = m; - fa = fm; - } - } - (a + b) / 2.0 -} - -fn ellipse_samples(ge: &Ellipse) -> Vec { - let n = 720usize; - let sw = ge.sweep(); - (0..=n).map(|i| ge.start + sw * i as f64 / n as f64).collect() -} - -/// Trim objects that modify.rs does not handle itself (ellipses and splines). -pub(crate) fn trim_other(k: &EntityKind, cut: &[Segment], pick: Vec2) -> Result> { - let none = || other("Object does not intersect a cutting edge."); - match k { - EntityKind::Ellipse(e) => { - let ge = ellipse_of(e); - let full = Ellipse { start: 0.0, end: TAU, ..ge }; - let eval = |t: f64| full.at_param(t); - let params: Vec = hits_on(&eval, &ellipse_samples(&ge), cut).into_iter().map(norm_angle).collect(); - if ge.is_full() { - let mut ps = params; - ps.sort_by(f64::total_cmp); - ps.dedup_by(|a, b| (*a - *b).abs() < 1e-9); - if ps.len() < 2 { - return Err(other("Ellipse must intersect cutting edges at two or more points.")); - } - let tp = full.param_of(pick); - let n = ps.len(); - for i in 0..n { - let (Some(a0), Some(a1)) = (ps.get(i).copied(), ps.get((i + 1) % n).copied()) else { continue }; - if cadcraft_geom::angle_in_sweep(tp, a0, a1) { - return Ok(vec![ellipse_kind(&Ellipse { start: a1, end: a0, ..ge })]); - } - } - return Err(none()); - } - let sweep = ge.sweep(); - let rel = |t: f64| norm_angle(t - ge.start); - let mut ts: Vec = params.into_iter().map(rel).filter(|t| *t > 1e-9 && *t < sweep - 1e-9).collect(); - ts.sort_by(f64::total_cmp); - let tp = rel(full.param_of(pick)).min(sweep); - let lo = ts.iter().copied().filter(|t| *t < tp).fold(0.0, f64::max); - let hi = ts.iter().copied().filter(|t| *t > tp).fold(sweep, f64::min); - if lo == 0.0 && hi == sweep { - return Err(none()); - } - let mut v = Vec::new(); - if lo > 0.0 { - v.push(ellipse_kind(&Ellipse { end: norm_angle(ge.start + lo), ..ge })); - } - if hi < sweep { - v.push(ellipse_kind(&Ellipse { start: norm_angle(ge.start + hi), ..ge })); - } - Ok(v) - } - EntityKind::Spline(sp) => { - if !sp.is_valid() { - return Err(other("Invalid spline.")); - } - let (lo_d, hi_d) = sp.domain(); - let n = 512; - let params: Vec = (0..=n).map(|i| lo_d + (hi_d - lo_d) * i as f64 / n as f64).collect(); - let eval = |t: f64| sp.eval(t); - let mut ts: Vec = - hits_on(&eval, ¶ms, cut).into_iter().filter(|t| *t > lo_d + 1e-9 * (hi_d - lo_d) && *t < hi_d - 1e-9 * (hi_d - lo_d)).collect(); - ts.sort_by(f64::total_cmp); - ts.dedup_by(|a, b| (*a - *b).abs() < 1e-9); - let tp = curves::spline_param_of(sp, pick); - if sp.closed { - return curve_edits::trim_closed_spline(sp, &ts, tp); - } - let lo = ts.iter().copied().filter(|t| *t < tp).fold(lo_d, f64::max); - let hi = ts.iter().copied().filter(|t| *t > tp).fold(hi_d, f64::min); - if lo == lo_d && hi == hi_d { - return Err(none()); - } - let mut v = Vec::new(); - if lo > lo_d - && let Some(a) = curves::sub_spline(sp, lo_d, lo) - { - v.push(EntityKind::Spline(a)); - } - if hi < hi_d - && let Some(b) = curves::sub_spline(sp, hi, hi_d) - { - v.push(EntityKind::Spline(b)); - } - Ok(v) - } - _ => Err(other("Cannot trim that object.")), - } -} - -/// Extend objects that modify.rs does not handle itself (elliptical arcs and open splines). -pub(crate) fn extend_other(k: &EntityKind, cut: &[Segment], pick: Vec2) -> Result { - match k { - EntityKind::Ellipse(e) => { - let ge = ellipse_of(e); - if ge.is_full() { - return Err(other("Cannot extend a closed ellipse.")); - } - let full = Ellipse { start: 0.0, end: TAU, ..ge }; - let from_end = pick.dist(ge.at_param(ge.end)) < pick.dist(ge.at_param(ge.start)); - let eval = |t: f64| full.at_param(t); - let hits: Vec = hits_on(&eval, &ellipse_samples(&full), cut) - .into_iter() - .map(norm_angle) - .filter(|t| !cadcraft_geom::angle_in_sweep(*t, ge.start, ge.end)) - .collect(); - let t = if from_end { - hits.iter().copied().min_by(|x, y| ccw_sweep(ge.end, *x).total_cmp(&ccw_sweep(ge.end, *y))) - } else { - hits.iter().copied().min_by(|x, y| ccw_sweep(*x, ge.start).total_cmp(&ccw_sweep(*y, ge.start))) - }; - let t = t.ok_or_else(|| other("Object does not intersect an edge."))?; - Ok(ellipse_kind(&if from_end { Ellipse { end: t, ..ge } } else { Ellipse { start: t, ..ge } })) - } - EntityKind::Spline(sp) => curve_edits::extend_spline(sp, cut, pick), - _ => Err(other("Cannot extend that object.")), - } -} - -// ===================================================================================== -// ARRAYPATH (non-associative copies along a curve) -// ===================================================================================== - -pub(crate) fn arraypath( - s: &mut Session, - hs: &[Handle], - path: Handle, - count: Option, - spacing: Option, - align: bool, -) -> Result> { - let pk = curves::entity(s, path)?.kind; - let c = Chain::of(&pk).ok_or_else(|| other("Select a line, arc, circle, polyline, ellipse or spline as the path."))?; - let l = c.len(); - if l <= 1e-12 { - return Err(other("The path has no length.")); - } - let objs: Vec = hs.iter().copied().filter(|h| *h != path).collect(); - if objs.is_empty() { - return Err(other("Select objects to array.")); - } - let (n, step) = match (count, spacing) { - (_, Some(sp)) if sp.is_finite() && sp > 0.0 => (((l / sp).floor() as usize).saturating_add(1), sp), - (Some(n), _) => { - let n = n.max(1); - (n, if c.closed { l / n as f64 } else { l / (n.max(2) - 1) as f64 }) - } - _ => (6, if c.closed { l / 6.0 } else { l / 5.0 }), - }; - super::array::check_size(s, n as u64, objs.len())?; - let Some((p0, t0)) = c.at_length(0.0) else { return Ok(Vec::new()) }; - let mut out = Vec::new(); - for i in 1..n { - let Some((p, t)) = c.at_length(step * i as f64) else { continue }; - let mut m = Mat3::translate(p - p0); - if align { - m = Mat3::rotate_about(p, t.angle() - t0.angle()).then_before(m); - } - out.extend(super::modify::transform_entities(s, &objs, &m, true)?); - } - Ok(out) -} - -fn run_arraypath(s: &mut Session, p: &Value) -> Result { - let hs = targets(s, p)?; - let path = curves::handle_param(p, "path").ok_or_else(|| bad("arraypath", "`path` (handle) is required"))?; - let count = p.get("count").and_then(Value::as_u64).map(|n| usize::try_from(n).unwrap_or(usize::MAX)); - let spacing = p.get("spacing").and_then(Value::as_f64); - let r = arraypath(s, &hs, path, count, spacing, bool_or(p, "align", true))?; - Ok(json!({ "handles": r.iter().map(|h| h.hex()).collect::>() })) -} - -#[derive(Default)] -pub(super) struct PathArrayM { - sel: SelectPhase, - objs: Vec, - path: Option, -} - -impl PathArrayM { - /// Start at the path prompt with the objects already selected (ARRAY > PAth). - pub(super) fn with_objects(objs: Vec) -> Self { - PathArrayM { sel: SelectPhase { picked: objs.clone(), done: true, ..SelectPhase::default() }, objs, path: None } - } -} - -impl Interactive for PathArrayM { - fn name(&self) -> &'static str { - "ARRAYPATH" - } - fn begin(&mut self, s: &mut Session) -> Result { - self.sel = SelectPhase::begin(s); - if self.sel.done { - self.objs = self.sel.picked.clone(); - } - Ok(Step::Continue) - } - fn prompt(&self, _s: &Session) -> Prompt { - if !self.sel.done { - return self.sel.prompt(); - } - match self.path { - None => Prompt::new("Select path curve", Accept::POINT), - Some(_) => Prompt::new("Enter number of items along path", Accept::NUMBER).default("6"), - } - } - fn input(&mut self, s: &mut Session, i: Input) -> Result { - if !self.sel.done { - return match self.sel.feed(s, &i)? { - SelOutcome::More => Ok(Step::Continue), - SelOutcome::Empty => Ok(Step::Cancel), - SelOutcome::Done(hs) => { - self.objs = hs; - Ok(Step::Continue) - } - }; - } - match (self.path, i) { - (None, Input::Point(p)) => { - let h = curves::pick_at(s, p).ok_or_else(|| other("*Invalid selection*"))?; - Chain::of(&curves::entity(s, h)?.kind).ok_or_else(|| other("Select a curve as the path."))?; - self.path = Some(h); - Ok(Step::Continue) - } - (Some(path), inp @ (Input::Text(_) | Input::Enter)) => { - let n = match &inp { - Input::Text(t) => number(t).filter(|n| *n >= 1.0).ok_or_else(|| other("Requires a count of 1 or more."))? as usize, - _ => 6, - }; - arraypath(s, &self.objs, path, Some(n), None, true)?; - s.set_selection(Vec::new()); - s.echo(format!("Type = Path Associative = No ({n} items)")); - Ok(Step::Done) - } - (None, Input::Enter) => Ok(Step::Cancel), - _ => Ok(Step::Continue), - } - } -} - -// ===================================================================================== -// TEXTEDIT -// ===================================================================================== - -fn text_of(k: &EntityKind) -> Option { - match k { - EntityKind::Text(t) => Some(t.value.clone()), - EntityKind::AttDef(a) => Some(a.text.value.clone()), - EntityKind::MText(t) => Some(t.contents.replace("\\P", "\n")), - EntityKind::Dimension(d) => Some(if d.text.is_empty() { "<>".into() } else { d.text.clone() }), - _ => None, - } -} - -pub(crate) fn set_text(s: &mut Session, h: Handle, text: &str) -> Result<()> { - if curves::is_locked(s, h) { - return Err(other("The object is on a locked layer.")); - } - let mut k = curves::entity(s, h)?.kind; - match &mut k { - EntityKind::Text(t) => t.value = text.to_string(), - EntityKind::AttDef(a) => a.text.value = text.to_string(), - EntityKind::MText(t) => t.contents = text.replace('\n', "\\P"), - EntityKind::Dimension(d) => { - d.text = if text == "<>" { String::new() } else { text.to_string() }; - d.block = None; - } - _ => return Err(other("Select a text, mtext, attribute definition or dimension.")), - } - set_kind(s, h, k) -} - -fn run_textedit(s: &mut Session, p: &Value) -> Result { - let h = targets(s, p)?.first().copied().ok_or_else(|| bad("textedit", "`handle` is required"))?; - let text = str_param(p, "text").ok_or_else(|| bad("textedit", "`text` is required"))?; - if text.len() > 1_000_000 { - return Err(bad("textedit", "text is too long")); - } - set_text(s, h, text)?; - Ok(json!({ "handle": h.hex() })) -} - -#[derive(Default)] -struct TextEditM { - h: Option<(Handle, String)>, - /// Edits made in this run, newest last: each object as it was before (the Undo option). - edits: Vec<(Handle, EntityKind)>, - /// Single mode: end after one edit (the Mode option; Multiple by default). - single: bool, - /// Asking for the edit mode. - mode: bool, -} - -impl Interactive for TextEditM { - fn name(&self) -> &'static str { - "TEXTEDIT" - } - fn prompt(&self, _s: &Session) -> Prompt { - if self.mode { - let current = if self.single { "Single" } else { "Multiple" }; - return Prompt::new("Enter a text edit mode option", curves::KW).kw(&["Single", "Multiple"]).default(current); - } - match &self.h { - None => Prompt::new("Select an annotation object", Accept::POINT).kw(&["Undo", "Mode"]), - Some((_, old)) => Prompt::new("Enter new text", Accept::TEXT).default(old.chars().take(40).collect::()), - } - } - fn input(&mut self, s: &mut Session, i: Input) -> Result { - if self.mode { - match i { - Input::Keyword(k) if k == "Single" => self.single = true, - Input::Keyword(k) if k == "Multiple" => self.single = false, - Input::Enter => {} - _ => return Err(other("Enter Single or Multiple.")), - } - self.mode = false; - return Ok(Step::Continue); - } - match (&self.h, i) { - (None, Input::Point(p)) => { - let h = curves::pick_at(s, p).ok_or_else(|| other("*Invalid selection*"))?; - let old = text_of(&curves::entity(s, h)?.kind).ok_or_else(|| other("Select a text, mtext, attribute definition or dimension."))?; - self.h = Some((h, old)); - Ok(Step::Continue) - } - (Some((h, _)), Input::Text(t) | Input::Keyword(t)) => { - let h = *h; - let before = curves::entity(s, h)?.kind; - set_text(s, h, &t)?; - self.edits.push((h, before)); - self.h = None; - Ok(if self.single { Step::Done } else { Step::Continue }) - } - (Some(_), Input::Enter) => { - self.h = None; - Ok(if self.single { Step::Done } else { Step::Continue }) - } - (None, Input::Enter) => Ok(Step::Done), - (None, Input::Keyword(k)) if k == "Undo" => { - match self.edits.pop() { - Some((h, before)) => set_kind(s, h, before)?, - None => s.echo("Everything has been undone"), - } - Ok(Step::Continue) - } - (None, Input::Keyword(k)) if k == "Mode" => { - self.mode = true; - Ok(Step::Continue) - } - _ => Ok(Step::Continue), - } - } -} +mod align; +mod blend; +mod fillet; +mod lengthen; +mod ncopy; +mod patharray; +mod pedit; +mod selop; +mod textedit; +mod trimext; + +use align::{AlignM, run_align}; +use blend::{BlendM, run_blend}; +use fillet::trim_for_fillet; +pub(crate) use fillet::{fillet_curves, fillet_polyline}; +pub(crate) use lengthen::LenMode; +use lengthen::{LengthenM, run_lengthen}; +use ncopy::{NcopyM, run_ncopy}; +pub(super) use patharray::PathArrayM; +use patharray::run_arraypath; +use pedit::{PPhase, PeditM, run_pedit}; +use selop::{SelKind, SelOp, run_chspace, run_flatten, run_reverse}; +use textedit::{TextEditM, run_textedit}; +use trimext::{ellipse_kind, ellipse_of}; +pub(crate) use trimext::{extend_other, trim_other}; #[cfg(test)] -#[path = "modify2_tests.rs"] mod tests; diff --git a/crates/engine/src/cmd/modify2/align.rs b/crates/engine/src/cmd/modify2/align.rs new file mode 100644 index 000000000..90e13887d --- /dev/null +++ b/crates/engine/src/cmd/modify2/align.rs @@ -0,0 +1,123 @@ +use super::*; + +// ===================================================================================== +// ALIGN +// ===================================================================================== + +pub(crate) fn align_matrix(s1: Vec2, d1: Vec2, pair2: Option<(Vec2, Vec2)>, scale: bool) -> Mat3 { + let mut m = Mat3::translate(d1 - s1); + if let Some((s2, d2)) = pair2 { + let vs = s2 - s1; + let vd = d2 - d1; + if vs.len() > 1e-12 && vd.len() > 1e-12 { + m = Mat3::rotate_about(d1, vd.angle() - vs.angle()).then_before(m); + if scale { + m = Mat3::scale_about(d1, vd.len() / vs.len()).then_before(m); + } + } + } + m +} + +pub(super) fn run_align(s: &mut Session, p: &Value) -> Result { + let hs = targets(s, p)?; + let s1 = point_req("align", p, "s1")?; + let d1 = point_req("align", p, "d1")?; + let pair2 = point_param(p, "s2").zip(point_param(p, "d2")); + let m = align_matrix(s1, d1, pair2, bool_or(p, "scale", false)); + let r = crate::cmd::modify::transform_entities(s, &hs, &m, false)?; + Ok(json!({ "handles": r.iter().map(|h| h.hex()).collect::>() })) +} + +#[derive(Default)] +pub(super) struct AlignM { + sel: SelectPhase, + objs: Vec, + pts: Vec, + ask_scale: bool, +} + +impl AlignM { + fn finish(&self, s: &mut Session, scale: bool) -> Result { + let (Some(s1), Some(d1)) = (self.pts.first().copied(), self.pts.get(1).copied()) else { return Ok(Step::Cancel) }; + let pair2 = self.pts.get(2).copied().zip(self.pts.get(3).copied()); + let m = align_matrix(s1, d1, pair2, scale); + crate::cmd::modify::transform_entities(s, &self.objs, &m, false)?; + s.set_selection(Vec::new()); + Ok(Step::Done) + } +} + +impl Interactive for AlignM { + fn name(&self) -> &'static str { + "ALIGN" + } + fn begin(&mut self, s: &mut Session) -> Result { + self.sel = SelectPhase::begin(s); + if self.sel.done { + self.objs = self.sel.picked.clone(); + } + Ok(Step::Continue) + } + fn prompt(&self, _s: &Session) -> Prompt { + if !self.sel.done { + return self.sel.prompt(); + } + if self.ask_scale { + return Prompt::new("Scale objects based on alignment points?", curves::KW).kw(&["Yes", "No"]).default("N"); + } + match self.pts.len() { + 0 => Prompt::new("Specify first source point", Accept::POINT), + 1 => Prompt::new("Specify first destination point", Accept::POINT).base_opt(self.pts.first().copied()), + 2 => Prompt::new("Specify second source point", Accept::POINT), + 3 => Prompt::new("Specify second destination point", Accept::POINT).base_opt(self.pts.get(2).copied()), + _ => Prompt::new("Specify third source point or ", Accept::POINT), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + if !self.sel.done { + return match self.sel.feed(s, &i)? { + SelOutcome::More => Ok(Step::Continue), + SelOutcome::Empty => Ok(Step::Cancel), + SelOutcome::Done(hs) => { + self.objs = hs; + Ok(Step::Continue) + } + }; + } + if self.ask_scale { + let yes = matches!(&i, Input::Keyword(k) | Input::Text(k) if k.to_ascii_lowercase().starts_with('y')); + return self.finish(s, yes); + } + match i { + Input::Point(p) => { + if self.pts.len() < 4 { + self.pts.push(p); + } else { + s.echo("Third pair ignored (2D align)."); + self.ask_scale = true; + } + Ok(Step::Continue) + } + Input::Enter => match self.pts.len() { + n if n >= 4 => { + self.ask_scale = true; + Ok(Step::Continue) + } + 2 | 3 => { + self.pts.truncate(2); + self.finish(s, false) + } + _ => Ok(Step::Cancel), + }, + _ => Ok(Step::Continue), + } + } + fn preview(&self, _s: &Session, c: Vec2) -> Vec { + match self.pts.as_slice() { + [a] => vec![line(*a, c)], + [_, _, a] => vec![line(*a, c)], + _ => Vec::new(), + } + } +} diff --git a/crates/engine/src/cmd/modify2/blend.rs b/crates/engine/src/cmd/modify2/blend.rs new file mode 100644 index 000000000..72dae0f43 --- /dev/null +++ b/crates/engine/src/cmd/modify2/blend.rs @@ -0,0 +1,117 @@ +use super::*; + +// ===================================================================================== +// BLEND +// ===================================================================================== + +/// Endpoint of a curve nearest `pick` and the outward unit tangent there. +fn curve_end(k: &EntityKind, pick: Vec2) -> Option<(Vec2, Vec2)> { + let c = Chain::of(k)?; + if c.closed { + return None; + } + let (a, b) = (c.start()?, c.end()?); + if pick.dist(b) <= pick.dist(a) { + let t = c.segs.last()?.tangent(1.0); + Some((b, t)) + } else { + let t = c.segs.first()?.tangent(0.0); + Some((a, -t)) + } +} + +pub(crate) fn blend_spline(e1: (Vec2, Vec2), e2: (Vec2, Vec2), smooth: bool) -> Option { + let (p1, t1) = e1; + let (p2, t2) = e2; + let d = p1.dist(p2); + if d < 1e-12 { + return None; + } + let ctrl = if smooth { + let k = d / 5.0; + vec![p1, p1 + t1 * k, p1 + t1 * (2.0 * k), p2 + t2 * (2.0 * k), p2 + t2 * k, p2] + } else { + let k = d / 3.0; + vec![p1, p1 + t1 * k, p2 + t2 * k, p2] + }; + let deg = ctrl.len() - 1; + Some(Spline::from_control(ctrl, deg)) +} + +fn blend(s: &mut Session, h1: Handle, p1: Vec2, h2: Handle, p2: Vec2, smooth: bool) -> Result { + let k1 = curves::entity(s, h1)?.kind; + let k2 = curves::entity(s, h2)?.kind; + let e1 = curve_end(&k1, p1).ok_or_else(|| other("Select an open line, arc, polyline, ellipse arc or spline."))?; + let e2 = curve_end(&k2, p2).ok_or_else(|| other("Select an open line, arc, polyline, ellipse arc or spline."))?; + let sp = blend_spline(e1, e2, smooth).ok_or_else(|| other("The ends coincide."))?; + s.add_entity(EntityKind::Spline(sp)) +} + +pub(super) fn run_blend(s: &mut Session, p: &Value) -> Result { + let h1 = curves::handle_param(p, "h1").ok_or_else(|| bad("blend", "`h1` is required"))?; + let h2 = curves::handle_param(p, "h2").ok_or_else(|| bad("blend", "`h2` is required"))?; + let p1 = point_req("blend", p, "p1")?; + let p2 = point_req("blend", p, "p2")?; + let smooth = str_param(p, "continuity").is_some_and(|c| c.eq_ignore_ascii_case("smooth")); + let h = blend(s, h1, p1, h2, p2, smooth)?; + Ok(json!({ "handle": h.hex() })) +} + +#[derive(Default)] +pub(super) struct BlendM { + first: Option<(Handle, Vec2)>, + smooth: bool, + asking: bool, +} + +impl Interactive for BlendM { + fn name(&self) -> &'static str { + "BLEND" + } + fn begin(&mut self, s: &mut Session) -> Result { + s.set_selection(Vec::new()); + s.echo(format!("Continuity = {}", if self.smooth { "Smooth" } else { "Tangent" })); + Ok(Step::Continue) + } + fn prompt(&self, _s: &Session) -> Prompt { + if self.asking { + return Prompt::new("Enter continuity", curves::KW).kw(&["Tangent", "Smooth"]).default("Tangent"); + } + match self.first { + None => Prompt::new("Select first object or", Accept::POINT).kw(&["CONtinuity"]), + Some(_) => Prompt::new("Select second point", Accept::POINT), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + if self.asking { + if let Input::Keyword(k) | Input::Text(k) = &i { + self.smooth = k.to_ascii_lowercase().starts_with('s'); + } + self.asking = false; + return Ok(Step::Continue); + } + match i { + Input::Keyword(_) => { + self.asking = true; + Ok(Step::Continue) + } + Input::Point(p) => { + let h = curves::pick_at(s, p).ok_or_else(|| other("*Invalid selection*"))?; + match self.first { + None => { + let k = curves::entity(s, h)?.kind; + curve_end(&k, p).ok_or_else(|| other("Select an open curve."))?; + self.first = Some((h, p)); + Ok(Step::Continue) + } + Some((h1, p1)) => { + blend(s, h1, p1, h, p, self.smooth)?; + Ok(Step::Done) + } + } + } + Input::Enter => Ok(Step::Cancel), + _ => Ok(Step::Continue), + } + } +} diff --git a/crates/engine/src/cmd/modify2/fillet.rs b/crates/engine/src/cmd/modify2/fillet.rs new file mode 100644 index 000000000..bdc5dded6 --- /dev/null +++ b/crates/engine/src/cmd/modify2/fillet.rs @@ -0,0 +1,181 @@ +use super::*; + +// ===================================================================================== +// FILLET extensions (arcs, circles, polylines) +// ===================================================================================== + +/// How a fillet operand is trimmed: the new kind and the arrival direction at the tangent point. +pub(super) fn trim_for_fillet(k: &EntityKind, pick: Vec2, t: Vec2) -> Option<(Option, Option)> { + match k { + EntityKind::Line(l) => { + let (a, b) = (l.a.xy(), l.b.xy()); + let keep = if (a - t).dot(pick - t) >= (b - t).dot(pick - t) { a } else { b }; + let dir = (t - keep).normalized(); + if dir == Vec2::ZERO { + return Some((None, None)); + } + Some((Some(if keep == a { line(a, t) } else { line(t, b) }), Some(dir))) + } + EntityKind::Arc(ar) => { + let g = Arc::new(ar.center.xy(), ar.radius, ar.start, ar.end); + let ta = g.center.angle_to(t); + let ccw_tan = (t - g.center).perp().normalized(); + let ap = g.center.angle_to(pick); + let keep_start_side = if g.contains_angle(ta) { + // Keep the side of the tangent point that holds the pick. + cadcraft_geom::angle_in_sweep(ap, g.start, ta) + } else { + // Extend the nearer end. + ccw_sweep(g.end, ta) <= ccw_sweep(ta, g.start) + }; + if keep_start_side { + let na = Arc::new(g.center, g.radius, g.start, ta); + Some((Some(arc(&na)), Some(ccw_tan))) + } else { + let na = Arc::new(g.center, g.radius, ta, g.end); + Some((Some(arc(&na)), Some(-ccw_tan))) + } + } + EntityKind::Circle(_) => Some((None, None)), + _ => None, + } +} + +/// Fillet two curves (lines, arcs, circles; elliptical arcs and splines in `curve_edits`) with +/// radius `r`. Returns the new arc, if any. +pub(crate) fn fillet_curves(s: &mut Session, h1: Handle, p1: Vec2, h2: Handle, p2: Vec2, r: f64) -> Result> { + if curves::is_locked(s, h1) || curves::is_locked(s, h2) { + return Err(other("The object is on a locked layer.")); + } + let e1 = curves::entity(s, h1)?; + let e2 = curves::entity(s, h2)?; + if [&e1, &e2].iter().any(|e| matches!(e.kind, EntityKind::Ellipse(_) | EntityKind::Spline(_))) { + return curve_edits::fillet_curvy(s, h1, p1, h2, p2, r, &e1, &e2); + } + let unsupported = || other("Fillet works on lines, arcs, circles, elliptical arcs and splines (and polylines with the Polyline option)."); + let o1 = TanObj::from_kind(&e1.kind, p1).filter(|_| matches!(e1.kind, EntityKind::Line(_) | EntityKind::Arc(_) | EntityKind::Circle(_))); + let o2 = TanObj::from_kind(&e2.kind, p2).filter(|_| matches!(e2.kind, EntityKind::Line(_) | EntityKind::Arc(_) | EntityKind::Circle(_))); + let (Some(o1), Some(o2)) = (o1, o2) else { return Err(unsupported()) }; + if h1 == h2 { + return Err(other("Cannot fillet an object to itself.")); + } + let (t1, t2, fc) = if r <= 1e-12 { + // Sharp corner: the intersection nearest the picks. + let hits: Vec = match (o1, o2) { + (TanObj::Line(a), TanObj::Line(b)) => line_line_infinite(a.a, a.b, b.a, b.b).map(|x| vec![x.0]).unwrap_or_default(), + (TanObj::Line(l), TanObj::Circle(c)) | (TanObj::Circle(c), TanObj::Line(l)) => line_circle(&l, &c).into_iter().map(|x| x.0).collect(), + (TanObj::Circle(a), TanObj::Circle(b)) => cadcraft_geom::circle_circle(&a, &b), + }; + let x = hits + .into_iter() + .min_by(|a, b| (a.dist(p1) + a.dist(p2)).total_cmp(&(b.dist(p1) + b.dist(p2)))) + .ok_or_else(|| other("The objects do not intersect."))?; + (x, x, None) + } else { + let c = curves::ttr(o1, p1, o2, p2, r).ok_or_else(|| other("Radius is too large."))?; + (o1.touch(&c), o2.touch(&c), Some(c)) + }; + let (n1, d1) = trim_for_fillet(&e1.kind, p1, t1).ok_or_else(unsupported)?; + let (n2, d2) = trim_for_fillet(&e2.kind, p2, t2).ok_or_else(unsupported)?; + let new_arc = fc.map(|c| { + let a1 = c.center.angle_to(t1); + let a2 = c.center.angle_to(t2); + let ccw1 = (t1 - c.center).perp().normalized(); + let ccw2 = (t2 - c.center).perp().normalized(); + let ccw = match (d1, d2) { + (Some(d), _) => ccw1.dot(d) > 0.0, + (None, Some(d)) => ccw2.dot(-d) > 0.0, + _ => ccw_sweep(a1, a2) <= std::f64::consts::PI, + }; + if ccw { Arc::new(c.center, c.radius, a1, a2) } else { Arc::new(c.center, c.radius, a2, a1) } + }); + if let Some(k) = n1 { + set_kind(s, h1, k)?; + } + if let Some(k) = n2 { + set_kind(s, h2, k)?; + } + match new_arc { + Some(a) => { + let space = s.doc()?.space_of(h1).unwrap_or_else(|| s.space()); + Ok(Some(push_entity(s, &space, e1.common.clone(), arc(&a))?)) + } + None => Ok(None), + } +} + +/// Fillet every corner between two straight segments of a polyline. Returns the count. +pub(crate) fn fillet_polyline(s: &mut Session, h: Handle, r: f64) -> Result { + if curves::is_locked(s, h) { + return Err(other("The object is on a locked layer.")); + } + let e = curves::entity(s, h)?; + let EntityKind::LwPolyline(pl) = &e.kind else { return Err(other("Select a 2D polyline.")) }; + let vs = &pl.vertices; + let n = vs.len(); + if n < 3 { + return Ok(0); + } + let at = |i: usize| vs.get(i % n).copied().unwrap_or_default(); + // Distance cut back from each corner (0 = not filleted). + let mut cut = vec![0.0f64; n]; + let mut turn = vec![0.0f64; n]; + for (i, c) in cut.iter_mut().enumerate() { + if !pl.closed && (i == 0 || i + 1 == n) { + continue; + } + let prev = at(i + n - 1); + let cur = at(i); + let next = at(i + 1); + if prev.bulge.abs() > 1e-12 || cur.bulge.abs() > 1e-12 { + continue; + } + let u1 = (prev.p - cur.p).normalized(); + let u2 = (next.p - cur.p).normalized(); + if u1 == Vec2::ZERO || u2 == Vec2::ZERO { + continue; + } + let half = u1.dot(u2).clamp(-1.0, 1.0).acos() / 2.0; + if half < 1e-9 || (std::f64::consts::FRAC_PI_2 - half).abs() < 1e-9 { + continue; + } + *c = if r <= 1e-12 { 0.0 } else { r / half.tan() }; + if let Some(t) = turn.get_mut(i) { + *t = (-u1).cross(u2).atan2((-u1).dot(u2)); + } + } + // Drop fillets that do not fit their segments. + for i in 0..n { + let j = (i + 1) % n; + let seg = at(i).p.dist(at(j).p); + let (ci, cj) = (cut.get(i).copied().unwrap_or(0.0), cut.get(j).copied().unwrap_or(0.0)); + if ci + cj > seg + 1e-9 { + if let Some(x) = cut.get_mut(i) { + *x = 0.0; + } + if let Some(x) = cut.get_mut(j) { + *x = 0.0; + } + } + } + let mut out: Vec = Vec::with_capacity(n * 2); + let mut count = 0; + for i in 0..n { + let v = at(i); + let c = cut.get(i).copied().unwrap_or(0.0); + if c <= 1e-12 { + out.push(v); + continue; + } + let prev = at(i + n - 1).p; + let next = at(i + 1).p; + let t1 = v.p + (prev - v.p).normalized() * c; + let t2 = v.p + (next - v.p).normalized() * c; + let b = cadcraft_geom::arc_to_bulge(turn.get(i).copied().unwrap_or(0.0)); + out.push(PolyVertex { p: t1, bulge: b, ..v }); + out.push(PolyVertex { p: t2, bulge: 0.0, ..v }); + count += 1; + } + set_kind(s, h, EntityKind::LwPolyline(LwPolyline { vertices: out, ..pl.clone() }))?; + Ok(count) +} diff --git a/crates/engine/src/cmd/modify2/lengthen.rs b/crates/engine/src/cmd/modify2/lengthen.rs new file mode 100644 index 000000000..d3021cb42 --- /dev/null +++ b/crates/engine/src/cmd/modify2/lengthen.rs @@ -0,0 +1,307 @@ +use super::trimext::ellipse_of; +use super::*; + +// ===================================================================================== +// LENGTHEN +// ===================================================================================== + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) enum LenMode { + Delta(f64), + DeltaAngle(f64), + Percent(f64), + Total(f64), + TotalAngle(f64), + To(Vec2), +} + +/// Current length (and included angle for arcs) of a curve. +fn measure_len(k: &EntityKind) -> Option<(f64, Option)> { + match k { + EntityKind::Arc(a) => { + let g = Arc::new(a.center.xy(), a.radius, a.start, a.end); + Some((g.len(), Some(g.sweep()))) + } + EntityKind::Ellipse(e) => Some((ellipse_of(e).length(), None)), + EntityKind::Spline(sp) if sp.is_valid() => Some((sp.length(), None)), + _ => Chain::of(k).map(|c| (c.len(), None)), + } +} + +/// Lengthen a line, arc, open polyline, elliptical arc or open spline at the end nearest `pick`. +pub(crate) fn lengthen_kind(k: &EntityKind, pick: Option, mode: LenMode) -> Result { + let too_short = || other("Lengthening would make the object zero length or negative."); + match k { + EntityKind::Line(l) => { + let (a, b) = (l.a.xy(), l.b.xy()); + let at_b = pick.is_none_or(|p| p.dist(b) <= p.dist(a)); + let (fixed, end) = if at_b { (a, b) } else { (b, a) }; + let len = fixed.dist(end); + let dir = (end - fixed).normalized(); + if dir == Vec2::ZERO { + return Err(other("Zero-length line.")); + } + let nl = match mode { + LenMode::Delta(d) => len + d, + LenMode::Percent(p) => len * p / 100.0, + LenMode::Total(t) => t, + LenMode::To(q) => (q - fixed).dot(dir), + LenMode::DeltaAngle(_) | LenMode::TotalAngle(_) => return Err(other("Angle options apply to arcs.")), + }; + if !(nl > 1e-12 && nl.is_finite()) { + return Err(too_short()); + } + let ne = fixed + dir * nl; + let mut out = l.clone(); + if at_b { + out.b = ne.to3(l.b.z); + } else { + out.a = ne.to3(l.a.z); + } + Ok(EntityKind::Line(out)) + } + EntityKind::Arc(ar) => { + let g = Arc::new(ar.center.xy(), ar.radius, ar.start, ar.end); + let at_end = pick.is_none_or(|p| p.dist(g.end_point()) <= p.dist(g.start_point())); + let sw = g.sweep(); + let r = g.radius.max(1e-300); + let ns = match mode { + LenMode::Delta(d) => sw + d / r, + LenMode::DeltaAngle(a) => sw + a, + LenMode::Percent(p) => sw * p / 100.0, + LenMode::Total(t) => t / r, + LenMode::TotalAngle(a) => a, + LenMode::To(q) => { + let a = g.center.angle_to(q); + if at_end { ccw_sweep(g.start, a) } else { ccw_sweep(a, g.end) } + } + }; + if !(ns > 1e-12 && ns < TAU - 1e-9) { + return Err(other("The new arc would be zero length or a full circle.")); + } + let na = if at_end { Arc::new(g.center, g.radius, g.start, g.start + ns) } else { Arc::new(g.center, g.radius, g.end - ns, g.end) }; + Ok(EntityKind::Arc(cadcraft_doc::Arc { radius: na.radius, start: na.start, end: na.end, ..ar.clone() })) + } + EntityKind::LwPolyline(p) if !p.closed && p.vertices.len() >= 2 => { + let total = Polyline { vertices: p.vertices.clone(), closed: false }.len(); + let mut vs = p.vertices.clone(); + let n = vs.len(); + let (first, last) = (vs.first().map(|v| v.p).unwrap_or_default(), vs.last().map(|v| v.p).unwrap_or_default()); + let at_end = pick.is_none_or(|q| q.dist(last) <= q.dist(first)); + if !at_end { + vs = curves::reverse_vertices(&vs, false); + } + let seg_start = vs.get(n - 2).copied().unwrap_or_default(); + if seg_start.bulge.abs() > 1e-12 { + return Err(other("The end segment is an arc; explode the polyline or use an open line end.")); + } + let end = vs.get(n - 1).map(|v| v.p).unwrap_or_default(); + let seg_len = seg_start.p.dist(end); + let dir = (end - seg_start.p).normalized(); + if dir == Vec2::ZERO { + return Err(other("Zero-length end segment.")); + } + let delta = match mode { + LenMode::Delta(d) => d, + LenMode::Percent(pc) => total * pc / 100.0 - total, + LenMode::Total(t) => t - total, + LenMode::To(q) => (q - seg_start.p).dot(dir) - seg_len, + LenMode::DeltaAngle(_) | LenMode::TotalAngle(_) => return Err(other("Angle options apply to arcs.")), + }; + let nl = seg_len + delta; + if !(nl > 1e-12 && nl.is_finite()) { + return Err(too_short()); + } + if let Some(v) = vs.get_mut(n - 1) { + v.p = seg_start.p + dir * nl; + } + if !at_end { + vs = curves::reverse_vertices(&vs, false); + } + Ok(EntityKind::LwPolyline(LwPolyline { vertices: vs, ..p.clone() })) + } + EntityKind::Ellipse(e) => curve_edits::lengthen_ellipse(e, pick, mode), + EntityKind::Spline(sp) => curve_edits::lengthen_spline(sp, pick, mode), + _ => Err(other("Select a line, arc, open polyline, elliptical arc or open spline.")), + } +} + +fn len_mode(p: &Value) -> Option { + let f = |k: &str| p.get(k).and_then(Value::as_f64).filter(|v| v.is_finite()); + if let Some(v) = f("delta") { + Some(LenMode::Delta(v)) + } else if let Some(v) = f("deltaAngle") { + Some(LenMode::DeltaAngle(v.to_radians())) + } else if let Some(v) = f("percent") { + Some(LenMode::Percent(v)) + } else if let Some(v) = f("total") { + Some(LenMode::Total(v)) + } else if let Some(v) = f("totalAngle") { + Some(LenMode::TotalAngle(v.to_radians())) + } else { + point_param(p, "to").map(LenMode::To) + } +} + +pub(super) fn run_lengthen(s: &mut Session, p: &Value) -> Result { + let h = targets(s, p)?.first().copied().ok_or_else(|| bad("lengthen", "`handle` is required"))?; + let e = curves::entity(s, h)?; + let Some(mode) = len_mode(p) else { + let (l, a) = measure_len(&e.kind).ok_or_else(|| other("Cannot measure that object."))?; + return Ok(json!({ "length": l, "angle": a.map(f64::to_degrees) })); + }; + if curves::is_locked(s, h) { + return Err(other("The object is on a locked layer.")); + } + let k = lengthen_kind(&e.kind, point_param(p, "pick"), mode)?; + let l = measure_len(&k).map(|x| x.0).unwrap_or(0.0); + set_kind(s, h, k)?; + Ok(json!({ "handle": h.hex(), "length": l })) +} + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +enum LPhase { + #[default] + Main, + Delta, + DeltaAngle, + Percent, + Total, + TotalAngle, + Change, + DynEnd(Handle, Vec2), +} + +#[derive(Default)] +pub(super) struct LengthenM { + phase: LPhase, + mode: Option, + dynamic: bool, + history: Vec<(Handle, EntityKind)>, +} + +impl Interactive for LengthenM { + fn name(&self) -> &'static str { + "LENGTHEN" + } + fn begin(&mut self, s: &mut Session) -> Result { + s.set_selection(Vec::new()); + Ok(Step::Continue) + } + fn prompt(&self, _s: &Session) -> Prompt { + match self.phase { + LPhase::Main => { + Prompt::new("Select an object to measure or", Accept::POINT).kw(&["DElta", "Percent", "Total", "DYnamic"]).default("DYnamic") + } + LPhase::Delta => Prompt::new("Enter delta length or", Accept::NUMBER).kw(&["Angle"]).default("0.0000"), + LPhase::DeltaAngle => Prompt::new("Enter delta angle", Accept::NUMBER).default("0"), + LPhase::Percent => Prompt::new("Enter percentage length", Accept::NUMBER).default("100.0000"), + LPhase::Total => Prompt::new("Specify total length or", Accept::NUMBER).kw(&["Angle"]).default("1.0000"), + LPhase::TotalAngle => Prompt::new("Specify total angle", Accept::NUMBER).default("57"), + LPhase::Change => Prompt::new("Select an object to change or", Accept::POINT).kw(&["Undo"]), + LPhase::DynEnd(..) => Prompt::new("Specify new end point", Accept::POINT), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + let num = |i: &Input| -> Option { if let Input::Text(t) = i { number(t) } else { None } }; + let au = s.angle_settings(); + let ang = |i: &Input| -> Option { if let Input::Text(t) = i { au.amount(t) } else { None } }; + match (self.phase, &i) { + (LPhase::Main, Input::Point(p)) => { + let h = curves::pick_at(s, *p).ok_or_else(|| other("*Invalid selection*"))?; + let k = curves::entity(s, h)?.kind; + match measure_len(&k) { + Some((l, Some(a))) => s.echo(format!("Current length: {l:.4}, included angle: {:.0}", a.to_degrees())), + Some((l, None)) => s.echo(format!("Current length: {l:.4}")), + None => s.echo("Cannot measure that object."), + } + Ok(Step::Continue) + } + (LPhase::Main, Input::Keyword(k)) => { + self.phase = match k.as_str() { + "DElta" => LPhase::Delta, + "Percent" => LPhase::Percent, + "Total" => LPhase::Total, + _ => { + self.dynamic = true; + LPhase::Change + } + }; + Ok(Step::Continue) + } + (LPhase::Main, Input::Enter) => { + self.dynamic = true; + self.phase = LPhase::Change; + Ok(Step::Continue) + } + (LPhase::Delta | LPhase::Total, Input::Keyword(_)) => { + self.phase = if self.phase == LPhase::Delta { LPhase::DeltaAngle } else { LPhase::TotalAngle }; + Ok(Step::Continue) + } + (LPhase::Delta, _) => { + self.mode = Some(LenMode::Delta(num(&i).ok_or_else(|| other("Requires a distance."))?)); + self.phase = LPhase::Change; + Ok(Step::Continue) + } + (LPhase::DeltaAngle, _) => { + self.mode = Some(LenMode::DeltaAngle(ang(&i).ok_or_else(|| other("Requires an angle."))?)); + self.phase = LPhase::Change; + Ok(Step::Continue) + } + (LPhase::Percent, _) => { + let v = num(&i).filter(|v| *v > 0.0).ok_or_else(|| other("Requires a positive percentage."))?; + self.mode = Some(LenMode::Percent(v)); + self.phase = LPhase::Change; + Ok(Step::Continue) + } + (LPhase::Total, _) => { + let v = num(&i).filter(|v| *v > 0.0).ok_or_else(|| other("Requires a positive length."))?; + self.mode = Some(LenMode::Total(v)); + self.phase = LPhase::Change; + Ok(Step::Continue) + } + (LPhase::TotalAngle, _) => { + self.mode = Some(LenMode::TotalAngle(ang(&i).ok_or_else(|| other("Requires an angle."))?)); + self.phase = LPhase::Change; + Ok(Step::Continue) + } + (LPhase::Change, Input::Point(p)) => { + let h = curves::pick_at(s, *p).ok_or_else(|| other("*Invalid selection*"))?; + if self.dynamic { + self.phase = LPhase::DynEnd(h, *p); + return Ok(Step::Continue); + } + let mode = self.mode.ok_or_else(|| other("No lengthen mode."))?; + let k = curves::entity(s, h)?.kind; + let nk = lengthen_kind(&k, Some(*p), mode)?; + set_kind(s, h, nk)?; + self.history.push((h, k)); + Ok(Step::Continue) + } + (LPhase::DynEnd(h, pick), Input::Point(p)) => { + let k = curves::entity(s, h)?.kind; + let nk = lengthen_kind(&k, Some(pick), LenMode::To(*p))?; + set_kind(s, h, nk)?; + self.history.push((h, k)); + self.phase = LPhase::Change; + Ok(Step::Continue) + } + (LPhase::Change, Input::Keyword(_)) => { + if let Some((h, k)) = self.history.pop() { + set_kind(s, h, k)?; + } + Ok(Step::Continue) + } + (_, Input::Enter) => Ok(Step::Done), + _ => Err(other("Invalid input.")), + } + } + fn preview(&self, s: &Session, c: Vec2) -> Vec { + if let LPhase::DynEnd(h, pick) = self.phase + && let Ok(e) = curves::entity(s, h) + { + return lengthen_kind(&e.kind, Some(pick), LenMode::To(c)).map(|k| vec![k]).unwrap_or_default(); + } + Vec::new() + } +} diff --git a/crates/engine/src/cmd/modify2/ncopy.rs b/crates/engine/src/cmd/modify2/ncopy.rs new file mode 100644 index 000000000..237110a5e --- /dev/null +++ b/crates/engine/src/cmd/modify2/ncopy.rs @@ -0,0 +1,151 @@ +use super::*; + +// ===================================================================================== +// NCOPY +// ===================================================================================== + +/// The leaf objects of a block reference in world coordinates (nested references expanded). +fn nested_leaves(d: &cadcraft_doc::Drawing, ins: &cadcraft_doc::Insert, m: &Mat3, depth: usize, out: &mut Vec<(Common, EntityKind)>) { + if depth >= MAX_BLOCK_DEPTH || out.len() >= MAX_GEN { + return; + } + let Some(b) = d.block(&ins.block) else { return }; + let mm = m.then_before(ins.transform(b.base.xy())); + for e in b.entities.iter() { + match &e.kind { + EntityKind::Insert(inner) => nested_leaves(d, inner, &mm, depth + 1, out), + EntityKind::AttDef(_) => {} + k => { + let mut k = k.clone(); + k.transform(&mm); + out.push((e.common.clone(), k)); + } + } + } +} + +fn leaves_of(s: &Session, h: Handle) -> Result> { + let e = curves::entity(s, h)?; + let EntityKind::Insert(ins) = &e.kind else { return Err(other("Select a block reference.")) }; + let mut out = Vec::new(); + nested_leaves(s.doc()?, ins, &Mat3::IDENTITY, 0, &mut out); + Ok(out) +} + +fn nearest_leaf(leaves: Vec<(Common, EntityKind)>, p: Vec2) -> Option<(Common, EntityKind)> { + leaves.into_iter().min_by(|a, b| curves::kind_distance(&a.1, p).total_cmp(&curves::kind_distance(&b.1, p))) +} + +fn place_copies(s: &mut Session, items: &[(Common, EntityKind)], delta: Vec2) -> Result> { + let space = s.space(); + let m = Mat3::translate(delta); + let mut out = Vec::new(); + for (c, k) in items { + let mut k = k.clone(); + k.transform(&m); + out.push(push_entity(s, &space, c.clone(), k)?); + } + Ok(out) +} + +pub(super) fn run_ncopy(s: &mut Session, p: &Value) -> Result { + let h = targets(s, p)?.first().copied().ok_or_else(|| bad("ncopy", "`handle` (block reference) is required"))?; + let leaves = leaves_of(s, h)?; + let items: Vec<(Common, EntityKind)> = match point_param(p, "pick") { + Some(q) => nearest_leaf(leaves, q).into_iter().collect(), + None => leaves, + }; + if items.is_empty() { + return Err(other("The block reference has no nested objects.")); + } + let delta = match (point_param(p, "delta"), point_param(p, "from"), point_param(p, "to")) { + (Some(d), _, _) => d, + (None, Some(a), Some(b)) => b - a, + _ => Vec2::ZERO, + }; + let hs = place_copies(s, &items, delta)?; + Ok(json!({ "handles": hs.iter().map(|h| h.hex()).collect::>() })) +} + +#[derive(Default)] +pub(super) struct NcopyM { + items: Vec<(Common, EntityKind)>, + selecting_done: bool, + base: Option, +} + +impl Interactive for NcopyM { + fn name(&self) -> &'static str { + "NCOPY" + } + fn begin(&mut self, s: &mut Session) -> Result { + s.set_selection(Vec::new()); + Ok(Step::Continue) + } + fn prompt(&self, _s: &Session) -> Prompt { + match (self.selecting_done, self.base) { + (false, _) => Prompt::new("Select nested objects to copy or", Accept::POINT).kw(&["Settings"]), + (true, None) => Prompt::new("Specify base point or", Accept::POINT).kw(&["Displacement", "Multiple"]).default("Displacement"), + (true, Some(b)) => Prompt::new("Specify second point or ", Accept::POINT).base(b), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + if !self.selecting_done { + return match i { + Input::Point(p) => { + let h = curves::pick_at(s, p).ok_or_else(|| other("*Invalid selection*"))?; + let leaf = nearest_leaf(leaves_of(s, h)?, p).ok_or_else(|| other("Nothing nested there."))?; + s.echo(format!("Nested object: {}", leaf.1.type_name())); + if self.items.len() < MAX_GEN { + self.items.push(leaf); + } + Ok(Step::Continue) + } + Input::Keyword(_) => { + s.echo("Insert mode: Copy (nested objects are copied as independent objects)."); + Ok(Step::Continue) + } + Input::Enter => { + if self.items.is_empty() { + return Ok(Step::Cancel); + } + self.selecting_done = true; + Ok(Step::Continue) + } + _ => Ok(Step::Continue), + }; + } + match (self.base, i) { + (None, Input::Point(p)) => { + self.base = Some(p); + Ok(Step::Continue) + } + (None, Input::Enter | Input::Keyword(_)) => { + place_copies(s, &self.items, Vec2::ZERO)?; + Ok(Step::Done) + } + (Some(b), Input::Point(p)) => { + place_copies(s, &self.items, p - b)?; + Ok(Step::Done) + } + (Some(b), Input::Enter) => { + place_copies(s, &self.items, b)?; + Ok(Step::Done) + } + _ => Ok(Step::Continue), + } + } + fn preview(&self, _s: &Session, c: Vec2) -> Vec { + let Some(b) = self.base else { return Vec::new() }; + let m = Mat3::translate(c - b); + self.items + .iter() + .take(500) + .map(|(_, k)| { + let mut k = k.clone(); + k.transform(&m); + k + }) + .collect() + } +} diff --git a/crates/engine/src/cmd/modify2/patharray.rs b/crates/engine/src/cmd/modify2/patharray.rs new file mode 100644 index 000000000..e8f03fc6b --- /dev/null +++ b/crates/engine/src/cmd/modify2/patharray.rs @@ -0,0 +1,122 @@ +use super::*; + +// ===================================================================================== +// ARRAYPATH (non-associative copies along a curve) +// ===================================================================================== + +pub(crate) fn arraypath( + s: &mut Session, + hs: &[Handle], + path: Handle, + count: Option, + spacing: Option, + align: bool, +) -> Result> { + let pk = curves::entity(s, path)?.kind; + let c = Chain::of(&pk).ok_or_else(|| other("Select a line, arc, circle, polyline, ellipse or spline as the path."))?; + let l = c.len(); + if l <= 1e-12 { + return Err(other("The path has no length.")); + } + let objs: Vec = hs.iter().copied().filter(|h| *h != path).collect(); + if objs.is_empty() { + return Err(other("Select objects to array.")); + } + let (n, step) = match (count, spacing) { + (_, Some(sp)) if sp.is_finite() && sp > 0.0 => (((l / sp).floor() as usize).saturating_add(1), sp), + (Some(n), _) => { + let n = n.max(1); + (n, if c.closed { l / n as f64 } else { l / (n.max(2) - 1) as f64 }) + } + _ => (6, if c.closed { l / 6.0 } else { l / 5.0 }), + }; + crate::cmd::array::check_size(s, n as u64, objs.len())?; + let Some((p0, t0)) = c.at_length(0.0) else { return Ok(Vec::new()) }; + let mut out = Vec::new(); + for i in 1..n { + let Some((p, t)) = c.at_length(step * i as f64) else { continue }; + let mut m = Mat3::translate(p - p0); + if align { + m = Mat3::rotate_about(p, t.angle() - t0.angle()).then_before(m); + } + out.extend(crate::cmd::modify::transform_entities(s, &objs, &m, true)?); + } + Ok(out) +} + +pub(super) fn run_arraypath(s: &mut Session, p: &Value) -> Result { + let hs = targets(s, p)?; + let path = curves::handle_param(p, "path").ok_or_else(|| bad("arraypath", "`path` (handle) is required"))?; + let count = p.get("count").and_then(Value::as_u64).map(|n| usize::try_from(n).unwrap_or(usize::MAX)); + let spacing = p.get("spacing").and_then(Value::as_f64); + let r = arraypath(s, &hs, path, count, spacing, bool_or(p, "align", true))?; + Ok(json!({ "handles": r.iter().map(|h| h.hex()).collect::>() })) +} + +#[derive(Default)] +pub(in crate::cmd) struct PathArrayM { + sel: SelectPhase, + objs: Vec, + path: Option, +} + +impl PathArrayM { + /// Start at the path prompt with the objects already selected (ARRAY > PAth). + pub(in crate::cmd) fn with_objects(objs: Vec) -> Self { + PathArrayM { sel: SelectPhase { picked: objs.clone(), done: true, ..SelectPhase::default() }, objs, path: None } + } +} + +impl Interactive for PathArrayM { + fn name(&self) -> &'static str { + "ARRAYPATH" + } + fn begin(&mut self, s: &mut Session) -> Result { + self.sel = SelectPhase::begin(s); + if self.sel.done { + self.objs = self.sel.picked.clone(); + } + Ok(Step::Continue) + } + fn prompt(&self, _s: &Session) -> Prompt { + if !self.sel.done { + return self.sel.prompt(); + } + match self.path { + None => Prompt::new("Select path curve", Accept::POINT), + Some(_) => Prompt::new("Enter number of items along path", Accept::NUMBER).default("6"), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + if !self.sel.done { + return match self.sel.feed(s, &i)? { + SelOutcome::More => Ok(Step::Continue), + SelOutcome::Empty => Ok(Step::Cancel), + SelOutcome::Done(hs) => { + self.objs = hs; + Ok(Step::Continue) + } + }; + } + match (self.path, i) { + (None, Input::Point(p)) => { + let h = curves::pick_at(s, p).ok_or_else(|| other("*Invalid selection*"))?; + Chain::of(&curves::entity(s, h)?.kind).ok_or_else(|| other("Select a curve as the path."))?; + self.path = Some(h); + Ok(Step::Continue) + } + (Some(path), inp @ (Input::Text(_) | Input::Enter)) => { + let n = match &inp { + Input::Text(t) => number(t).filter(|n| *n >= 1.0).ok_or_else(|| other("Requires a count of 1 or more."))? as usize, + _ => 6, + }; + arraypath(s, &self.objs, path, Some(n), None, true)?; + s.set_selection(Vec::new()); + s.echo(format!("Type = Path Associative = No ({n} items)")); + Ok(Step::Done) + } + (None, Input::Enter) => Ok(Step::Cancel), + _ => Ok(Step::Continue), + } + } +} diff --git a/crates/engine/src/cmd/modify2/pedit.rs b/crates/engine/src/cmd/modify2/pedit.rs new file mode 100644 index 000000000..399759cf4 --- /dev/null +++ b/crates/engine/src/cmd/modify2/pedit.rs @@ -0,0 +1,587 @@ +use super::*; + +/// Fit curve: a tangent-continuous pair of arcs (biarc) per segment through every vertex, +/// following the tangent directions given per vertex (Edit vertex Tangent). +pub(crate) fn fit_vertices(vs: &[PolyVertex], closed: bool, tans: &[Option]) -> Vec { + let pts: Vec = vs.iter().map(|v| v.p).collect(); + let n = pts.len(); + if n < 3 { + return vs.iter().map(|v| PolyVertex { bulge: 0.0, ..*v }).collect(); + } + let at = |i: usize| pts.get(i).copied().unwrap_or_default(); + let tangent = |i: usize| -> Vec2 { + let (prev, next) = if closed { + (at((i + n - 1) % n), at((i + 1) % n)) + } else if i == 0 { + (at(0), at(1)) + } else if i + 1 == n { + (at(n - 2), at(n - 1)) + } else { + (at(i - 1), at(i + 1)) + }; + let t = (next - prev).normalized(); + if t == Vec2::ZERO { Vec2::X } else { t } + }; + // Open ends: mirror the neighbouring tangent across the chord for a natural end. + let tan_at = |i: usize| -> Vec2 { + if let Some(Some(t)) = tans.get(i) { + return *t; + } + if closed || (i != 0 && i + 1 != n) { + return tangent(i); + } + let (a, b, tb) = if i == 0 { (at(0), at(1), tangent(1)) } else { (at(n - 1), at(n - 2), -tangent(n - 2)) }; + let c = (b - a).normalized(); + let m = c * (2.0 * tb.dot(c)) - tb; + let m = if i == 0 { m } else { -m }; + if m.len() < 1e-12 { c } else { m.normalized() } + }; + let count = if closed { n } else { n - 1 }; + let mut out = Vec::with_capacity(count * 2 + 1); + for i in 0..count { + let p0 = at(i); + let p1 = at((i + 1) % n); + let t0 = tan_at(i); + let t1 = tan_at((i + 1) % n); + let v = p1 - p0; + let tsum = t0 + t1; + let denom = 2.0 * (1.0 - t0.dot(t1)); + let d = if denom.abs() < 1e-12 { + let vt = v.dot(t1); + if vt.abs() < 1e-12 { v.len() / 2.0 } else { v.len2() / (4.0 * vt) } + } else { + let vt = v.dot(tsum); + (-vt + (vt * vt + denom * v.len2()).max(0.0).sqrt()) / denom + }; + let pm = (p0 + t0 * d + p1 - t1 * d) / 2.0; + let b1 = curves::bulge_from_tangent(p0, pm, t0); + let b2 = -curves::bulge_from_tangent(p1, pm, -t1); + let w = vs.get(i).copied().unwrap_or_default(); + out.push(PolyVertex { p: p0, bulge: if b1.is_finite() { b1 } else { 0.0 }, start_width: w.start_width, end_width: w.end_width }); + out.push(PolyVertex { p: pm, bulge: if b2.is_finite() { b2 } else { 0.0 }, start_width: w.end_width, end_width: w.end_width }); + } + if !closed { + out.push(PolyVertex::new(at(n - 1))); + } + out +} + +/// Spline curve: a cubic B-spline with the vertices as its frame (control points). +pub(crate) fn spline_vertices(vs: &[PolyVertex], closed: bool, segs: usize) -> Vec { + let pts: Vec = vs.iter().map(|v| v.p).collect(); + let n = pts.len(); + if n < 3 { + return vs.to_vec(); + } + let segs = segs.clamp(1, 64).min((MAX_GEN / n).max(1)); + let mut out: Vec = Vec::new(); + if closed { + // Uniform periodic cubic B-spline. + let at = |i: usize| pts.get(i % n).copied().unwrap_or_default(); + for i in 0..n { + let (p0, p1, p2, p3) = (at(i + n - 1), at(i), at(i + 1), at(i + 2)); + for k in 0..segs { + let t = k as f64 / segs as f64; + let (t2, t3) = (t * t, t * t * t); + let b0 = (1.0 - t).powi(3) / 6.0; + let b1 = (3.0 * t3 - 6.0 * t2 + 4.0) / 6.0; + let b2 = (-3.0 * t3 + 3.0 * t2 + 3.0 * t + 1.0) / 6.0; + let b3 = t3 / 6.0; + out.push(p0 * b0 + p1 * b1 + p2 * b2 + p3 * b3); + } + } + } else { + let sp = Spline::from_control(pts.clone(), 3); + let (lo, hi) = sp.domain(); + let m = (segs * (n - 1)).min(MAX_GEN); + for k in 0..=m { + out.push(sp.eval(lo + (hi - lo) * k as f64 / m as f64)); + } + } + out.into_iter().map(PolyVertex::new).collect() +} + +/// Join connected lines/arcs/open polylines onto the ends of polyline `h` (kept in place). +pub(crate) fn pedit_join(s: &mut Session, h: Handle, others: &[Handle], fuzz: f64) -> Result { + let e = curves::entity(s, h)?; + let mut base = as_lwpoly(&e.kind).ok_or_else(|| other("Select a polyline."))?; + if base.closed { + return Err(other("Cannot join to a closed polyline.")); + } + let mut chain = Chain::of(&EntityKind::LwPolyline(base.clone())).unwrap_or_default(); + let tol = fuzz.max(1e-6); + let mut pool: Vec<(Handle, Chain)> = Vec::new(); + for o in others { + if *o == h { + continue; + } + let Ok(oe) = curves::entity(s, *o) else { continue }; + let ok = matches!(&oe.kind, EntityKind::Line(_) | EntityKind::Arc(_)) || matches!(&oe.kind, EntityKind::LwPolyline(p) if !p.closed); + if ok && let Some(c) = Chain::of(&oe.kind) { + pool.push((*o, c)); + } + } + let mut joined = Vec::new(); + while let (Some(st), Some(en)) = (chain.start(), chain.end()) { + let Some(i) = pool + .iter() + .position(|(_, c)| c.start().zip(c.end()).is_some_and(|(a, b)| a.near(en, tol) || b.near(en, tol) || a.near(st, tol) || b.near(st, tol))) + else { + break; + }; + let (oh, c) = pool.remove(i); + let (Some(a), Some(b)) = (c.start(), c.end()) else { continue }; + if a.near(en, tol) { + chain.segs.extend(c.segs); + } else if b.near(en, tol) { + chain.segs.extend(c.reversed().segs); + } else if b.near(st, tol) { + let mut v = c.segs; + v.extend(chain.segs); + chain.segs = v; + } else { + let mut v = c.reversed().segs; + v.extend(chain.segs); + chain.segs = v; + } + joined.push(oh); + } + if joined.is_empty() { + return Ok(0); + } + chain.closed = chain.start().zip(chain.end()).is_some_and(|(a, b)| a.near(b, tol)) && chain.segs.len() > 1; + base.vertices = chain.vertices(); + base.closed = chain.closed; + let d = s.doc_mut()?; + d.modify_entity(h, |e| e.kind = EntityKind::LwPolyline(base))?; + for o in &joined { + d.remove_entity(*o); + } + Ok(joined.len()) +} + +fn pedit_apply(s: &mut Session, h: Handle, opt: &str, p: &Value) -> Result { + let e = curves::entity(s, h)?; + if curves::is_locked(s, h) { + return Err(other("The object is on a locked layer.")); + } + let mut pl = match &e.kind { + EntityKind::LwPolyline(pl) => pl.clone(), + EntityKind::Line(_) | EntityKind::Arc(_) if bool_or(p, "convert", true) => as_lwpoly(&e.kind).ok_or_else(|| other("Cannot convert."))?, + _ => return Err(other("Object selected is not a polyline.")), + }; + match opt { + "close" | "c" => pl.closed = pl.vertices.len() > 1, + "open" | "o" => pl.closed = false, + "width" | "w" => { + let w = f64_req("pedit", p, "width")?; + if w < 0.0 { + return Err(bad("pedit", "width must be non-negative")); + } + pl.const_width = w; + for v in &mut pl.vertices { + v.start_width = 0.0; + v.end_width = 0.0; + } + } + "fit" | "f" => pl.vertices = fit_vertices(&pl.vertices, pl.closed, &pedit_vertex::tangent_param(p, pl.vertices.len())?), + "spline" | "s" => { + let segs = s.doc()?.header.i64("SPLINESEGS", 8).clamp(1, 64) as usize; + pl.vertices = spline_vertices(&pl.vertices, pl.closed, segs); + } + "decurve" | "d" => pl.vertices.iter_mut().for_each(|v| v.bulge = 0.0), + "reverse" | "r" => pl.vertices = curves::reverse_vertices(&pl.vertices, pl.closed), + "ltypegen" | "l" => pl.plinegen = bool_or(p, "on", !pl.plinegen), + "join" | "j" => { + set_kind(s, h, EntityKind::LwPolyline(pl))?; + let others: Vec = p + .get("handles2") + .or_else(|| p.get("join")) + .and_then(Value::as_array) + .map(|a| a.iter().filter_map(curves::handle_value).collect()) + .unwrap_or_default(); + let n = pedit_join(s, h, &others, f64_or(p, "fuzz", 0.0))?; + return Ok(json!({ "handle": h.hex(), "joined": n })); + } + "vertex" | "e" => { + let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("pedit", "`index` is required"))? as usize; + let n = pl.vertices.len(); + if i >= n { + return Err(bad("pedit", "vertex index out of range")); + } + match str_param(p, "action").unwrap_or("move") { + "move" => { + let to = point_req("pedit", p, "to")?; + if let Some(v) = pl.vertices.get_mut(i) { + v.p = to; + } + } + "insert" => { + let to = point_req("pedit", p, "to")?; + if n >= MAX_GEN { + return Err(bad("pedit", "too many vertices")); + } + if let Some(v) = pl.vertices.get_mut(i) { + v.bulge = 0.0; + } + pl.vertices.insert(i + 1, PolyVertex::new(to)); + } + "straighten" => { + let j = p.get("index2").and_then(Value::as_u64).map_or(i, |j| j as usize); + pedit_vertex::straighten(&mut pl, i, j)?; + } + "break" => { + let j = p.get("index2").and_then(Value::as_u64).map_or(i, |j| j as usize); + let hs = pedit_vertex::break_poly(s, h, &pl, i, j)?; + return Ok(json!({ "handle": h.hex(), "handles": hs.iter().map(|h| h.hex()).collect::>() })); + } + // The display is always current; tangents only exist within an interactive PEDIT. + "regen" => {} + "width" => { + let sw = f64_or(p, "startWidth", 0.0).max(0.0); + let ew = f64_or(p, "endWidth", sw).max(0.0); + if let Some(v) = pl.vertices.get_mut(i) { + v.start_width = sw; + v.end_width = ew; + } + } + _ => return Err(bad("pedit", "action must be move, insert, straighten, break, width or regen")), + } + } + _ => return Err(bad("pedit", "unknown option")), + } + set_kind(s, h, EntityKind::LwPolyline(pl))?; + Ok(json!({ "handle": h.hex() })) +} + +pub(super) fn run_pedit(s: &mut Session, p: &Value) -> Result { + let hs = targets(s, p)?; + if hs.is_empty() { + return Err(bad("pedit", "`handle` is required")); + } + let opt = str_param(p, "option").ok_or_else(|| bad("pedit", "`option` is required"))?.to_ascii_lowercase(); + let mut out = Vec::new(); + for h in hs { + out.push(pedit_apply(s, h, &opt, p)?); + } + Ok(if out.len() == 1 { out.pop().unwrap_or(Value::Null) } else { json!({ "results": out }) }) +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(super) enum PPhase { + #[default] + Select, + Convert, + Main, + Width, + Join, + Vertex, + VMove, + VInsert, + VWidthStart, + VWidthEnd, + /// The second marker of Break / Straighten. + VMark, + VTangent, +} + +#[derive(Default)] +pub(super) struct PeditM { + pub(super) phase: PPhase, + pub(super) h: Option, + pub(super) history: Vec, + original: Option>, + join: SelectPhase, + pub(super) vertex: usize, + vwidth: f64, + /// Break / Straighten: the second marked vertex, and which of the two runs. + pub(super) mark2: usize, + pub(super) breaking: bool, + /// Edit vertex Tangent directions by vertex position, used by Fit. + pub(super) tangents: Vec<(Vec2, Vec2)>, + /// Pieces split off by Break, with the history length after it (erased again by Undo). + pub(super) split_off: Vec<(usize, Vec)>, +} + +impl PeditM { + pub(super) fn poly(&self, s: &Session) -> Option { + let e = s.doc().ok()?.entity(self.h?)?.clone(); + match &e.kind { + EntityKind::LwPolyline(p) => Some(p.clone()), + _ => None, + } + } + pub(super) fn snapshot(&mut self, s: &Session) { + if let Some(h) = self.h + && let Ok(e) = curves::entity(s, h) + { + self.history.push(e.kind); + } + } + pub(super) fn apply(&mut self, s: &mut Session, opt: &str, p: Value) -> Result<()> { + let h = self.h.ok_or_else(|| other("No polyline."))?; + self.snapshot(s); + if let Err(e) = pedit_apply(s, h, opt, &p) { + self.history.pop(); + return Err(e); + } + Ok(()) + } +} + +impl Interactive for PeditM { + fn name(&self) -> &'static str { + "PEDIT" + } + fn begin(&mut self, s: &mut Session) -> Result { + let sel = s.selection(); + if let [h] = sel.as_slice() { + let k = curves::entity(s, *h)?.kind; + match k { + EntityKind::LwPolyline(_) => { + self.h = Some(*h); + self.original = self.poly(s).map(|p| p.vertices); + self.phase = PPhase::Main; + } + EntityKind::Line(_) | EntityKind::Arc(_) => { + self.h = Some(*h); + self.phase = PPhase::Convert; + } + _ => {} + } + } + Ok(Step::Continue) + } + fn prompt(&self, s: &Session) -> Prompt { + match self.phase { + PPhase::Select => Prompt::new("Select polyline or", Accept::POINT).kw(&["Multiple"]), + PPhase::Convert => { + Prompt::new("Object selected is not a polyline. Do you want to turn it into one?", curves::KW).kw(&["Yes", "No"]).default("Y") + } + PPhase::Main => { + let closed = self.poly(s).is_some_and(|p| p.closed); + let first = if closed { "Open" } else { "Close" }; + Prompt::new("Enter an option", curves::KW).kw(&[ + first, + "Join", + "Width", + "Edit vertex", + "Fit", + "Spline", + "Decurve", + "Ltype gen", + "Reverse", + "Undo", + ]) + } + PPhase::Width => Prompt::new("Specify new width for all segments", Accept::NUMBER), + PPhase::Join => self.join.prompt(), + PPhase::Vertex => Prompt::new("Enter a vertex editing option", curves::KW) + .kw(&["Next", "Previous", "Break", "Insert", "Move", "Regen", "Straighten", "Tangent", "Width", "eXit"]) + .default("N"), + PPhase::VMove => Prompt::new("Specify new location for marked vertex", Accept::POINT), + PPhase::VInsert => Prompt::new("Specify location for new vertex", Accept::POINT), + PPhase::VWidthStart => Prompt::new("Specify starting width for next segment", Accept::NUMBER), + PPhase::VWidthEnd => Prompt::new("Specify ending width for next segment", Accept::NUMBER).default(format!("{:.4}", self.vwidth)), + PPhase::VMark => Prompt::new("Enter an option", curves::KW).kw(&["Next", "Previous", "Go", "eXit"]).default("N"), + PPhase::VTangent => Prompt::new("Specify direction of vertex tangent", Accept::POINT_OR_NUMBER) + .base_opt(self.poly(s).and_then(|p| p.vertices.get(self.vertex).map(|v| v.p))), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + let kw = |i: &Input| match i { + Input::Keyword(k) | Input::Text(k) => Some(k.trim().to_ascii_lowercase()), + _ => None, + }; + match self.phase { + PPhase::Select => match i { + Input::Point(p) => { + let h = curves::pick_at(s, p).ok_or_else(|| other("*Invalid selection*"))?; + let k = curves::entity(s, h)?.kind; + self.h = Some(h); + match k { + EntityKind::LwPolyline(pl) => { + self.original = Some(pl.vertices); + self.phase = PPhase::Main; + } + EntityKind::Line(_) | EntityKind::Arc(_) => self.phase = PPhase::Convert, + _ => { + self.h = None; + return Err(other("Object selected is not a polyline.")); + } + } + s.set_selection(vec![h]); + Ok(Step::Continue) + } + Input::Keyword(_) => Err(other("Select one polyline (Multiple is available through the JSON form).")), + Input::Enter => Ok(Step::Cancel), + _ => Ok(Step::Continue), + }, + PPhase::Convert => { + let no = kw(&i).is_some_and(|k| k.starts_with('n')); + if no { + return Ok(Step::Done); + } + let h = self.h.ok_or_else(|| other("No object."))?; + let k = curves::entity(s, h)?.kind; + let pl = as_lwpoly(&k).ok_or_else(|| other("Cannot convert."))?; + self.original = Some(pl.vertices.clone()); + set_kind(s, h, EntityKind::LwPolyline(pl))?; + self.phase = PPhase::Main; + Ok(Step::Continue) + } + PPhase::Main => { + let Some(k) = kw(&i) else { + if i == Input::Enter { + s.set_selection(Vec::new()); + return Ok(Step::Done); + } + return Ok(Step::Continue); + }; + match k.as_str() { + "open" | "o" => self.apply(s, "open", json!({}))?, + "close" | "c" => self.apply(s, "close", json!({}))?, + "join" | "j" => { + self.join = SelectPhase::default(); + self.phase = PPhase::Join; + } + "width" | "w" => self.phase = PPhase::Width, + "edit vertex" | "e" => { + self.vertex = 0; + self.phase = PPhase::Vertex; + } + "fit" | "f" => self.apply(s, "fit", json!({ "tangents": self.tangents_json(s) }))?, + "spline" | "s" => self.apply(s, "spline", json!({}))?, + "decurve" | "d" => { + // Within the session, Decurve restores the frame before Fit/Spline. + let h = self.h.ok_or_else(|| other("No polyline."))?; + self.snapshot(s); + match (self.poly(s), self.original.clone()) { + (Some(mut pl), Some(orig)) => { + pl.vertices = orig.into_iter().map(|v| PolyVertex { bulge: 0.0, ..v }).collect(); + set_kind(s, h, EntityKind::LwPolyline(pl))?; + } + _ => { + self.history.pop(); + self.apply(s, "decurve", json!({}))?; + } + } + } + "ltype gen" | "l" => self.apply(s, "ltypegen", json!({}))?, + "reverse" | "r" => self.apply(s, "reverse", json!({}))?, + "undo" | "u" => { + self.undo_split(s)?; + if let (Some(h), Some(k)) = (self.h, self.history.pop()) { + set_kind(s, h, k)?; + } + } + _ => return Err(other("Invalid option keyword.")), + } + Ok(Step::Continue) + } + PPhase::Width => { + if let Input::Text(t) = &i { + let w = number(t).filter(|w| *w >= 0.0).ok_or_else(|| other("Requires a non-negative width."))?; + self.apply(s, "width", json!({ "width": w }))?; + } + self.phase = PPhase::Main; + Ok(Step::Continue) + } + PPhase::Join => match self.join.feed(s, &i)? { + SelOutcome::More => Ok(Step::Continue), + SelOutcome::Empty => { + self.phase = PPhase::Main; + Ok(Step::Continue) + } + SelOutcome::Done(hs) => { + let h = self.h.ok_or_else(|| other("No polyline."))?; + self.snapshot(s); + let n = pedit_join(s, h, &hs, 0.0)?; + s.echo(format!("{n} segments added to polyline")); + s.set_selection(vec![h]); + self.phase = PPhase::Main; + Ok(Step::Continue) + } + }, + PPhase::Vertex => { + let n = self.poly(s).map(|p| p.vertices.len()).unwrap_or(0).max(1); + let k = kw(&i).unwrap_or_else(|| if i == Input::Enter { "n".into() } else { String::new() }); + match k.as_str() { + "next" | "n" => self.vertex = (self.vertex + 1) % n, + "previous" | "p" => self.vertex = (self.vertex + n - 1) % n, + "move" | "m" => self.phase = PPhase::VMove, + "insert" | "i" => self.phase = PPhase::VInsert, + "straighten" | "s" | "break" | "b" => { + self.breaking = k.starts_with('b'); + self.mark2 = self.vertex; + self.phase = PPhase::VMark; + } + "tangent" | "t" => self.phase = PPhase::VTangent, + // The display is always current. + "regen" | "r" => {} + "width" | "w" => self.phase = PPhase::VWidthStart, + "exit" | "x" => self.phase = PPhase::Main, + "" => {} + _ => return Err(other("Invalid option keyword.")), + } + Ok(Step::Continue) + } + PPhase::VMove | PPhase::VInsert => { + if let Input::Point(p) = i { + let action = if self.phase == PPhase::VMove { "move" } else { "insert" }; + self.apply(s, "vertex", json!({ "action": action, "index": self.vertex, "to": [p.x, p.y] }))?; + if action == "insert" { + self.vertex += 1; + } + } + self.phase = PPhase::Vertex; + Ok(Step::Continue) + } + PPhase::VWidthStart => { + if let Input::Text(t) = &i { + self.vwidth = number(t).filter(|w| *w >= 0.0).ok_or_else(|| other("Requires a non-negative width."))?; + self.phase = PPhase::VWidthEnd; + } else { + self.phase = PPhase::Vertex; + } + Ok(Step::Continue) + } + PPhase::VMark => { + let k = kw(&i).unwrap_or_else(|| if i == Input::Enter { "n".into() } else { String::new() }); + self.mark_input(s, &k)?; + Ok(Step::Continue) + } + PPhase::VTangent => { + self.tangent_input(s, &i)?; + Ok(Step::Continue) + } + PPhase::VWidthEnd => { + let ew = match &i { + Input::Text(t) => number(t).filter(|w| *w >= 0.0).ok_or_else(|| other("Requires a non-negative width."))?, + _ => self.vwidth, + }; + self.apply(s, "vertex", json!({ "action": "width", "index": self.vertex, "startWidth": self.vwidth, "endWidth": ew }))?; + self.phase = PPhase::Vertex; + Ok(Step::Continue) + } + } + } + fn preview(&self, s: &Session, c: Vec2) -> Vec { + if !matches!( + self.phase, + PPhase::Vertex | PPhase::VMove | PPhase::VInsert | PPhase::VWidthStart | PPhase::VWidthEnd | PPhase::VMark | PPhase::VTangent + ) { + return Vec::new(); + } + let Some(v) = self.poly(s).and_then(|p| p.vertices.get(self.vertex).copied()) else { return Vec::new() }; + let k = s.pixel_size() * 8.0; + let mut out = vec![line(v.p - Vec2::new(k, k), v.p + Vec2::new(k, k)), line(v.p - Vec2::new(k, -k), v.p + Vec2::new(k, -k))]; + if matches!(self.phase, PPhase::VMove | PPhase::VInsert | PPhase::VTangent) { + out.push(line(v.p, c)); + } + if let (PPhase::VMark, Some(m)) = (self.phase, self.poly(s).and_then(|p| p.vertices.get(self.mark2).copied())) { + out.extend([line(m.p - Vec2::new(k, k), m.p + Vec2::new(k, k)), line(m.p - Vec2::new(k, -k), m.p + Vec2::new(k, -k))]); + } + out + } +} diff --git a/crates/engine/src/cmd/modify2/selop.rs b/crates/engine/src/cmd/modify2/selop.rs new file mode 100644 index 000000000..90330bbbf --- /dev/null +++ b/crates/engine/src/cmd/modify2/selop.rs @@ -0,0 +1,281 @@ +use super::*; + +// ===================================================================================== +// REVERSE / CHSPACE / FLATTEN (select, then act) +// ===================================================================================== + +pub(crate) fn reverse_kind(k: &EntityKind) -> Option { + Some(match k { + EntityKind::Line(l) => EntityKind::Line(cadcraft_doc::Line { a: l.b, b: l.a }), + EntityKind::LwPolyline(p) => EntityKind::LwPolyline(LwPolyline { vertices: curves::reverse_vertices(&p.vertices, p.closed), ..p.clone() }), + EntityKind::Polyline3d(p) => { + EntityKind::Polyline3d(cadcraft_doc::Polyline3d { points: p.points.iter().rev().copied().collect(), closed: p.closed }) + } + EntityKind::Spline(sp) => EntityKind::Spline(curves::reverse_spline(sp)), + _ => return None, + }) +} + +fn reverse(s: &mut Session, hs: &[Handle]) -> Result { + let mut n = 0; + for h in hs { + if curves::is_locked(s, *h) { + continue; + } + let Ok(e) = curves::entity(s, *h) else { continue }; + if let Some(k) = reverse_kind(&e.kind) { + set_kind(s, *h, k)?; + n += 1; + } + } + Ok(n) +} + +pub(super) fn run_reverse(s: &mut Session, p: &Value) -> Result { + let hs = targets(s, p)?; + let n = reverse(s, &hs)?; + Ok(json!({ "reversed": n })) +} + +/// Zero every z coordinate; 3D polylines become 2D polylines. +pub(crate) fn flatten_kind(k: &mut EntityKind) { + let z = |p: &mut cadcraft_geom::Vec3| p.z = 0.0; + match k { + EntityKind::Line(l) => { + z(&mut l.a); + z(&mut l.b); + } + EntityKind::Point(p) => z(&mut p.p), + EntityKind::Circle(c) => z(&mut c.center), + EntityKind::Arc(a) => z(&mut a.center), + EntityKind::Ellipse(e) => { + z(&mut e.center); + z(&mut e.major); + } + EntityKind::Helix(h) => *k = EntityKind::Spline(h.spline()), + EntityKind::LwPolyline(p) => p.elevation = 0.0, + EntityKind::Polyline3d(p) => { + *k = lwpoly(p.points.iter().map(|q| PolyVertex::new(q.xy())).collect(), p.closed); + } + EntityKind::Ray(r) | EntityKind::XLine(r) => { + z(&mut r.base); + r.dir.z = 0.0; + r.dir = r.dir.normalized(); + } + EntityKind::Text(t) => { + z(&mut t.insert); + t.align_pt.iter_mut().for_each(z); + } + EntityKind::AttDef(a) => { + z(&mut a.text.insert); + a.text.align_pt.iter_mut().for_each(z); + if let Some(ml) = &mut a.multiline { + z(&mut ml.mtext.insert); + } + } + EntityKind::MText(t) => z(&mut t.insert), + EntityKind::Insert(i) => { + z(&mut i.insert); + for a in &mut i.attribs { + z(&mut a.text.insert); + a.text.align_pt.iter_mut().for_each(z); + if let Some(ml) = &mut a.multiline { + z(&mut ml.mtext.insert); + } + } + } + EntityKind::Dimension(d) => { + for p in [&mut d.defpt, &mut d.text_mid, &mut d.p13, &mut d.p14, &mut d.p15, &mut d.p16] { + z(p); + } + d.block = None; + } + EntityKind::Leader(l) => l.vertices.iter_mut().for_each(z), + EntityKind::MLine(m) => m.vertices.iter_mut().for_each(|v| z(&mut v.p)), + EntityKind::MLeader(m) => { + m.leaders.iter_mut().for_each(|l| l.iter_mut().for_each(z)); + z(&mut m.landing); + m.block.iter_mut().for_each(|b| z(&mut b.insert)); + } + EntityKind::Hatch(h) => h.elevation = 0.0, + EntityKind::Solid(so) | EntityKind::Trace(so) => so.corners.iter_mut().for_each(z), + EntityKind::Face3d(f) => f.corners.iter_mut().for_each(z), + EntityKind::Region(_) => {} + EntityKind::Image(i) => { + z(&mut i.insert); + i.u.z = 0.0; + i.v.z = 0.0; + } + EntityKind::Table(t) => z(&mut t.insert), + EntityKind::Tolerance(t) => { + z(&mut t.insert); + t.dir.z = 0.0; + } + EntityKind::Spline(_) | EntityKind::Viewport(_) | EntityKind::Wipeout(_) | EntityKind::Unknown(_) => {} + } +} + +fn flatten(s: &mut Session, hs: &[Handle]) -> Result { + let mut n = 0; + for h in hs { + if curves::is_locked(s, *h) { + continue; + } + if s.doc()?.entity(*h).is_none() { + continue; + } + s.doc_mut()?.modify_entity(*h, |e| { + flatten_kind(&mut e.kind); + e.common.thickness = 0.0; + })?; + n += 1; + } + Ok(n) +} + +pub(super) fn run_flatten(s: &mut Session, p: &Value) -> Result { + let hs = targets(s, p)?; + let n = flatten(s, &hs)?; + Ok(json!({ "flattened": n })) +} + +/// Model↔paper transform through a viewport: paper = center + (model − viewCenter) / scale. +fn viewport_of(s: &Session, layout: &str, vp: Option) -> Option { + let d = s.doc().ok()?; + if let Some(h) = vp + && let Some(e) = d.entity(h) + && let EntityKind::Viewport(v) = &e.kind + { + return Some(v.clone()); + } + let st = d.space(&Space::Paper(layout.to_string()))?; + st.iter().find_map(|e| match &e.kind { + EntityKind::Viewport(v) if v.height > 1e-12 && v.view_height > 1e-12 && v.id != 1 => Some(v.clone()), + _ => None, + }) +} + +fn chspace(s: &mut Session, hs: &[Handle], to: Option<&str>, vp: Option) -> Result<(usize, Space, Space)> { + let from = s.space(); + let (target, layout) = match (&from, to) { + (Space::Model, t) => { + let name = match t { + Some(n) if !n.eq_ignore_ascii_case("model") => n.to_string(), + _ => { + let d = s.doc()?; + let mut ls: Vec<_> = d.layouts.iter().collect(); + ls.sort_by_key(|l| l.tab_order); + ls.first().map(|l| l.name.clone()).ok_or_else(|| other("No layout to move objects to."))? + } + }; + if s.doc()?.layout(&name).is_none() { + return Err(other(format!("Layout \"{name}\" not found."))); + } + let real = s.doc()?.layout(&name).map(|l| l.name.clone()).unwrap_or(name); + (Space::Paper(real.clone()), real) + } + (Space::Paper(n), _) => (Space::Model, n.clone()), + }; + let m = match viewport_of(s, &layout, vp) { + Some(v) => { + let k = v.view_height / v.height; + let to_paper = Mat3::translate(v.center.xy()).then_before(Mat3::scale(1.0 / k, 1.0 / k)).then_before(Mat3::translate(-v.view_center)); + if target == Space::Model { to_paper.inverse().unwrap_or(Mat3::IDENTITY) } else { to_paper } + } + None => Mat3::IDENTITY, + }; + let mut n = 0; + for h in hs { + if curves::is_locked(s, *h) { + continue; + } + let d = s.doc_mut()?; + if d.space_of(*h).as_ref() != Some(&from) { + continue; + } + let Some(e) = d.entity(*h).map(|e| (**e).clone()) else { continue }; + if matches!(e.kind, EntityKind::Viewport(_)) { + continue; + } + let mut e = e; + e.kind.transform(&m); + d.remove_entity(*h); + if let Some(st) = d.space_mut(&target) { + st.push(e); + n += 1; + } + } + s.set_selection(Vec::new()); + Ok((n, from, target)) +} + +pub(super) fn run_chspace(s: &mut Session, p: &Value) -> Result { + let hs = targets(s, p)?; + let (n, _, target) = chspace(s, &hs, str_param(p, "to"), curves::handle_param(p, "viewport"))?; + Ok(json!({ "moved": n, "to": target.layout().unwrap_or("model") })) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum SelKind { + Reverse, + Chspace, + Flatten, +} + +pub(super) struct SelOp { + kind: SelKind, + sel: SelectPhase, +} + +impl SelOp { + pub(super) fn new(kind: SelKind) -> Self { + SelOp { kind, sel: SelectPhase::default() } + } + fn act(&self, s: &mut Session, hs: &[Handle]) -> Result { + match self.kind { + SelKind::Reverse => { + let n = reverse(s, hs)?; + s.echo(format!("{n} object(s) direction has been reversed.")); + } + SelKind::Flatten => { + let n = flatten(s, hs)?; + s.echo(format!("{n} object(s) flattened.")); + } + SelKind::Chspace => { + let (n, from, to) = chspace(s, hs, None, None)?; + let nm = |sp: &Space| if *sp == Space::Model { "MODEL" } else { "PAPER" }; + s.echo(format!("{n} object(s) changed from {} space to {} space.", nm(&from), nm(&to))); + } + } + s.set_selection(Vec::new()); + Ok(Step::Done) + } +} + +impl Interactive for SelOp { + fn name(&self) -> &'static str { + match self.kind { + SelKind::Reverse => "REVERSE", + SelKind::Chspace => "CHSPACE", + SelKind::Flatten => "FLATTEN", + } + } + fn begin(&mut self, s: &mut Session) -> Result { + self.sel = SelectPhase::begin(s); + if self.sel.done { + let hs = self.sel.picked.clone(); + return self.act(s, &hs); + } + Ok(Step::Continue) + } + fn prompt(&self, _s: &Session) -> Prompt { + self.sel.prompt() + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + match self.sel.feed(s, &i)? { + SelOutcome::More => Ok(Step::Continue), + SelOutcome::Empty => Ok(Step::Cancel), + SelOutcome::Done(hs) => self.act(s, &hs), + } + } +} diff --git a/crates/engine/src/cmd/modify2_tests.rs b/crates/engine/src/cmd/modify2/tests.rs similarity index 100% rename from crates/engine/src/cmd/modify2_tests.rs rename to crates/engine/src/cmd/modify2/tests.rs diff --git a/crates/engine/src/cmd/modify2/textedit.rs b/crates/engine/src/cmd/modify2/textedit.rs new file mode 100644 index 000000000..d160d992f --- /dev/null +++ b/crates/engine/src/cmd/modify2/textedit.rs @@ -0,0 +1,115 @@ +use super::*; + +// ===================================================================================== +// TEXTEDIT +// ===================================================================================== + +fn text_of(k: &EntityKind) -> Option { + match k { + EntityKind::Text(t) => Some(t.value.clone()), + EntityKind::AttDef(a) => Some(a.text.value.clone()), + EntityKind::MText(t) => Some(t.contents.replace("\\P", "\n")), + EntityKind::Dimension(d) => Some(if d.text.is_empty() { "<>".into() } else { d.text.clone() }), + _ => None, + } +} + +pub(crate) fn set_text(s: &mut Session, h: Handle, text: &str) -> Result<()> { + if curves::is_locked(s, h) { + return Err(other("The object is on a locked layer.")); + } + let mut k = curves::entity(s, h)?.kind; + match &mut k { + EntityKind::Text(t) => t.value = text.to_string(), + EntityKind::AttDef(a) => a.text.value = text.to_string(), + EntityKind::MText(t) => t.contents = text.replace('\n', "\\P"), + EntityKind::Dimension(d) => { + d.text = if text == "<>" { String::new() } else { text.to_string() }; + d.block = None; + } + _ => return Err(other("Select a text, mtext, attribute definition or dimension.")), + } + set_kind(s, h, k) +} + +pub(super) fn run_textedit(s: &mut Session, p: &Value) -> Result { + let h = targets(s, p)?.first().copied().ok_or_else(|| bad("textedit", "`handle` is required"))?; + let text = str_param(p, "text").ok_or_else(|| bad("textedit", "`text` is required"))?; + if text.len() > 1_000_000 { + return Err(bad("textedit", "text is too long")); + } + set_text(s, h, text)?; + Ok(json!({ "handle": h.hex() })) +} + +#[derive(Default)] +pub(super) struct TextEditM { + h: Option<(Handle, String)>, + /// Edits made in this run, newest last: each object as it was before (the Undo option). + edits: Vec<(Handle, EntityKind)>, + /// Single mode: end after one edit (the Mode option; Multiple by default). + single: bool, + /// Asking for the edit mode. + mode: bool, +} + +impl Interactive for TextEditM { + fn name(&self) -> &'static str { + "TEXTEDIT" + } + fn prompt(&self, _s: &Session) -> Prompt { + if self.mode { + let current = if self.single { "Single" } else { "Multiple" }; + return Prompt::new("Enter a text edit mode option", curves::KW).kw(&["Single", "Multiple"]).default(current); + } + match &self.h { + None => Prompt::new("Select an annotation object", Accept::POINT).kw(&["Undo", "Mode"]), + Some((_, old)) => Prompt::new("Enter new text", Accept::TEXT).default(old.chars().take(40).collect::()), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + if self.mode { + match i { + Input::Keyword(k) if k == "Single" => self.single = true, + Input::Keyword(k) if k == "Multiple" => self.single = false, + Input::Enter => {} + _ => return Err(other("Enter Single or Multiple.")), + } + self.mode = false; + return Ok(Step::Continue); + } + match (&self.h, i) { + (None, Input::Point(p)) => { + let h = curves::pick_at(s, p).ok_or_else(|| other("*Invalid selection*"))?; + let old = text_of(&curves::entity(s, h)?.kind).ok_or_else(|| other("Select a text, mtext, attribute definition or dimension."))?; + self.h = Some((h, old)); + Ok(Step::Continue) + } + (Some((h, _)), Input::Text(t) | Input::Keyword(t)) => { + let h = *h; + let before = curves::entity(s, h)?.kind; + set_text(s, h, &t)?; + self.edits.push((h, before)); + self.h = None; + Ok(if self.single { Step::Done } else { Step::Continue }) + } + (Some(_), Input::Enter) => { + self.h = None; + Ok(if self.single { Step::Done } else { Step::Continue }) + } + (None, Input::Enter) => Ok(Step::Done), + (None, Input::Keyword(k)) if k == "Undo" => { + match self.edits.pop() { + Some((h, before)) => set_kind(s, h, before)?, + None => s.echo("Everything has been undone"), + } + Ok(Step::Continue) + } + (None, Input::Keyword(k)) if k == "Mode" => { + self.mode = true; + Ok(Step::Continue) + } + _ => Ok(Step::Continue), + } + } +} diff --git a/crates/engine/src/cmd/modify2/trimext.rs b/crates/engine/src/cmd/modify2/trimext.rs new file mode 100644 index 000000000..f5258dc43 --- /dev/null +++ b/crates/engine/src/cmd/modify2/trimext.rs @@ -0,0 +1,176 @@ +use super::*; + +// ===================================================================================== +// TRIM / EXTEND for ellipses and splines +// ===================================================================================== + +pub(super) fn ellipse_of(e: &cadcraft_doc::Ellipse) -> Ellipse { + Ellipse { center: e.center.xy(), major: e.major.xy(), ratio: e.ratio, start: e.start, end: e.end } +} + +pub(super) fn ellipse_kind(e: &Ellipse) -> EntityKind { + EntityKind::Ellipse(cadcraft_doc::Ellipse { center: v3(e.center), major: v3(e.major), ratio: e.ratio, start: e.start, end: e.end }) +} + +/// Intersections of a sampled curve `eval(t)` (parameters `ts`) with the cutting segments, +/// as curve parameters refined by bisection on the exact curve. +fn hits_on(eval: &dyn Fn(f64) -> Vec2, ts: &[f64], cut: &[Segment]) -> Vec { + let mut out = Vec::new(); + for w in ts.windows(2) { + let (Some(t0), Some(t1)) = (w.first().copied(), w.get(1).copied()) else { continue }; + let (a, b) = (eval(t0), eval(t1)); + let sg = Segment::Line(Line::new(a, b)); + for c in cut { + for x in intersect_ext(&sg, c, false) { + let f = Line::new(a, b).param_of(x).clamp(0.0, 1.0); + out.push(refine_hit(eval, c, t0, t1, t0 + (t1 - t0) * f)); + } + } + } + out +} + +/// Signed side of `p` relative to a cutting segment (line side, or inside/outside an arc's circle). +fn side_of(c: &Segment, p: Vec2) -> f64 { + match c { + Segment::Line(l) => l.side(p) / l.len().max(1e-300), + Segment::Arc { arc, .. } => p.dist(arc.center) - arc.radius, + } +} + +fn refine_hit(eval: &dyn Fn(f64) -> Vec2, c: &Segment, t0: f64, t1: f64, guess: f64) -> f64 { + let (mut a, mut b) = (t0, t1); + let (mut fa, fb) = (side_of(c, eval(a)), side_of(c, eval(b))); + if !(fa.is_finite() && fb.is_finite()) || fa * fb > 0.0 { + return guess; + } + for _ in 0..60 { + let m = (a + b) / 2.0; + let fm = side_of(c, eval(m)); + if fa * fm <= 0.0 { + b = m; + } else { + a = m; + fa = fm; + } + } + (a + b) / 2.0 +} + +fn ellipse_samples(ge: &Ellipse) -> Vec { + let n = 720usize; + let sw = ge.sweep(); + (0..=n).map(|i| ge.start + sw * i as f64 / n as f64).collect() +} + +/// Trim objects that modify.rs does not handle itself (ellipses and splines). +pub(crate) fn trim_other(k: &EntityKind, cut: &[Segment], pick: Vec2) -> Result> { + let none = || other("Object does not intersect a cutting edge."); + match k { + EntityKind::Ellipse(e) => { + let ge = ellipse_of(e); + let full = Ellipse { start: 0.0, end: TAU, ..ge }; + let eval = |t: f64| full.at_param(t); + let params: Vec = hits_on(&eval, &ellipse_samples(&ge), cut).into_iter().map(norm_angle).collect(); + if ge.is_full() { + let mut ps = params; + ps.sort_by(f64::total_cmp); + ps.dedup_by(|a, b| (*a - *b).abs() < 1e-9); + if ps.len() < 2 { + return Err(other("Ellipse must intersect cutting edges at two or more points.")); + } + let tp = full.param_of(pick); + let n = ps.len(); + for i in 0..n { + let (Some(a0), Some(a1)) = (ps.get(i).copied(), ps.get((i + 1) % n).copied()) else { continue }; + if cadcraft_geom::angle_in_sweep(tp, a0, a1) { + return Ok(vec![ellipse_kind(&Ellipse { start: a1, end: a0, ..ge })]); + } + } + return Err(none()); + } + let sweep = ge.sweep(); + let rel = |t: f64| norm_angle(t - ge.start); + let mut ts: Vec = params.into_iter().map(rel).filter(|t| *t > 1e-9 && *t < sweep - 1e-9).collect(); + ts.sort_by(f64::total_cmp); + let tp = rel(full.param_of(pick)).min(sweep); + let lo = ts.iter().copied().filter(|t| *t < tp).fold(0.0, f64::max); + let hi = ts.iter().copied().filter(|t| *t > tp).fold(sweep, f64::min); + if lo == 0.0 && hi == sweep { + return Err(none()); + } + let mut v = Vec::new(); + if lo > 0.0 { + v.push(ellipse_kind(&Ellipse { end: norm_angle(ge.start + lo), ..ge })); + } + if hi < sweep { + v.push(ellipse_kind(&Ellipse { start: norm_angle(ge.start + hi), ..ge })); + } + Ok(v) + } + EntityKind::Spline(sp) => { + if !sp.is_valid() { + return Err(other("Invalid spline.")); + } + let (lo_d, hi_d) = sp.domain(); + let n = 512; + let params: Vec = (0..=n).map(|i| lo_d + (hi_d - lo_d) * i as f64 / n as f64).collect(); + let eval = |t: f64| sp.eval(t); + let mut ts: Vec = + hits_on(&eval, ¶ms, cut).into_iter().filter(|t| *t > lo_d + 1e-9 * (hi_d - lo_d) && *t < hi_d - 1e-9 * (hi_d - lo_d)).collect(); + ts.sort_by(f64::total_cmp); + ts.dedup_by(|a, b| (*a - *b).abs() < 1e-9); + let tp = curves::spline_param_of(sp, pick); + if sp.closed { + return curve_edits::trim_closed_spline(sp, &ts, tp); + } + let lo = ts.iter().copied().filter(|t| *t < tp).fold(lo_d, f64::max); + let hi = ts.iter().copied().filter(|t| *t > tp).fold(hi_d, f64::min); + if lo == lo_d && hi == hi_d { + return Err(none()); + } + let mut v = Vec::new(); + if lo > lo_d + && let Some(a) = curves::sub_spline(sp, lo_d, lo) + { + v.push(EntityKind::Spline(a)); + } + if hi < hi_d + && let Some(b) = curves::sub_spline(sp, hi, hi_d) + { + v.push(EntityKind::Spline(b)); + } + Ok(v) + } + _ => Err(other("Cannot trim that object.")), + } +} + +/// Extend objects that modify.rs does not handle itself (elliptical arcs and open splines). +pub(crate) fn extend_other(k: &EntityKind, cut: &[Segment], pick: Vec2) -> Result { + match k { + EntityKind::Ellipse(e) => { + let ge = ellipse_of(e); + if ge.is_full() { + return Err(other("Cannot extend a closed ellipse.")); + } + let full = Ellipse { start: 0.0, end: TAU, ..ge }; + let from_end = pick.dist(ge.at_param(ge.end)) < pick.dist(ge.at_param(ge.start)); + let eval = |t: f64| full.at_param(t); + let hits: Vec = hits_on(&eval, &ellipse_samples(&full), cut) + .into_iter() + .map(norm_angle) + .filter(|t| !cadcraft_geom::angle_in_sweep(*t, ge.start, ge.end)) + .collect(); + let t = if from_end { + hits.iter().copied().min_by(|x, y| ccw_sweep(ge.end, *x).total_cmp(&ccw_sweep(ge.end, *y))) + } else { + hits.iter().copied().min_by(|x, y| ccw_sweep(*x, ge.start).total_cmp(&ccw_sweep(*y, ge.start))) + }; + let t = t.ok_or_else(|| other("Object does not intersect an edge."))?; + Ok(ellipse_kind(&if from_end { Ellipse { end: t, ..ge } } else { Ellipse { start: t, ..ge } })) + } + EntityKind::Spline(sp) => curve_edits::extend_spline(sp, cut, pick), + _ => Err(other("Cannot extend that object.")), + } +} diff --git a/crates/engine/src/cmd/txt2mtxt.rs b/crates/engine/src/cmd/txt2mtxt.rs index 29d90d23e..6682ee4d2 100644 --- a/crates/engine/src/cmd/txt2mtxt.rs +++ b/crates/engine/src/cmd/txt2mtxt.rs @@ -334,5 +334,4 @@ impl Interactive for Txt2MtxtM { } #[cfg(test)] -#[path = "txt2mtxt_tests.rs"] mod tests; diff --git a/crates/engine/src/cmd/txt2mtxt_tests.rs b/crates/engine/src/cmd/txt2mtxt/tests.rs similarity index 100% rename from crates/engine/src/cmd/txt2mtxt_tests.rs rename to crates/engine/src/cmd/txt2mtxt/tests.rs diff --git a/crates/engine/src/cmd/view/vports.rs b/crates/engine/src/cmd/view/vports.rs index 5b95f720a..f040133fc 100644 --- a/crates/engine/src/cmd/view/vports.rs +++ b/crates/engine/src/cmd/view/vports.rs @@ -6,11 +6,12 @@ //! tiles is an undo step. The session keeps each tile's view ([`crate::ModelTiles`]); the current //! tile shows the model space view itself, so ZOOM, PAN and every pick work in it unchanged. The //! UI draws the tiles from [`Session::model_tiles`] and hands clicks in other tiles to -//! [`Session::click_tile`]. In a layout -VPORTS is MVIEW. +//! [`Session::click_tile`]. In a layout -VPORTS is MVIEW. View > Viewports > Named Viewports... +//! restores or deletes a saved configuration. use std::sync::Arc; -use cadcraft_doc::{Drawing, Space, VportConfig, VportTile}; +use cadcraft_doc::{Drawing, EntityKind, Space, VportConfig, VportTile}; use cadcraft_geom::Vec2; use serde_json::{Value, json}; @@ -37,6 +38,12 @@ pub fn specs() -> Vec { ) .interactive(vports_factory), CommandSpec::new("vports.activate", "Set Current Viewport", run_activate).params("{index: tile number from 1}").noundo(), + CommandSpec::new("vports.named", "Named Viewports...", run_named) + .menu(&["View", "Viewports", "Named Viewports..."]) + .params( + "{name?: a saved viewport configuration, delete?: bool, p1?, p2?, layout?} (no name: → {current, configurations: [{name, viewports}]}; the Model tab shows the configuration's tiles, a layout gets one viewport per tile over p1-p2 or the printable area; typed or from the menu: the Named Viewports dialog)", + ) + .interactive(named_factory), ] } @@ -388,7 +395,59 @@ fn run_activate(s: &mut Session, p: &Value) -> Result { summary(s) } +/// View > Viewports > Named Viewports...: without a name, the saved configurations and the one +/// the Model tab shows; with one, restore it (its tiles in the Model tab, MVIEW Restore in a +/// layout) or, with `delete`, delete it. +fn run_named(s: &mut Session, p: &Value) -> Result { + let delete = match p.get("delete") { + None | Some(Value::Null) => false, + Some(Value::Bool(b)) => *b, + Some(_) => return Err(bad("vports.named", "`delete` must be true or false")), + }; + let Some(name) = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()) else { + let configs: Vec = + s.doc()?.vports.iter().filter(|c| !c.is_active()).map(|c| json!({ "name": c.name, "viewports": c.tiles.len() })).collect(); + return Ok(json!({ "current": s.viewport_configuration(), "configurations": configs })); + }; + let saved = s + .doc()? + .vports + .iter() + .find(|c| !c.is_active() && c.name.eq_ignore_ascii_case(name)) + .map(|c| c.name.clone()) + .ok_or_else(|| EngineError::Other(format!("Could not find viewport configuration \"{}\".", name.chars().take(64).collect::())))?; + if delete { + s.doc_mut()?.vports.retain(|c| c.is_active() || c.name != saved); + return Ok(json!({ "deleted": saved, "message": format!("Viewport configuration \"{saved}\" deleted.") })); + } + if s.layout_space() != Space::Model || p.get("layout").is_some() { + let mview = find_command("mview").ok_or_else(|| EngineError::UnknownCommand("mview".into()))?; + let mut p = p.clone(); + if let Some(o) = p.as_object_mut() { + o.remove("delete"); + o.insert("option".into(), json!("restore")); + o.insert("name".into(), json!(saved)); + } + return (mview.run)(s, &p); + } + run_vports(s, &json!({ "option": "restore", "name": saved })) +} + +/// Whether two configurations have their tiles in the same places (in any order). +fn same_places(a: &[VportTile], b: &[VportTile]) -> bool { + let near = |p: Vec2, q: Vec2| (p.x - q.x).abs() < EPS && (p.y - q.y).abs() < EPS; + a.len() == b.len() && a.iter().all(|t| b.iter().any(|u| near(t.min, u.min) && near(t.max, u.max))) +} + impl Session { + /// The saved viewport configuration whose tiles are where the Model tab's tiles are now (the + /// first such one in the drawing), if any. + pub fn viewport_configuration(&self) -> Option { + let st = self.state().ok()?; + let (tiles, _) = live(st, self.viewport_px); + st.doc.vports.iter().filter(|c| !c.is_active()).find(|c| same_places(&c.tiles, &tiles)).map(|c| c.name.clone()) + } + /// The model space tiles to draw (empty: one view, or not in the Model tab). pub fn model_tiles(&self) -> Vec { let Ok(st) = self.state() else { return Vec::new() }; @@ -598,3 +657,33 @@ impl VportsM { } } } + +/// Named Viewports... without the dialog: -VPORTS Restore in the Model tab, MVIEW Restore in a +/// layout. +fn named_factory(s: &Session) -> Result> { + Ok(Box::new(Restoring(vports_factory(s)?))) +} + +/// -VPORTS or MVIEW started at its Restore option. +struct Restoring(Box); + +impl Interactive for Restoring { + fn name(&self) -> &'static str { + self.0.name() + } + fn begin(&mut self, s: &mut Session) -> Result { + match self.0.begin(s)? { + Step::Continue => self.0.input(s, Input::Keyword("Restore".into())), + other => Ok(other), + } + } + fn prompt(&self, s: &Session) -> Prompt { + self.0.prompt(s) + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + self.0.input(s, i) + } + fn preview(&self, s: &Session, cursor: Vec2) -> Vec { + self.0.preview(s, cursor) + } +} diff --git a/crates/engine/src/doc_state.rs b/crates/engine/src/doc_state.rs new file mode 100644 index 000000000..8934df872 --- /dev/null +++ b/crates/engine/src/doc_state.rs @@ -0,0 +1,226 @@ +use super::view::{ModelTiles, View, usable_view}; +use super::*; + +#[derive(Clone, Debug)] +pub struct Snapshot { + pub label: String, + pub doc: Arc, + pub selection: Vec, +} + +/// One open drawing. +#[derive(Clone, Debug)] +pub struct DocState { + pub doc: Arc, + pub undo: Vec, + pub redo: Vec, + /// UNDO marks, BEgin/End group and Control (see [`undoctl`]). + pub undo_ctl: undoctl::UndoCtl, + pub selection: Vec, + pub previous_selection: Vec, + pub path: Option, + pub title: String, + pub saved: Arc, + pub revision: u64, + pub space: Space, + /// Tiled model viewports (VPORTS). + pub tiles: ModelTiles, + /// MSPACE: the layout viewport whose model space is being edited (None = paper space). + pub mspace: Option, + pub views: Vec<(Space, View)>, + pub view_history: Vec, + pub uid: u64, + /// The drawing is saved as binary DXF (it was opened from one, or last saved as one). + pub binary_dxf: bool, + /// The Block Editor (BEDIT), when it is open: `doc` is then the editor's drawing, and the + /// drawing itself waits in it ([`DocState::drawing`]). + pub block_edit: Option, +} + +static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + +impl DocState { + pub fn new(doc: Drawing, title: &str, path: Option) -> Self { + let doc = Arc::new(doc); + DocState { + saved: doc.clone(), + doc, + undo: Vec::new(), + redo: Vec::new(), + undo_ctl: undoctl::UndoCtl::default(), + selection: Vec::new(), + previous_selection: Vec::new(), + path, + title: title.into(), + revision: 1, + space: Space::Model, + tiles: ModelTiles::default(), + mspace: None, + views: Vec::new(), + view_history: Vec::new(), + uid: NEXT_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed), + binary_dxf: false, + block_edit: None, + } + } + /// The drawing has changes not saved to its file, or the Block Editor has changes not yet + /// saved into its block. + pub fn is_dirty(&self) -> bool { + !Arc::ptr_eq(self.drawing(), &self.saved) || self.block_unsaved() + } + /// After undo/redo replaced the drawing (`before` is the one shown until then): if the current + /// layout no longer exists, follow it to its other name when it was renamed (same tab order, + /// a name `before` did not have), else go to the Model tab. + pub(crate) fn follow_layout(&mut self, before: &Drawing) { + let Space::Paper(name) = &self.space else { return }; + if self.doc.layouts.iter().any(|l| l.name == *name) { + return; + } + let renamed = before + .layouts + .iter() + .find(|l| l.name == *name) + .and_then(|old| self.doc.layouts.iter().find(|l| l.tab_order == old.tab_order && !before.layouts.iter().any(|b| b.name == l.name))); + match renamed.map(|l| Space::Paper(l.name.clone())) { + Some(to) => { + let from = self.space.clone(); + for (sp, _) in self.views.iter_mut() { + if *sp == from { + *sp = to.clone(); + } + } + self.space = to; + } + None => { + self.space = Space::Model; + self.mspace = None; + } + } + } + /// The space edits, picks and snaps act on: model space inside an active viewport. + pub fn edit_space(&self) -> Space { + if self.active_viewport().is_some() { Space::Model } else { self.space.clone() } + } + /// The active MSPACE viewport, if it still exists in the current layout. + pub fn active_viewport(&self) -> Option<(Handle, cadcraft_doc::Viewport)> { + let h = self.mspace?; + let e = self.doc.space(&self.space)?.get(h)?; + match &e.kind { + cadcraft_doc::EntityKind::Viewport(v) if v.height > 1e-12 && v.view_height > 1e-12 => Some((h, v.clone())), + _ => None, + } + } + pub fn paper_view(&self) -> View { + self.views.iter().find(|(s, _)| *s == self.space).map(|(_, v)| *v).unwrap_or_default() + } + /// The screen view. Inside an MSPACE viewport this is the model-space view that maps the + /// screen consistently with the paper view, so picks, snaps and previews work in model units. + pub fn view(&self) -> View { + let pv = self.paper_view(); + match self.active_viewport() { + Some((_, vp)) => { + let k = vp.view_height / vp.height; + View { center: vp.view_center + (pv.center - vp.center.xy()) * k, height: pv.height * k } + } + None => pv, + } + } + /// Map a requested screen view to the paper view, or (in MSPACE) to the viewport's model view. + /// A finite request can still map to a view too large to represent: then nothing changes and + /// this returns false. + fn store_view(&mut self, v: View) -> bool { + if let Some((h, vp)) = self.active_viewport() { + let pv = self.paper_view(); + let k = v.height / pv.height.max(1e-12); + if vp.locked { + // A locked viewport keeps its scale: pan/zoom the sheet instead. + let k0 = vp.view_height / vp.height; + let pc = vp.center.xy() + (v.center - vp.view_center) / k0; + let paper = View { center: pc, height: v.height / k0 }; + if !usable_view(&paper) { + return false; + } + self.put_paper_view(paper); + } else { + let center = v.center - (pv.center - vp.center.xy()) * k; + let height = vp.height * k; + if !usable_view(&View { center, height }) { + return false; + } + let doc = Arc::make_mut(&mut self.doc); + if let Some(store) = doc.space_mut(&self.space.clone()) { + store.modify(h, |e| { + if let cadcraft_doc::EntityKind::Viewport(x) = &mut e.kind { + x.view_center = center; + x.view_height = height; + } + }); + } + } + return true; + } + self.put_paper_view(v); + true + } + fn put_paper_view(&mut self, v: View) { + match self.views.iter_mut().find(|(s, _)| *s == self.space) { + Some((_, slot)) => *slot = v, + None => self.views.push((self.space.clone(), v)), + } + } + /// Set the view, recording view history. Returns false, changing nothing, when the view (or, + /// in MSPACE, the viewport view it maps to) isn't finite with a positive height. + pub fn set_view(&mut self, v: View) -> bool { + if !usable_view(&v) { + return false; + } + let old = self.view(); + if !self.store_view(v) { + return false; + } + if old != v { + self.view_history.push(old); + if self.view_history.len() > 50 { + self.view_history.remove(0); + } + } + true + } + /// The active MSPACE viewport when ZOOM works on its own view, as in AutoCAD: extents and + /// windows fit the viewport, and zooms keep its centre. A locked viewport keeps its scale, so + /// zooming it moves the sheet instead (see `store_view`). + pub fn zoom_viewport(&self) -> Option { + self.active_viewport().map(|(_, v)| v).filter(|v| !v.locked && v.width > 1e-12) + } + /// The view ZOOM starts from: the model view of the active unlocked viewport, else [`Self::view`]. + pub fn zoom_frame(&self) -> View { + match self.zoom_viewport() { + Some(v) => View { center: v.view_center, height: v.view_height }, + None => self.view(), + } + } + /// Width / height of the area ZOOM fits into: the active unlocked viewport, else `screen`. + pub fn zoom_aspect(&self, screen: f64) -> f64 { + self.zoom_viewport().map_or(screen, |v| v.width / v.height) + } + /// Set the view ZOOM works on (see [`Self::zoom_frame`]), recording view history. + pub fn set_zoom_frame(&mut self, f: View) { + let v = match self.zoom_viewport() { + // The screen view that `store_view` maps to this model view of the viewport. + Some(vp) => { + let pv = self.paper_view(); + let k = f.height / vp.height; + View { center: f.center + (pv.center - vp.center.xy()) * k, height: pv.height * k } + } + None => f, + }; + self.set_view(v); + } + /// Set without recording view history (realtime pan/zoom frames). + pub fn set_view_quiet(&mut self, v: View) { + if !(v.center.is_finite() && v.height.is_finite() && v.height > 1e-12) { + return; + } + self.store_view(v); + } +} diff --git a/crates/engine/src/error.rs b/crates/engine/src/error.rs new file mode 100644 index 000000000..84a53aba8 --- /dev/null +++ b/crates/engine/src/error.rs @@ -0,0 +1,24 @@ +#[derive(Debug, thiserror::Error)] +pub enum EngineError { + #[error("unknown command `{0}`")] + UnknownCommand(String), + #[error("command `{0}` is not available right now: {1}")] + Disabled(String, String), + #[error("invalid parameters for `{cmd}`: {msg}")] + BadParams { cmd: String, msg: String }, + #[error("no active document")] + NoDocument, + #[error("{0}")] + Other(String), + /// A command panicked; the guard kept the drawing as it was (a bug: please report it). + #[error("internal error in `{0}` (the drawing was kept as it was): {1}")] + Internal(String, String), +} + +impl From for EngineError { + fn from(e: cadcraft_doc::DocError) -> Self { + EngineError::Other(e.to_string()) + } +} + +pub type Result = std::result::Result; diff --git a/crates/engine/src/grips.rs b/crates/engine/src/grips.rs index 964bc1f61..a7dee5b0b 100644 --- a/crates/engine/src/grips.rs +++ b/crates/engine/src/grips.rs @@ -538,5 +538,4 @@ pub fn polyline_grip_count(p: &LwPolyline) -> usize { } #[cfg(test)] -#[path = "grips_tests.rs"] mod tests; diff --git a/crates/engine/src/grips_tests.rs b/crates/engine/src/grips/tests.rs similarity index 100% rename from crates/engine/src/grips_tests.rs rename to crates/engine/src/grips/tests.rs diff --git a/crates/engine/src/last_used.rs b/crates/engine/src/last_used.rs new file mode 100644 index 000000000..e984d9feb --- /dev/null +++ b/crates/engine/src/last_used.rs @@ -0,0 +1,97 @@ +use super::*; + +/// Options and values the interactive commands remember for the rest of the session, offered as +/// the `` the next time they ask (as AutoCAD does). Values AutoCAD keeps in a drawing +/// system variable (HPNAME, OFFSETDIST, FILLETRAD, CHAMFERA, POLYSIDES…) live in the drawing +/// header instead. JSON calls never read these: their explicit parameters and documented defaults +/// stay authoritative. +#[derive(Clone, Debug, PartialEq)] +pub struct LastUsed { + /// HATCH/GRADIENT starts at "Select objects" instead of "Pick internal point". + pub hatch_select: bool, + /// POLYGON: circumscribed about the circle (the `C` option) instead of inscribed. + pub polygon_circumscribed: bool, + /// ROTATE angle (radians). + pub rotate_angle: f64, + /// SCALE factor. + pub scale_factor: f64, + /// TXT2MTXT's Settings. + pub txt2mtxt: cmd::Txt2MtxtOptions, + /// MATCHPROP Settings: the properties it copies. + pub matchprop: cmd::MatchProps, + /// MLEADER placement order and Options. + pub mleader: MLeaderOptions, + /// MLEADER's block content name (Options > Content type > Block). + pub mleader_block: String, + /// BREAKLINE's Block, Size and Extension. + pub breakline: cmd::BreaklineOptions, +} + +impl Default for LastUsed { + fn default() -> Self { + LastUsed { + hatch_select: false, + polygon_circumscribed: false, + rotate_angle: 0.0, + scale_factor: 1.0, + txt2mtxt: cmd::Txt2MtxtOptions::default(), + matchprop: cmd::MatchProps::default(), + mleader: MLeaderOptions::default(), + mleader_block: String::new(), + breakline: cmd::BreaklineOptions::default(), + } + } +} + +/// Which part of a multileader MLEADER places first. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum LeaderOrder { + #[default] + Arrowhead, + Landing, + Content, +} + +/// The MLEADER placement order and the values set at its Options prompt. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct MLeaderOptions { + pub order: LeaderOrder, + /// Maximum points of the leader line (2..=64). + pub max_points: usize, + /// First and second angle constraints (radians; 0 = free). + pub angles: [f64; 2], + /// Draw the landing line (dogleg). + pub landing: bool, + /// Ask for multiline text content (Content type None turns it off). + pub content: bool, + /// Leader lines: straight, spline, or none (content only). + pub leader_type: LeaderType, + /// The content is a block (`LastUsed::mleader_block`) instead of multiline text. + pub block: bool, + /// The block's extents centre (not its insertion point) sits at the end of the landing. + pub block_center: bool, +} + +/// MLEADER's leader line type (Options > Leader type). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum LeaderType { + #[default] + Straight, + Spline, + None, +} + +impl Default for MLeaderOptions { + fn default() -> Self { + MLeaderOptions { + order: LeaderOrder::Arrowhead, + max_points: 2, + angles: [0.0; 2], + landing: true, + content: true, + leader_type: LeaderType::Straight, + block: false, + block_center: true, + } + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index c1eb5a6cc..caeda8d7d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -49,599 +49,20 @@ pub use cmd::hatch::{HatchPicks, PatternChoice, PickMode, pattern_choices}; pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command}; pub use prompt::{Accept, Drag, Input, Interactive, Picker, Prompt, Step}; -#[derive(Debug, thiserror::Error)] -pub enum EngineError { - #[error("unknown command `{0}`")] - UnknownCommand(String), - #[error("command `{0}` is not available right now: {1}")] - Disabled(String, String), - #[error("invalid parameters for `{cmd}`: {msg}")] - BadParams { cmd: String, msg: String }, - #[error("no active document")] - NoDocument, - #[error("{0}")] - Other(String), - /// A command panicked; the guard kept the drawing as it was (a bug: please report it). - #[error("internal error in `{0}` (the drawing was kept as it was): {1}")] - Internal(String, String), -} +mod doc_state; +mod error; +mod last_used; +mod running; +mod session; +mod settings; +mod view; -impl From for EngineError { - fn from(e: cadcraft_doc::DocError) -> Self { - EngineError::Other(e.to_string()) - } -} - -pub type Result = std::result::Result; - -/// A 2D view of a space: world centre and visible height in drawing units. -#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] -pub struct View { - pub center: Vec2, - pub height: f64, -} - -impl Default for View { - fn default() -> Self { - View { center: Vec2::new(6.0, 4.5), height: 10.0 } - } -} - -fn usable_view(v: &View) -> bool { - v.center.is_finite() && v.height.is_finite() && v.height > 1e-12 -} - -/// Tiled model viewports (VPORTS): the session side of the drawing's `*Active` configuration. -#[derive(Clone, Debug, Default, PartialEq)] -pub struct ModelTiles { - /// The tile corners these views belong to (when the drawing's tiles change, by VPORTS or - /// undo, its views take over). - pub rects: Vec<(Vec2, Vec2)>, - /// Each tile's view; the current tile shows the model space view itself. - pub views: Vec, - /// The current tile. - pub active: usize, -} - -/// One tile of the model space drawing area, for the UI ([`Session::model_tiles`]): its corners -/// as fractions of the drawing area (0,0 lower left) and its view. -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct ModelTile { - pub min: Vec2, - pub max: Vec2, - pub view: View, - pub active: bool, -} - -#[derive(Clone, Debug)] -pub struct Snapshot { - pub label: String, - pub doc: Arc, - pub selection: Vec, -} - -/// One open drawing. -#[derive(Clone, Debug)] -pub struct DocState { - pub doc: Arc, - pub undo: Vec, - pub redo: Vec, - /// UNDO marks, BEgin/End group and Control (see [`undoctl`]). - pub undo_ctl: undoctl::UndoCtl, - pub selection: Vec, - pub previous_selection: Vec, - pub path: Option, - pub title: String, - pub saved: Arc, - pub revision: u64, - pub space: Space, - /// Tiled model viewports (VPORTS). - pub tiles: ModelTiles, - /// MSPACE: the layout viewport whose model space is being edited (None = paper space). - pub mspace: Option, - pub views: Vec<(Space, View)>, - pub view_history: Vec, - pub uid: u64, - /// The drawing is saved as binary DXF (it was opened from one, or last saved as one). - pub binary_dxf: bool, - /// The Block Editor (BEDIT), when it is open: `doc` is then the editor's drawing, and the - /// drawing itself waits in it ([`DocState::drawing`]). - pub block_edit: Option, -} - -static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); - -impl DocState { - pub fn new(doc: Drawing, title: &str, path: Option) -> Self { - let doc = Arc::new(doc); - DocState { - saved: doc.clone(), - doc, - undo: Vec::new(), - redo: Vec::new(), - undo_ctl: undoctl::UndoCtl::default(), - selection: Vec::new(), - previous_selection: Vec::new(), - path, - title: title.into(), - revision: 1, - space: Space::Model, - tiles: ModelTiles::default(), - mspace: None, - views: Vec::new(), - view_history: Vec::new(), - uid: NEXT_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed), - binary_dxf: false, - block_edit: None, - } - } - /// The drawing has changes not saved to its file, or the Block Editor has changes not yet - /// saved into its block. - pub fn is_dirty(&self) -> bool { - !Arc::ptr_eq(self.drawing(), &self.saved) || self.block_unsaved() - } - /// After undo/redo replaced the drawing (`before` is the one shown until then): if the current - /// layout no longer exists, follow it to its other name when it was renamed (same tab order, - /// a name `before` did not have), else go to the Model tab. - fn follow_layout(&mut self, before: &Drawing) { - let Space::Paper(name) = &self.space else { return }; - if self.doc.layouts.iter().any(|l| l.name == *name) { - return; - } - let renamed = before - .layouts - .iter() - .find(|l| l.name == *name) - .and_then(|old| self.doc.layouts.iter().find(|l| l.tab_order == old.tab_order && !before.layouts.iter().any(|b| b.name == l.name))); - match renamed.map(|l| Space::Paper(l.name.clone())) { - Some(to) => { - let from = self.space.clone(); - for (sp, _) in self.views.iter_mut() { - if *sp == from { - *sp = to.clone(); - } - } - self.space = to; - } - None => { - self.space = Space::Model; - self.mspace = None; - } - } - } - /// The space edits, picks and snaps act on: model space inside an active viewport. - pub fn edit_space(&self) -> Space { - if self.active_viewport().is_some() { Space::Model } else { self.space.clone() } - } - /// The active MSPACE viewport, if it still exists in the current layout. - pub fn active_viewport(&self) -> Option<(Handle, cadcraft_doc::Viewport)> { - let h = self.mspace?; - let e = self.doc.space(&self.space)?.get(h)?; - match &e.kind { - cadcraft_doc::EntityKind::Viewport(v) if v.height > 1e-12 && v.view_height > 1e-12 => Some((h, v.clone())), - _ => None, - } - } - pub fn paper_view(&self) -> View { - self.views.iter().find(|(s, _)| *s == self.space).map(|(_, v)| *v).unwrap_or_default() - } - /// The screen view. Inside an MSPACE viewport this is the model-space view that maps the - /// screen consistently with the paper view, so picks, snaps and previews work in model units. - pub fn view(&self) -> View { - let pv = self.paper_view(); - match self.active_viewport() { - Some((_, vp)) => { - let k = vp.view_height / vp.height; - View { center: vp.view_center + (pv.center - vp.center.xy()) * k, height: pv.height * k } - } - None => pv, - } - } - /// Map a requested screen view to the paper view, or (in MSPACE) to the viewport's model view. - /// A finite request can still map to a view too large to represent: then nothing changes and - /// this returns false. - fn store_view(&mut self, v: View) -> bool { - if let Some((h, vp)) = self.active_viewport() { - let pv = self.paper_view(); - let k = v.height / pv.height.max(1e-12); - if vp.locked { - // A locked viewport keeps its scale: pan/zoom the sheet instead. - let k0 = vp.view_height / vp.height; - let pc = vp.center.xy() + (v.center - vp.view_center) / k0; - let paper = View { center: pc, height: v.height / k0 }; - if !usable_view(&paper) { - return false; - } - self.put_paper_view(paper); - } else { - let center = v.center - (pv.center - vp.center.xy()) * k; - let height = vp.height * k; - if !usable_view(&View { center, height }) { - return false; - } - let doc = Arc::make_mut(&mut self.doc); - if let Some(store) = doc.space_mut(&self.space.clone()) { - store.modify(h, |e| { - if let cadcraft_doc::EntityKind::Viewport(x) = &mut e.kind { - x.view_center = center; - x.view_height = height; - } - }); - } - } - return true; - } - self.put_paper_view(v); - true - } - fn put_paper_view(&mut self, v: View) { - match self.views.iter_mut().find(|(s, _)| *s == self.space) { - Some((_, slot)) => *slot = v, - None => self.views.push((self.space.clone(), v)), - } - } - /// Set the view, recording view history. Returns false, changing nothing, when the view (or, - /// in MSPACE, the viewport view it maps to) isn't finite with a positive height. - pub fn set_view(&mut self, v: View) -> bool { - if !usable_view(&v) { - return false; - } - let old = self.view(); - if !self.store_view(v) { - return false; - } - if old != v { - self.view_history.push(old); - if self.view_history.len() > 50 { - self.view_history.remove(0); - } - } - true - } - /// The active MSPACE viewport when ZOOM works on its own view, as in AutoCAD: extents and - /// windows fit the viewport, and zooms keep its centre. A locked viewport keeps its scale, so - /// zooming it moves the sheet instead (see `store_view`). - pub fn zoom_viewport(&self) -> Option { - self.active_viewport().map(|(_, v)| v).filter(|v| !v.locked && v.width > 1e-12) - } - /// The view ZOOM starts from: the model view of the active unlocked viewport, else [`Self::view`]. - pub fn zoom_frame(&self) -> View { - match self.zoom_viewport() { - Some(v) => View { center: v.view_center, height: v.view_height }, - None => self.view(), - } - } - /// Width / height of the area ZOOM fits into: the active unlocked viewport, else `screen`. - pub fn zoom_aspect(&self, screen: f64) -> f64 { - self.zoom_viewport().map_or(screen, |v| v.width / v.height) - } - /// Set the view ZOOM works on (see [`Self::zoom_frame`]), recording view history. - pub fn set_zoom_frame(&mut self, f: View) { - let v = match self.zoom_viewport() { - // The screen view that `store_view` maps to this model view of the viewport. - Some(vp) => { - let pv = self.paper_view(); - let k = f.height / vp.height; - View { center: f.center + (pv.center - vp.center.xy()) * k, height: pv.height * k } - } - None => f, - }; - self.set_view(v); - } - /// Set without recording view history (realtime pan/zoom frames). - pub fn set_view_quiet(&mut self, v: View) { - if !(v.center.is_finite() && v.height.is_finite() && v.height > 1e-12) { - return; - } - self.store_view(v); - } -} - -/// Session-level drafting settings. [`Session::drafting_settings`] supplies the active drawing's -/// snap/grid spacing in the corresponding fields for UI and JSON consumers. -#[derive(Clone, Debug, Serialize, Deserialize)] -#[serde(default, rename_all = "camelCase")] -pub struct Settings { - pub osmode: u32, - pub orthomode: bool, - pub polarmode: bool, - /// POLARANG (radians). - pub polarang: f64, - pub gridmode: bool, - pub snapmode: bool, - /// SNAPUNIT in a drafting settings snapshot; stored in the drawing header. - pub snapunit: Vec2, - /// OSNAPHATCH: object snaps find hatch objects (off by default, as in AutoCAD). - pub osnaphatch: bool, - /// GRIDUNIT in a drafting settings snapshot; zero components follow SNAPUNIT. - pub gridunit: Vec2, - /// Major grid line every N minor lines (GRIDMAJOR). - pub gridmajor: u32, - /// ZOOMFACTOR: how far one mouse wheel step zooms (3..=100). - pub zoomfactor: u32, - /// MEASUREINIT: the units of a new drawing made without a template (NEW, QNEW and the first - /// drawing at startup): `false` (0, the default) imperial, `true` (1) metric. - pub measureinit: bool, - /// -VIEW sEttings > Layer snapshot: named views save the layer settings with them. - pub view_layer_snapshot: bool, - pub dynmode: bool, - /// DYNDIGRIP: which dimensions grip dimensional input shows while a grip is stretched (bits: - /// 1 resulting length, 2 length change, 4 absolute angle, 8 angle change, 16 radius); read - /// when DYNDIVIS = 2. - pub dyndigrip: u32, - /// DYNDIVIS: how many of them: 0 the first, 1 the first two (default), 2 all DYNDIGRIP asks for. - pub dyndivis: u8, - /// DYNPIFORMAT = 1: Dynamic Input shows second and next points as Cartesian `x,y` instead of - /// polar `distance, - /// SNAPTYPE 1: snap mode (F9) snaps along tracking paths at POLARDIST steps (PolarSnap) - /// instead of to the grid. - pub polarsnap: bool, - /// POLARDIST: the PolarSnap step; 0 uses the snap X spacing. - pub polardist: f64, - /// AUDITCTL: 1 creates an audit report (.adt) file when AUDIT is run (default: 0). - pub auditctl: bool, -} - -impl Default for Settings { - fn default() -> Self { - Settings { - osmode: 4133, - orthomode: false, - polarmode: true, - polarang: std::f64::consts::FRAC_PI_2, - gridmode: true, - snapmode: false, - snapunit: Vec2::new(0.5, 0.5), - osnaphatch: false, - gridunit: Vec2::new(0.5, 0.5), - gridmajor: 5, - zoomfactor: 60, - measureinit: false, - view_layer_snapshot: true, - dynmode: true, - dyndigrip: 31, - dyndivis: 1, - dynpi_cartesian: false, - dynpi_absolute: false, - dynprompt: true, - lwdisplay: false, - transparency_display: true, - plot_transparency_override: 1, - selection_cycling: false, - selection_cycling_display: 2, - selectsimilarmode: 130, - otrack: true, - pickbox: 3.0, - aperture: 10.0, - pickfirst: true, - acadlspasdoc: false, - cmdecho: true, - filedia: true, - pickadd: true, - pickdrag: true, - pickstyle: 1, - gripsize: 5.0, - cursorsize: 5.0, - maxarray: 100_000, - isodraft: false, - isoplane: snap::iso::IsoPlane::Left, - annoallvisible: true, - annoautoscale: false, - qpmode: false, - highlight: true, - selectioneffect: 1, - selectionpreview: 3, - previeweffect: 2, - previewfilter: 3, - grips: 1, - gripobjlimit: 100, - selectionarea: true, - windowareacolor: 150, - crossingareacolor: 100, - selectionareaopacity: 25, - polar_flags: 0, - polaraddang: Vec::new(), - polarsnap: false, - polardist: 0.0, - auditctl: false, - } - } -} - -/// An interactive command in progress. -pub struct Running { - pub id: String, - pub machine: Box, - pub before: Arc, - pub selection_before: Vec, -} - -/// A pending window/crossing selection started by a click on empty space. -#[derive(Clone, Copy, Debug, PartialEq, Serialize)] -pub struct PendingWindow { - pub corner: Vec2, - pub during_command: bool, -} - -/// Options and values the interactive commands remember for the rest of the session, offered as -/// the `` the next time they ask (as AutoCAD does). Values AutoCAD keeps in a drawing -/// system variable (HPNAME, OFFSETDIST, FILLETRAD, CHAMFERA, POLYSIDES…) live in the drawing -/// header instead. JSON calls never read these: their explicit parameters and documented defaults -/// stay authoritative. -#[derive(Clone, Debug, PartialEq)] -pub struct LastUsed { - /// HATCH/GRADIENT starts at "Select objects" instead of "Pick internal point". - pub hatch_select: bool, - /// POLYGON: circumscribed about the circle (the `C` option) instead of inscribed. - pub polygon_circumscribed: bool, - /// ROTATE angle (radians). - pub rotate_angle: f64, - /// SCALE factor. - pub scale_factor: f64, - /// TXT2MTXT's Settings. - pub txt2mtxt: cmd::Txt2MtxtOptions, - /// MATCHPROP Settings: the properties it copies. - pub matchprop: cmd::MatchProps, - /// MLEADER placement order and Options. - pub mleader: MLeaderOptions, - /// MLEADER's block content name (Options > Content type > Block). - pub mleader_block: String, - /// BREAKLINE's Block, Size and Extension. - pub breakline: cmd::BreaklineOptions, -} - -impl Default for LastUsed { - fn default() -> Self { - LastUsed { - hatch_select: false, - polygon_circumscribed: false, - rotate_angle: 0.0, - scale_factor: 1.0, - txt2mtxt: cmd::Txt2MtxtOptions::default(), - matchprop: cmd::MatchProps::default(), - mleader: MLeaderOptions::default(), - mleader_block: String::new(), - breakline: cmd::BreaklineOptions::default(), - } - } -} - -/// Which part of a multileader MLEADER places first. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub enum LeaderOrder { - #[default] - Arrowhead, - Landing, - Content, -} - -/// The MLEADER placement order and the values set at its Options prompt. -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct MLeaderOptions { - pub order: LeaderOrder, - /// Maximum points of the leader line (2..=64). - pub max_points: usize, - /// First and second angle constraints (radians; 0 = free). - pub angles: [f64; 2], - /// Draw the landing line (dogleg). - pub landing: bool, - /// Ask for multiline text content (Content type None turns it off). - pub content: bool, - /// Leader lines: straight, spline, or none (content only). - pub leader_type: LeaderType, - /// The content is a block (`LastUsed::mleader_block`) instead of multiline text. - pub block: bool, - /// The block's extents centre (not its insertion point) sits at the end of the landing. - pub block_center: bool, -} - -/// MLEADER's leader line type (Options > Leader type). -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub enum LeaderType { - #[default] - Straight, - Spline, - None, -} - -impl Default for MLeaderOptions { - fn default() -> Self { - MLeaderOptions { - order: LeaderOrder::Arrowhead, - max_points: 2, - angles: [0.0; 2], - landing: true, - content: true, - leader_type: LeaderType::Straight, - block: false, - block_center: true, - } - } -} +pub use doc_state::{DocState, Snapshot}; +pub use error::{EngineError, Result}; +pub use last_used::{LastUsed, LeaderOrder, LeaderType, MLeaderOptions}; +pub use running::{PendingWindow, Running}; +pub use settings::Settings; +pub use view::{ModelTile, ModelTiles, View}; /// The editor session: open drawings, settings, the running command and the command log. pub struct Session { @@ -763,61 +184,6 @@ 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() }; - let mut st = DocState::new(d, &format!("Drawing{}", self.untitled_counter), None); - st.set_view_quiet(if metric { View { center: Vec2::new(210.0, 148.5), height: 297.0 } } else { View::default() }); - 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); - if !ext.is_empty() { - st.set_view_quiet(View { - center: ext.center(), - height: (ext.height().max(ext.width() * self.viewport_px.1 / self.viewport_px.0.max(1.0)) * 1.1).max(1e-6), - }); - } - 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) { - if self.running.is_some() { - self.cancel(); - } - } - - /// Add a file read off-thread with [`cmd::file::read_file`] as the active drawing (OPEN's fast - /// half). A panic while adding it is reported as an error, never propagated. - pub fn finish_open(&mut self, f: cmd::file::ReadFile) -> Result { - let n = self.docs.len(); - match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| cmd::file::finish_open(self, f))) { - Ok(r) => r, - 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())) - } - } - } pub fn state(&self) -> Result<&DocState> { self.docs.get(self.active).ok_or(EngineError::NoDocument) @@ -922,947 +288,6 @@ impl Session { v.height / self.viewport_px.1.max(1.0) } - // ---------------- programmatic execution ---------------- - - /// Run a command with JSON parameters (no dialogs). Records undo when the drawing changes. - pub fn execute(&mut self, id: &str, params: &Value) -> Result { - let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?; - (spec.enabled)(self).map_err(|m| EngineError::Disabled(spec.id.into(), m))?; - if let Some(why) = cmd::blocks::bedit::refused_in_editor(self, spec.id) { - return Err(EngineError::Disabled(spec.id.into(), why)); - } - let before = self.state().ok().map(|s| (s.doc.clone(), s.selection.clone(), s.uid)); - let editing = self.state().is_ok_and(|s| s.block_edit.is_some()); - let run = spec.run; - let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run(self, params))); - let result = match result { - Ok(r) => r, - Err(p) => { - let msg = p - .downcast_ref::<&str>() - .map(ToString::to_string) - .or_else(|| p.downcast_ref::().cloned()) - .unwrap_or_else(|| "panic".into()); - // Restore the drawing as it was. - if let Some((doc, sel, uid)) = &before - && let Some(st) = self.docs.iter_mut().find(|d| d.uid == *uid) - { - st.doc = doc.clone(); - st.selection = sel.clone(); - } - return Err(EngineError::Internal(id.into(), msg)); - } - }; - let violation = before.as_ref().and_then(|b| cmd::blocks::refedit::after_command(self, &b.0, spec.id)); - let result = match (violation, result) { - (Some(m), Ok(_)) => Err(EngineError::Other(m)), - (_, r) => r, - }; - // Opening or closing the Block Editor swaps the whole drawing: nothing in it changed. - if self.state().is_ok_and(|s| s.block_edit.is_some()) == editing { - self.after_command(before.as_ref().map(|b| &b.0), before.as_ref().map(|b| b.2), spec.undoable && result.is_ok()); - } - let result = match (result, &before) { - (Ok(v), Some((doc, sel, uid))) if spec.undoable => self.refuse_non_finite(Some(*uid), doc, sel, spec.id).map(|()| v), - (r, _) => r, - }; - let grouped = self.lisp_groups_undo(); - if spec.undoable - && let Some((doc, sel, uid)) = before - && let Some(st) = self.docs.iter_mut().find(|d| d.uid == uid) - && !Arc::ptr_eq(&doc, &st.doc) - { - // Inside a LISP routine the routine's single undo step records it. - if !grouped { - st.push_undo(Snapshot { label: spec.label.to_string(), doc, selection: sel }); - } - st.revision += 1; - } - result - } - - /// Bump the revision without an undo step (view-only or settings changes that redraw). - pub fn touch(&mut self) { - if let Ok(st) = self.state_mut() { - st.revision += 1; - } - } - - // ---------------- interactive commands ---------------- - - /// Start a command by name or alias as if typed at the command line. - pub fn start(&mut self, name: &str) -> Result<()> { - self.last_polar = None; - let name = name.trim(); - let (transparent, name) = match name.strip_prefix('\'') { - Some(n) => (true, n), - None => (false, name), - }; - let lower = name.trim_start_matches(['_', '.', '-']).to_ascii_lowercase(); - // A command-line form with an id of its own (-ATTEDIT) is not the command it prefixes. - let dashed = cmd::resolve_alias(&name.trim_start_matches(['_', '.']).to_ascii_lowercase()); - let id = if find_command(&dashed).is_some() { dashed } else { cmd::resolve_alias(&lower) }; - let Some(spec) = find_command(&id) else { - // A command defined in LISP: (defun c:NAME …). - if !transparent && let Some(f) = self.lisp_command(&lower) { - if self.running.is_some() { - self.cancel(); - } - self.last_command = Some(lower.clone()); - self.echo(format!("Command: {}", lower.to_ascii_uppercase())); - return self.lisp_run_command(&lower, f); - } - // A bare system variable name (ORTHOMODE, GRIDMODE…) acts as SETVAR on that variable. - let var = lower.to_ascii_uppercase(); - if !name.starts_with(['_', '.', '-']) && sysvars::get(self, &var).is_some() { - self.start(if transparent { "'setvar" } else { "setvar" })?; - return self.input(Input::Text(var)); - } - return Err(EngineError::UnknownCommand(name.to_string())); - }; - if transparent && spec.transparent && self.running.is_some() { - // Transparent commands (zoom, pan…) run without cancelling the active one. - if spec.interactive.is_none() { - self.echo(format!(">>{}", spec.label)); - return self.execute(spec.id, &Value::Null).map(|_| ()); - } - if self.suspended.is_some() { - self.echo("A transparent command is already in progress."); - return Ok(()); - } - } - let nested = transparent && spec.transparent && self.running.is_some() && spec.interactive.is_some(); - // Ctrl+Z / U / UNDO while a command's prompt offers its own Undo keyword (LINE, PLINE…) - // takes back the last segment and keeps the command running, as the keyword does. - if !nested && matches!(spec.id, "u" | "undo") && self.current_prompt().is_some_and(|p| p.keywords.iter().any(|k| k == "Undo")) { - self.echo(format!("Command: {}", spec.id.to_ascii_uppercase())); - return self.input(Input::Keyword("Undo".into())); - } - if !nested && self.running.is_some() { - self.cancel(); - } - (spec.enabled)(self).map_err(|m| EngineError::Disabled(spec.id.into(), m))?; - if let Some(why) = cmd::blocks::bedit::refused_in_editor(self, spec.id) { - return Err(EngineError::Disabled(spec.id.into(), why)); - } - if nested { - // The outer command stays the one Enter repeats. - self.echo(format!(">>{}", spec.id.to_ascii_uppercase())); - } else { - self.last_command = Some(spec.id.to_string()); - self.echo(format!("Command: {}", spec.id.to_ascii_uppercase())); - } - match spec.interactive { - Some(factory) => { - let machine = guard::Guarded::create(spec.id, factory, self)?; - let st = self.state()?; - let before = st.doc.clone(); - let selection_before = st.selection.clone(); - if nested { - self.suspended = self.running.take(); - } - self.running = Some(Running { id: spec.id.to_string(), machine, before, selection_before }); - self.pending_window = None; - // Some commands complete immediately (e.g. ERASE with a pickfirst selection). - self.feed(None)?; - Ok(()) - } - None => { - let r = self.execute(spec.id, &Value::Null)?; - if let Some(s) = r.as_str() { - self.echo(s.to_string()); - } else if !r.is_null() - && let Some(msg) = r.get("message").and_then(Value::as_str) - { - for l in msg.lines() { - self.echo(l.to_string()); - } - } - Ok(()) - } - } - } - - /// Feed an object-snap pick, retaining its viewport origin for measurements. Point modifiers - /// that combine or constrain coordinates produce ordinary drawing-space points. - pub fn input_snapped(&mut self, hit: snap::SnapHit) -> Result<()> { - let source = hit.viewport.filter(|_| self.point_mods.iter().all(|f| matches!(f, pointmod::Frame::Snap { mode } if *mode != 0))); - let previous = std::mem::replace(&mut self.picked_viewport, source); - let result = self.input(hit.input()); - self.picked_viewport = previous; - result - } - - /// Give an input to the running command. With no command running, `Enter` repeats the last - /// command (as in AutoCAD) and points do nothing. A LISP routine waiting in a `get…` - /// function takes the input instead; one paused in `command` carries on after it. - pub fn input(&mut self, input: Input) -> Result<()> { - if self.lisp_input(input.clone()) { - return Ok(()); - } - let r = self.input_to_command(input); - self.lisp_after_command_input(); - r - } - - fn input_to_command(&mut self, input: Input) -> Result<()> { - if self.running.is_none() { - if input == Input::Enter - && let Some(last) = self.last_command.clone() - { - return self.start(&last); - } - return Ok(()); - } - if !self.point_mods.is_empty() { - return self.modifier_input(input); - } - // A deferred snap is only meaningful where the prompt resolves it; elsewhere it is the - // point it was picked at. - let input = match input { - Input::Deferred(d) if !self.current_prompt().is_some_and(|p| p.deferred) => Input::Point(d.at), - other => other, - }; - // Generic selection handling during "Select objects:" prompts. - let input = self.preprocess_selection(input)?; - match input { - Some(i) => { - // SIngle: the first selection that finds objects also ends the prompt. - let single = self.select_single && matches!(&i, Input::Pick(hs) if !hs.is_empty()); - self.feed(Some(i))?; - if single { - self.select_single = false; - if self.current_prompt().is_some_and(|p| p.accept.select) { - self.feed(Some(Input::Enter))?; - } - } - Ok(()) - } - None => Ok(()), - } - } - - fn preprocess_selection(&mut self, input: Input) -> Result> { - // The command's own prompt: a typed selection mode only applies at its "Select objects". - let Some(prompt) = self.running.as_ref().map(|r| r.machine.prompt(self)) else { return Ok(Some(input)) }; - if !prompt.accept.select { - return Ok(Some(input)); - } - if self.select_mode.is_some() { - return self.select_mode_input(input); - } - // `_L`, `_ALL`, `_W`: the English name of a selection keyword, as scripts write it. - let input = match input { - Input::Text(t) => Input::Text(t.trim().strip_prefix('_').map(str::to_string).unwrap_or(t)), - other => other, - }; - if let Input::Text(t) | Input::Keyword(t) = &input - && let Some(m) = select::SelectMode::from_keyword(t) - { - self.pending_window = None; - self.select_mode = Some(m); - return Ok(None); - } - if let Input::Text(t) | Input::Keyword(t) = &input - && !prompt.keywords.iter().any(|k| k.eq_ignore_ascii_case(t.trim())) - { - match t.trim().to_ascii_lowercase().as_str() { - "si" | "single" => { - self.select_single = true; - return Ok(None); - } - // AUto (a pick, or a window where nothing is picked) is how points always select; - // Multiple picks the same way until Enter. - "au" | "auto" | "m" | "multiple" => return Ok(None), - _ => {} - } - } - let space = self.space(); - match input { - Input::Point(p) => { - let ap = self.pixel_size() * self.settings.pickbox.max(1.0) * 1.5; - if let Some(pw) = self.pending_window.take() { - let crossing = p.x < pw.corner.x; - let hs = select::select_window(self.doc()?, &space, Bounds2::new(pw.corner, p), crossing); - return Ok(Some(Input::Pick(cmd::groups::expand(self, hs)))); - } - match select::pick(self.doc()?, &space, p, ap) { - Some(h) => Ok(Some(Input::Pick(cmd::groups::expand(self, vec![h])))), - None => { - self.pending_window = Some(PendingWindow { corner: p, during_command: true }); - Ok(None) - } - } - } - Input::Text(ref t) | Input::Keyword(ref t) => { - let tl = t.trim().to_ascii_lowercase(); - let d = self.doc()?; - let store = d.space(&space); - let picked = match tl.as_str() { - "all" => store.map(|s| s.iter().filter(|e| d.is_visible(e)).map(|e| e.handle).collect()), - "l" | "last" => store.and_then(|s| s.last()).map(|e| vec![e.handle]), - "p" | "previous" => Some(self.state()?.previous_selection.clone()), - _ => None, - }; - match picked { - Some(hs) => Ok(Some(Input::Pick(cmd::groups::expand(self, hs)))), - None => Ok(Some(input)), - } - } - Input::Cancel => { - self.pending_window = None; - Ok(Some(Input::Cancel)) - } - other => Ok(Some(other)), - } - } - - /// Input while a typed selection mode (W, C, F, WP, CP) collects its points: the selected - /// objects go to the command as a pick once the mode is complete. - fn select_mode_input(&mut self, input: Input) -> Result> { - let Some(mut mode) = self.select_mode.take() else { return Ok(Some(input)) }; - if mode.kind == select::SelectKind::Group { - return match input { - Input::Text(name) | Input::Keyword(name) => match select::select_group(self.doc()?, &self.space(), &name) { - Some(hs) => Ok(Some(Input::Pick(hs))), - None => { - self.echo(format!("No group named \"{}\".", name.trim())); - Ok(None) - } - }, - // Enter or Esc leaves the group name prompt. - Input::Cancel => Ok(Some(input)), - Input::Enter => Ok(None), - _ => { - self.echo("Invalid group name."); - self.select_mode = Some(mode); - Ok(None) - } - }; - } - match input { - Input::Point(p) => { - if mode.push(p) { - return Ok(mode.select(self.doc()?, &self.space()).map(Input::Pick)); - } - } - Input::Keyword(k) if k == "Undo" => { - mode.pts.pop(); - } - // Enter ends a fence or polygon; with too few points the mode is dropped. - Input::Enter => return Ok(mode.select(self.doc()?, &self.space()).map(Input::Pick)), - Input::Cancel | Input::Pick(_) => return Ok(Some(input)), - _ => self.echo("Invalid point."), - } - self.select_mode = Some(mode); - Ok(None) - } - - fn feed(&mut self, input: Option) -> Result<()> { - let base = if matches!(input, Some(Input::Point(_))) { self.current_prompt().and_then(|p| p.base) } else { None }; - let Some(mut run) = self.running.take() else { return Ok(()) }; - let step = match input { - Some(Input::Cancel) => Ok(Step::Cancel), - Some(i) => { - if let Input::Point(p) = &i { - self.last_point = *p; - if let Some(b) = base.filter(|b| b.is_finite() && p.is_finite() && b.dist(*p) > 1e-12) { - self.last_segment = Some((b, *p)); - } - // A temporary tracking point (TT) serves one point. - self.tracking.release_temporary(); - } - let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run.machine.input(self, i))); - match r { - Ok(r) => r, - Err(_) => Err(EngineError::Internal(run.id.clone(), "panic".into())), - } - } - // A command starts with no modifiers pending. - None => { - self.point_mods.clear(); - run.machine.begin(self) - } - }; - // Redraw what the command has added so far (LINE adds a segment per point), not only when it ends. - if let Ok(st) = self.state_mut() - && !Arc::ptr_eq(&run.before, &st.doc) - { - st.revision += 1; - } - match step { - Ok(Step::Continue) => { - self.running = Some(run); - Ok(()) - } - Ok(Step::Done) => { - self.finish(run, false); - self.resume_suspended(); - Ok(()) - } - Ok(Step::Cancel) => { - self.echo("*Cancel*"); - self.finish(run, true); - self.resume_suspended(); - Ok(()) - } - Err(EngineError::Internal(id, m)) => { - // Restore and end the command. - if let Ok(st) = self.state_mut() { - st.doc = run.before.clone(); - } - self.echo(format!("Internal error in {id}: {m}")); - self.resume_suspended(); - Err(EngineError::Internal(id, m)) - } - Err(e) => { - // Invalid input: report and keep prompting (AutoCAD re-prompts). - self.echo(e.to_string()); - self.running = Some(run); - Ok(()) - } - } - } - - fn finish(&mut self, run: Running, cancelled: bool) { - // A typed selection mode or SIngle never outlives its command. - self.select_mode = None; - self.select_single = false; - if find_command(&run.id).is_some_and(|c| c.undoable) - && let Err(e) = self.refuse_non_finite(None, &run.before, &run.selection_before, &run.id) - { - self.echo(e.to_string()); - } - // A command outside undo that changed the drawing (UNDO, which swapped it for an earlier - // one) records no undo step, as in `execute`. - if find_command(&run.id).is_some_and(|c| !c.undoable) && self.state().is_ok_and(|st| !Arc::ptr_eq(&run.before, &st.doc)) { - self.pending_window = None; - return; - } - self.pending_window = None; - self.select_mode = None; - let label = find_command(&run.id).map(|c| c.label).unwrap_or("Command"); - let _ = cancelled; - if let Some(m) = cmd::blocks::refedit::after_command(self, &run.before, &run.id) { - self.echo(m); - } - self.after_command(Some(&run.before), None, true); - // A transparent command's changes belong to the command it ran inside, and a command a - // LISP routine ran belongs to the routine's single undo step. - if self.suspended.is_some() || self.lisp_groups_undo() { - if let Ok(st) = self.state_mut() - && !Arc::ptr_eq(&run.before, &st.doc) - { - st.revision += 1; - } - return; - } - if let Ok(st) = self.state_mut() - && !Arc::ptr_eq(&run.before, &st.doc) - { - st.push_undo(Snapshot { label: label.to_string(), doc: run.before, selection: run.selection_before }); - st.revision += 1; - } - } - - /// The post-command passes, in order, run after every command — by [`Session::execute`] and - /// when an interactive command finishes — so their edits land in the command's own undo step: - /// group membership is pruned, constraints are inferred, purged and re-solved, and - /// associative dimensions and hatches follow their sources. A new pass is added here, once. - /// - /// `before` is the drawing the command started from; `uid` (when known) is the document it - /// started in, so associativity is not applied across a document switch; `ok` is false for a - /// failed or non-undoable programmatic command, which skips the constraint pass. - fn after_command(&mut self, before: Option<&Arc>, uid: Option, ok: bool) { - cmd::groups::after_command(self, before); - cmd::constraints::after_command(self, before, ok); - assoc::after_command(self, before, uid); - } - - fn resume_suspended(&mut self) { - if let Some(outer) = self.suspended.take() { - self.echo(format!("Resuming {} command.", outer.id.to_ascii_uppercase())); - self.running = Some(outer); - } - } - - /// Cancel the running command (Esc). With no command, clears the selection. Inside a - /// transparent command, cancels it and the command it interrupted. - pub fn cancel(&mut self) { - self.point_mods.clear(); - // Esc stops a LISP routine waiting for input, and the command it paused in. - if self.lisp_waiting() { - if self.running.is_some() { - let _ = self.feed(Some(Input::Cancel)); - } - self.echo("*Cancel*"); - self.lisp_abort("Function cancelled"); - return; - } - if self.running.is_some() { - if self.suspended.is_some() { - if let Some(nested) = self.running.take() { - self.finish(nested, true); - } - self.running = self.suspended.take(); - } - let _ = self.feed(Some(Input::Cancel)); - } else { - self.pending_window = None; - self.set_selection(Vec::new()); - } - } - - /// The multiline text the running command is collecting, for the in-place editor - /// ([`Interactive::editing_mtext`]). - pub fn editing_mtext(&self) -> Option { - self.running.as_ref().and_then(|r| r.machine.editing_mtext()) - } - - pub fn current_prompt(&self) -> Option { - if let Some(p) = self.lisp_prompt() { - return Some(p); - } - self.running.as_ref().map(|r| { - let p = r.machine.prompt(self); - match &self.select_mode { - Some(m) if p.accept.select => m.prompt(), - _ => p, - } - }) - } - - /// Whether the cursor picks objects right now, so the UI shows the pick box (PICKBOX) at the - /// crosshair: at prompts that select objects, and with no command running when PICKFIRST is on - /// (noun-verb selection). - pub fn picking_objects(&self) -> bool { - match self.current_prompt() { - Some(p) => p.picks_objects(), - None => self.settings.pickfirst, - } - } - - /// Whether the object under the cursor is highlighted (SELECTIONPREVIEW): the cursor picks - /// objects ([`Self::picking_objects`]) and the bit for a command asking for them (2) or for - /// no command (1) is on. - pub fn previews_selection(&self) -> bool { - let bit = if self.current_prompt().is_some() { 2 } else { 1 }; - self.picking_objects() && self.settings.selectionpreview & bit != 0 - } - - /// Whether selected objects show grips while `selected` objects are selected (GRIPS, - /// GRIPOBJLIMIT). - pub fn shows_grips(&self, selected: usize) -> bool { - let limit = self.settings.gripobjlimit; - self.settings.grips > 0 && (limit == 0 || selected <= limit as usize) - } - - /// The text shown on the command line: the active prompt, or "Command:". - pub fn prompt_text(&self) -> String { - if let Some(p) = self.lisp_prompt() { - return p.display(); - } - match &self.running { - Some(r) if self.suspended.is_some() => format!(">>{} {}", r.id.to_ascii_uppercase(), r.machine.prompt(self).display()), - Some(r) => format!("{} {}", r.id.to_ascii_uppercase(), r.machine.prompt(self).display()), - None => "Command:".into(), - } - } - - /// Preview entities for the cursor: objects drawn with their own properties - /// ([`Interactive::preview_objects`]), then the rubber band with the current properties, in - /// the grip blue over such objects (so it shows over their fills, whatever their colour). - pub fn preview(&self, cursor: Vec2) -> Vec { - let mut common = self.current_common(); - let (kinds, mut out) = match (self.lisp_preview(cursor), &self.running) { - (Some(k), _) => (k, Vec::new()), - (None, Some(r)) => (r.machine.preview(self, cursor), r.machine.preview_objects(self, cursor)), - (None, None) => (Vec::new(), Vec::new()), - }; - if !out.is_empty() { - common.color = cadcraft_doc::color::Color::True(cadcraft_doc::color::Rgb(0x2f, 0x6b, 0xff)); - } - out.extend(kinds.into_iter().map(|k| Entity { handle: Handle(0), common: common.clone(), kind: k })); - out - } - - /// The active command's preview as a transform of existing objects (see - /// [`Interactive::drag`]), with its extra rubber-band geometry as entities. - pub fn drag(&self, cursor: Vec2) -> Option<(Drag, Vec)> { - let r = self.running.as_ref()?; - let d = r.machine.drag(self, cursor)?; - let common = self.current_common(); - let extra = d.extra.iter().map(|k| Entity { handle: Handle(0), common: common.clone(), kind: k.clone() }).collect(); - Some((d, extra)) - } - - /// Process one line typed at the command line (Enter pressed). Spaces separate inputs as in a - /// script line (see [`Session::script`]), so `MOVE L 0,0 5,5` ends the selection with the - /// second space and `ERASE L ` with the trailing one. Input after the command ends is ignored. - pub fn cmdline(&mut self, text: &str) -> Result<()> { - let t = text.trim_end_matches(['\r', '\n']); - // The command line types spaces literally at free-text prompts: the line is one input. - if self.current_prompt().is_some_and(|p| p.accept.text && !p.accept.point && !p.accept.number) { - return self.typed(t); - } - // At `Command:` leading blanks before a command name are not Enter (`" LINE"` from the - // control channel starts LINE); a line of only blanks still is. - let t = if self.running.is_none() && !t.trim().is_empty() { t.trim_start() } else { t }; - self.input_line(t, false) - } - - /// One line of input: each space ends an input and an empty input (two spaces in a row, a - /// trailing space, an empty line) is Enter, which at `Command:` repeats the last command. In a - /// script, a free-text prompt (TEXT/MTEXT contents) takes the rest of the line, a prompt with - /// keywords still splits so `I 15` reaches the keyword and the value, and the line goes on - /// after its command ends; at the command line the rest after the command ends is ignored. - fn input_line(&mut self, line: &str, script: bool) -> Result<()> { - let mut rest = line; - let mut first = true; - loop { - if self.running.is_none() { - if !script && !first { - return Ok(()); - } - // `cmd {json}`: programmatic call with parameters. - if let Some((name, json)) = rest.trim().split_once(' ') - && json.trim_start().starts_with('{') - { - let params: Value = - serde_json::from_str(json.trim()).map_err(|e| EngineError::BadParams { cmd: name.into(), msg: e.to_string() })?; - let id = cmd::resolve_alias(&name.to_ascii_lowercase()); - let r = self.execute(&id, ¶ms)?; - if !r.is_null() { - self.echo(r.to_string()); - } - return Ok(()); - } - } else if script && self.current_prompt().is_some_and(|p| p.accept.text && !p.accept.point && !p.accept.number && p.keywords.is_empty()) { - return self.typed(rest); - } - first = false; - let (tok, more) = next_token(rest); - if self.running.is_some() || self.lisp_prompt().is_some() { - self.typed(tok)?; - } else if tok.trim().is_empty() { - self.input(Input::Enter)?; - } else if lisp_expression(tok) { - // `(expression)` or `!variable` at Command: evaluates LISP and shows the value. - let src = tok.trim().strip_prefix('!').unwrap_or(tok.trim()).to_string(); - if let Err(e) = self.lisp_eval(&src, true, false, Vec::new()) { - self.echo(e.to_string()); - } - } else { - self.start(tok)?; - } - match more { - Some(more) => rest = more, - None => return Ok(()), - } - } - } - - /// One token typed while a command runs. - fn typed(&mut self, t: &str) -> Result<()> { - let Some(prompt) = self.current_prompt() else { return Ok(()) }; - let tt = t.trim(); - if tt.is_empty() { - return self.input(Input::Enter); - } - // FROM, M2P, point filters, ` v, - Ok(_) => return Ok(()), - Err(e) => { - self.echo(e.to_string()); - return Ok(()); - } - }; - return match v { - lisp::value::Value::Nil => Ok(()), - lisp::value::Value::Int(i) => self.typed(&i.to_string()), - lisp::value::Value::Real(r) => self.typed(&format!("{r}")), - lisp::value::Value::Str(s) => self.typed(&s), - other => match other.as_point() { - Some(p) => self.input(Input::Point(Vec2::new(p[0], p[1]))), - None => { - self.echo(format!("Invalid input: {}", lisp::value::to_text(&other, true))); - Ok(()) - } - }, - }; - } - // At "Select objects" prompts the selection modes (Previous, Last, ALL) win over a command - // keyword sharing the letter (HATCH's "picK internal point" against `P`). - let selection_mode = prompt.accept.select - && matches!(tt.strip_prefix('_').unwrap_or(tt).to_ascii_lowercase().as_str(), "all" | "l" | "last" | "p" | "previous"); - // A leading underscore asks for a keyword by its English name, whatever the interface - // language (`_M`, `_Yes`): scripts and LISP routines write them that way. (Snap - // overrides such as `_cen` were taken above.) - if !selection_mode - && let Some(k) = prompt.match_keyword(tt).or_else(|| tt.strip_prefix('_').and_then(|r| prompt.match_keyword(r))) - && !(prompt.accept.number && tt.parse::().is_ok()) - { - return self.input(Input::Keyword(k)); - } - if prompt.accept.point { - if let Some(p) = prompt::parse_point_with(tt, self.last_point, &self.angle_settings()) { - self.last_polar = prompt::parse_polar_text(tt, &self.angle_settings()); - return self.input(Input::Point(p)); - } - // Direct distance entry along the tracking path the cursor is on, from the point it - // comes from (an acquired or temporary tracking point needs no base point). - if let Some(dist) = units::parse_distance(tt).filter(|_| !prompt.accept.number) - && let Some((from, dir)) = self.tracking.last.as_ref().filter(|t| t.point.near(self.cursor, 1e-9)).and_then(|t| t.along()) - { - return self.input(Input::Point(from + dir * dist)); - } - // Direct distance entry along the rubber band. - if let (Some(base), Some(dist)) = (prompt.base, units::parse_distance(tt)) - && !prompt.accept.number - { - let mut c = self.cursor; - if self.settings.orthomode && !prompt.corner { - c = snap::iso::constrain(&self.drafting_settings(), base, c); - } - let dir = (c - base).normalized(); - let dir = if dir == Vec2::ZERO { Vec2::X } else { dir }; - return self.input(Input::Point(base + dir * dist)); - } - } - self.input(Input::Text(tt.to_string())) - } - - /// Run a script, as AutoCAD reads one: a space or a line end ends an input, and an empty - /// input (two spaces in a row, a space at the end of a line, an empty line) is Enter. At - /// free-text prompts spaces are part of the text. Lines starting with `;` are comments. - /// - /// A script cannot run a script: the SCRIPT command (or another `cmd {json}` call) inside a - /// running one is refused, so two files that run each other cannot recurse until the stack dies. - pub fn script(&mut self, text: &str) -> Result<()> { - if self.in_script { - return Err(EngineError::BadParams { cmd: "script".into(), msg: "a script is already running; nested scripts are not supported".into() }); - } - self.in_script = true; - let r = self.script_lines(text); - self.in_script = false; - r - } - - fn script_lines(&mut self, text: &str) -> Result<()> { - for line in text.lines() { - // Trailing spaces are inputs (Enter); other trailing whitespace (`\r`) is not. - let line = line.trim_end_matches(|c: char| c.is_whitespace() && c != ' '); - if line.trim_start().starts_with(';') { - continue; - } - self.input_line(line, true)?; - } - Ok(()) - } - - // ---------------- undo ---------------- - - pub fn undo(&mut self) -> Result> { - let st = self.state_mut()?; - let Some(snap) = st.undo.pop() else { return Ok(None) }; - let cur = Snapshot { label: snap.label.clone(), doc: st.doc.clone(), selection: st.selection.clone() }; - st.doc = snap.doc; - st.follow_layout(&cur.doc); - st.selection = Vec::new(); - st.redo.push(cur); - st.revision += 1; - Ok(Some(snap.label)) - } - pub fn redo(&mut self) -> Result> { - let st = self.state_mut()?; - let Some(snap) = st.redo.pop() else { return Ok(None) }; - let cur = Snapshot { label: snap.label.clone(), doc: st.doc.clone(), selection: st.selection.clone() }; - st.doc = snap.doc; - st.follow_layout(&cur.doc); - st.selection = Vec::new(); - st.undo.push(cur); - st.revision += 1; - Ok(Some(snap.label)) - } - - // ---------------- picking without a command ---------------- - - /// Open a selection window with one corner at `corner`, for press-and-drag selection - /// (AutoCAD's PICKDRAG = 2: a drag opens the window wherever it starts, even over an object; - /// the next point, where the drag ends, closes it). Only while objects are being selected: no - /// command running, or a command asking for objects. Returns whether a window was opened. - pub fn begin_window(&mut self, corner: Vec2) -> bool { - let during_command = self.running.is_some(); - if !self.settings.pickdrag || !corner.is_finite() || (during_command && !self.current_prompt().is_some_and(|p| p.accept.select)) { - return false; - } - self.pending_window = Some(PendingWindow { corner, during_command }); - true - } - - /// A click with no command running: pick/toggle objects or start a selection window. - /// - /// PICKADD on (the default): picks add to the selection and Shift removes. PICKADD off: each pick - /// replaces the selection and Shift adds (Shift+pick on a selected object removes it). - pub fn idle_click(&mut self, p: Vec2, shift: bool) -> Result<()> { - let space = self.space(); - let pickadd = self.settings.pickadd; - if let Some(pw) = self.pending_window.take() { - let crossing = p.x < pw.corner.x; - let hs = select::select_window(self.doc()?, &space, Bounds2::new(pw.corner, p), crossing); - let hs = cmd::groups::expand(self, hs); - let mut sel = if pickadd || shift { self.selection() } else { Vec::new() }; - if shift && pickadd { - sel.retain(|h| !hs.contains(h)); - } else { - sel.extend(hs); - } - self.set_selection(sel); - return Ok(()); - } - let ap = self.pixel_size() * self.settings.pickbox.max(1.0) * 1.5; - match select::pick(self.doc()?, &space, p, ap) { - Some(h) => self.idle_pick(h, shift), - None => self.pending_window = Some(PendingWindow { corner: p, during_command: false }), - } - Ok(()) - } - - /// An idle click that picked `h`, with [`Self::idle_click`]'s PICKADD and Shift rules. - fn idle_pick(&mut self, h: Handle, shift: bool) { - let pickadd = self.settings.pickadd; - // With group selection the object's selectable groups come (or go) with it. - let hs = cmd::groups::expand(self, vec![h]); - let mut sel = if pickadd || shift { self.selection() } else { Vec::new() }; - let had = sel.contains(&h); - if shift && (pickadd || had) { - sel.retain(|x| !hs.contains(x)); - } else if !had { - sel.extend(hs); - } - self.set_selection(sel); - } - - /// Whether a click at `p` selects objects by picking: no command running, or a command at - /// "Select objects" with no selection window or typed mode under way. - pub fn click_picks(&self) -> bool { - self.pending_window.is_none() - && match self.running.as_ref() { - None => self.lisp_prompt().is_none(), - Some(r) => self.select_mode.is_none() && r.machine.prompt(self).accept.select, - } - } - - /// The objects under the pick box at `p` when selection cycling is on and more than one is - /// there: what a click at `p` would choose between. - fn cycling_hits(&self, p: Vec2) -> Vec { - if !self.settings.selection_cycling { - return Vec::new(); - } - let hs = self.pick_candidates(p); - if hs.len() > 1 { hs } else { Vec::new() } - } - - /// Every object under the pick box at `p`, nearest first, when a click there picks objects - /// (empty otherwise): what Shift+Space steps the highlight through. - pub fn pick_candidates(&self, p: Vec2) -> Vec { - if !self.click_picks() { - return Vec::new(); - } - let ap = self.pixel_size() * self.settings.pickbox.max(1.0) * 1.5; - self.doc().map(|d| select::pick_all(d, &self.space(), p, ap)).unwrap_or_default() - } - - /// Whether selection cycling marks `p` with its badge (SELECTIONCYCLING 1 or 2): overlapping - /// objects a click there would choose between. - pub fn cycling_badge(&self, p: Vec2) -> bool { - self.settings.selection_cycling_display >= 1 && !self.cycling_hits(p).is_empty() - } - - /// The objects a click at `p` lists to choose from (SELECTIONCYCLING 2), nearest first; empty - /// when the click should pick as usual. - pub fn cycling_candidates(&self, p: Vec2) -> Vec { - if self.settings.selection_cycling_display < 2 { - return Vec::new(); - } - self.cycling_hits(p) - } - - /// Pick `h` from the selection cycling list, as a click on it alone would have: into the - /// command at "Select objects", else into the selection (Shift as for [`Self::idle_click`]). - pub fn pick_cycled(&mut self, h: Handle, shift: bool) -> Result<()> { - // The list may be older than an undo or an erase. - if self.doc()?.entity(h).is_none() { - return Ok(()); - } - if self.running.is_some() { - let hs = cmd::groups::expand(self, vec![h]); - return self.input(Input::Pick(hs)); - } - self.idle_pick(h, shift); - Ok(()) - } - - /// Remember the selection used by a command as "Previous". - pub fn remember_selection(&mut self, sel: &[Handle]) { - if let Ok(st) = self.state_mut() { - st.previous_selection = sel.to_vec(); - } - } - - // ---------------- views ---------------- - - /// The limits of the space being edited: the sheet in paper space, else LIMMIN/LIMMAX. - pub fn zoom_limits(&self) -> Result { - let d = self.doc()?; - if let Space::Paper(name) = self.space() - && let Some(sheet) = cadcraft_render::sheet(d, &name) - { - return Ok(sheet.bounds()); - } - let lo = d.header.point("LIMMIN").map(|p| p.xy()).unwrap_or(Vec2::ZERO); - let hi = d.header.point("LIMMAX").map(|p| p.xy()).unwrap_or(Vec2::new(12.0, 9.0)); - Ok(Bounds2::new(lo, hi)) - } - - pub fn zoom_extents(&mut self) -> Result<()> { - let space = self.space(); - let ext = self.doc()?.extents(&space); - let (w, h) = self.viewport_px; - let ext = if ext.is_empty() { self.zoom_limits()? } else { ext }; - let st = self.state_mut()?; - let aspect = st.zoom_aspect(w / h.max(1.0)); - let height = ext.height().max(ext.width() / aspect.max(1e-6)).max(1e-6) * 1.05; - st.set_zoom_frame(View { center: ext.center(), height }); - Ok(()) - } - - /// Zoom by `factor` (> 1 zooms in) keeping `about` fixed on screen. - pub fn zoom_about(&mut self, factor: f64, about: Vec2) -> Result<()> { - if !(factor.is_finite() && factor > 0.0) { - return Ok(()); - } - let st = self.state_mut()?; - let v = st.view(); - let h = (v.height / factor).clamp(1e-9, 1e12); - let k = h / v.height; - let c = about + (v.center - about) * k; - st.set_view_quiet(View { center: c, height: h }); - Ok(()) - } - /// Serializable summary for inspect/automation. pub fn summary(&self) -> Value { let docs: Vec = self @@ -1887,51 +312,5 @@ impl Session { } } -/// Typed text that is LISP: `(expression)` or `!variable`. -fn lisp_expression(t: &str) -> bool { - let t = t.trim(); - t.starts_with('(') || (t.starts_with('!') && t.len() > 1) -} - -/// The next command-line input in `rest` and what follows it: up to a space or tab, except that -/// a LISP expression runs to its closing parenthesis (spaces and quoted strings inside it). -fn next_token(rest: &str) -> (&str, Option<&str>) { - let lead = rest.len() - rest.trim_start_matches('!').len(); - if rest.get(lead..).is_some_and(|r| r.starts_with('(')) { - let (mut depth, mut in_str, mut escaped) = (0i32, false, false); - for (i, c) in rest.char_indices().skip(lead) { - if in_str { - match (escaped, c) { - (true, _) => escaped = false, - (false, '\\') => escaped = true, - (false, '"') => in_str = false, - _ => {} - } - continue; - } - match c { - '"' => in_str = true, - '(' => depth += 1, - ')' => { - depth -= 1; - if depth == 0 { - let end = i + 1; - // As after any input, a space ends it and a trailing one is Enter. - let more = rest.get(end..).filter(|m| !m.is_empty()).map(|m| m.strip_prefix([' ', '\t']).unwrap_or(m)); - return (&rest[..end], more); - } - } - _ => {} - } - } - // Unbalanced: all of it (the reader reports the error). - return (rest, None); - } - match rest.split_once([' ', '\t']) { - Some((tok, more)) => (tok, Some(more)), - None => (rest, None), - } -} - #[cfg(test)] mod tests; diff --git a/crates/engine/src/lisp/mod.rs b/crates/engine/src/lisp.rs similarity index 100% rename from crates/engine/src/lisp/mod.rs rename to crates/engine/src/lisp.rs diff --git a/crates/engine/src/lisp/builtins.rs b/crates/engine/src/lisp/builtins.rs index 8e89d667c..e953bec9d 100644 --- a/crates/engine/src/lisp/builtins.rs +++ b/crates/engine/src/lisp/builtins.rs @@ -13,400 +13,6 @@ use super::eval::{Ctx, Flow, Special}; use super::value::{Builtin, BuiltinKind, Sym, Value, charge_bytes, check_string_len, eq, equal, real_text, to_text}; use super::{Env, LispError, drawing, input, read}; -type R = Result; - -/// `too few arguments` / `too many arguments`. -pub fn args_err(few: bool) -> LispError { - LispError::new(if few { "too few arguments" } else { "too many arguments" }) -} - -fn arity(a: &[Value], min: usize, max: usize) -> Result<(), LispError> { - if a.len() < min { - return Err(args_err(true)); - } - if a.len() > max { - return Err(args_err(false)); - } - Ok(()) -} - -fn num(v: &Value) -> Result { - v.as_number().ok_or_else(|| LispError::bad_arg("numberp", v)) -} - -fn int(v: &Value) -> Result { - match v { - Value::Int(i) => Ok(*i), - other => Err(LispError::bad_arg("fixnump", other)), - } -} - -fn string(v: &Value) -> Result, LispError> { - match v { - Value::Str(s) => Ok(s.clone()), - other => Err(LispError::bad_arg("stringp", other)), - } -} - -fn list_arg(v: &Value) -> Result, LispError> { - if v.is_list() { Ok(v.to_vec()) } else { Err(LispError::bad_arg("listp", v)) } -} - -fn point(v: &Value) -> Result<[f64; 3], LispError> { - v.as_point().ok_or_else(|| LispError::bad_arg("2D/3D point", v)) -} - -fn real_or_int(r: f64, all_int: bool) -> Value { - if all_int { Value::Int(r as i32) } else { Value::Real(r) } -} - -// ---------------- numbers ---------------- - -/// Integer arithmetic wraps at 32 bits, as AutoLISP's does; any real makes the result real. -fn arith(a: &[Value], op: char) -> R { - let all_int = a.iter().all(|v| matches!(v, Value::Int(_))); - let mut it = a.iter(); - let Some(first) = it.next() else { - return Ok(Value::Int(if op == '*' { 1 } else { 0 })); - }; - if all_int { - let mut acc = int(first)?; - if a.len() == 1 { - return Ok(Value::Int(match op { - '-' => acc.wrapping_neg(), - _ => acc, - })); - } - for v in it { - let x = int(v)?; - acc = match op { - '+' => acc.wrapping_add(x), - '-' => acc.wrapping_sub(x), - '*' => acc.wrapping_mul(x), - _ => { - if x == 0 { - return Err(LispError::new("divide by zero")); - } - acc.wrapping_div(x) - } - }; - } - return Ok(Value::Int(acc)); - } - let mut acc = num(first)?; - if a.len() == 1 { - return Ok(Value::Real(if op == '-' { -acc } else { acc })); - } - for v in it { - let x = num(v)?; - acc = match op { - '+' => acc + x, - '-' => acc - x, - '*' => acc * x, - _ => { - if x == 0.0 { - return Err(LispError::new("divide by zero")); - } - acc / x - } - }; - } - Ok(Value::Real(acc)) -} - -fn add(a: &[Value]) -> R { - arith(a, '+') -} -fn sub(a: &[Value]) -> R { - arith(a, '-') -} -fn mul(a: &[Value]) -> R { - arith(a, '*') -} -fn div(a: &[Value]) -> R { - arity(a, 1, usize::MAX)?; - arith(a, '/') -} - -fn rem(a: &[Value]) -> R { - arity(a, 1, usize::MAX)?; - if a.iter().all(|v| matches!(v, Value::Int(_))) { - let mut it = a.iter(); - let mut acc = int(it.next().unwrap_or(&Value::Int(0)))?; - for v in it { - let x = int(v)?; - if x == 0 { - return Err(LispError::new("divide by zero")); - } - acc = acc.wrapping_rem(x); - } - return Ok(Value::Int(acc)); - } - let mut it = a.iter(); - let mut acc = num(it.next().unwrap_or(&Value::Int(0)))?; - for v in it { - let x = num(v)?; - if x == 0.0 { - return Err(LispError::new("divide by zero")); - } - acc %= x; - } - Ok(Value::Real(acc)) -} - -fn one_plus(a: &[Value]) -> R { - arity(a, 1, 1)?; - arith(&[a[0].clone(), Value::Int(1)], '+') -} -fn one_minus(a: &[Value]) -> R { - arity(a, 1, 1)?; - arith(&[a[0].clone(), Value::Int(1)], '-') -} - -fn abs(a: &[Value]) -> R { - arity(a, 1, 1)?; - Ok(match &a[0] { - Value::Int(i) => Value::Int(i.wrapping_abs()), - v => Value::Real(num(v)?.abs()), - }) -} - -fn min_max(a: &[Value], max: bool) -> R { - arity(a, 1, usize::MAX)?; - let all_int = a.iter().all(|v| matches!(v, Value::Int(_))); - let mut best = num(&a[0])?; - for v in &a[1..] { - let x = num(v)?; - if (max && x > best) || (!max && x < best) { - best = x; - } - } - Ok(real_or_int(best, all_int)) -} -fn min(a: &[Value]) -> R { - min_max(a, false) -} -fn max(a: &[Value]) -> R { - min_max(a, true) -} - -fn unary(a: &[Value], f: fn(f64) -> f64) -> R { - arity(a, 1, 1)?; - let r = f(num(&a[0])?); - if r.is_finite() { Ok(Value::Real(r)) } else { Err(LispError::new("function undefined for argument")) } -} -fn sqrt(a: &[Value]) -> R { - if num(a.first().unwrap_or(&Value::Nil))? < 0.0 { - return Err(LispError::new("function undefined for argument")); - } - unary(a, f64::sqrt) -} -fn exp(a: &[Value]) -> R { - unary(a, f64::exp) -} -fn log(a: &[Value]) -> R { - if num(a.first().unwrap_or(&Value::Nil))? <= 0.0 { - return Err(LispError::new("function undefined for argument")); - } - unary(a, f64::ln) -} -fn sin(a: &[Value]) -> R { - unary(a, f64::sin) -} -fn cos(a: &[Value]) -> R { - unary(a, f64::cos) -} -fn atan(a: &[Value]) -> R { - arity(a, 1, 2)?; - let y = num(&a[0])?; - Ok(Value::Real(match a.get(1) { - Some(x) => y.atan2(num(x)?), - None => y.atan(), - })) -} - -fn expt(a: &[Value]) -> R { - arity(a, 2, 2)?; - match (&a[0], &a[1]) { - (Value::Int(b), Value::Int(e)) if *e >= 0 => Ok(Value::Int(b.wrapping_pow(u32::try_from(*e).unwrap_or(0)))), - (b, e) => { - let r = num(b)?.powf(num(e)?); - if r.is_finite() { Ok(Value::Real(r)) } else { Err(LispError::new("function undefined for argument")) } - } - } -} - -fn fix(a: &[Value]) -> R { - arity(a, 1, 1)?; - Ok(match &a[0] { - Value::Int(i) => Value::Int(*i), - v => { - let r = num(v)?.trunc(); - if r.is_finite() && r >= f64::from(i32::MIN) && r <= f64::from(i32::MAX) { Value::Int(r as i32) } else { Value::Real(r) } - } - }) -} - -fn float(a: &[Value]) -> R { - arity(a, 1, 1)?; - Ok(Value::Real(num(&a[0])?)) -} - -fn gcd(a: &[Value]) -> R { - arity(a, 2, 2)?; - let (mut x, mut y) = (i64::from(int(&a[0])?).abs(), i64::from(int(&a[1])?).abs()); - if x == 0 && y == 0 { - return Err(LispError::new("bad argument value: 0")); - } - while y != 0 { - let t = x % y; - x = y; - y = t; - } - Ok(Value::Int(i32::try_from(x).unwrap_or(i32::MAX))) -} - -fn logand(a: &[Value]) -> R { - a.iter().try_fold(-1i32, |acc, v| Ok(acc & int(v)?)).map(|r| Value::Int(if a.is_empty() { 0 } else { r })) -} -fn logior(a: &[Value]) -> R { - a.iter().try_fold(0i32, |acc, v| Ok(acc | int(v)?)).map(Value::Int) -} -fn lsh(a: &[Value]) -> R { - arity(a, 0, 2)?; - let x = a.first().map(int).transpose()?.unwrap_or(0); - let n = a.get(1).map(int).transpose()?.unwrap_or(0); - let r = if n >= 32 || n <= -32 { - 0 - } else if n >= 0 { - x.wrapping_shl(n as u32) - } else { - ((x as u32) >> ((-n) as u32)) as i32 - }; - Ok(Value::Int(r)) -} -fn lognot(a: &[Value]) -> R { - arity(a, 1, 1)?; - Ok(Value::Int(!int(&a[0])?)) -} - -/// Numbers compare by value; strings by text. -fn compare(a: &Value, b: &Value) -> Result { - match (a, b) { - (Value::Str(x), Value::Str(y)) => Ok(x.cmp(y)), - _ => num(a)?.partial_cmp(&num(b)?).ok_or_else(|| LispError::new("bad argument value")), - } -} - -fn chain(a: &[Value], ok: fn(std::cmp::Ordering) -> bool) -> R { - arity(a, 1, usize::MAX)?; - for w in a.windows(2) { - if let [x, y] = w - && !ok(compare(x, y)?) - { - return Ok(Value::Nil); - } - } - Ok(Value::t()) -} -/// `=` and `/=`: numbers and strings compare as [`compare`] does, and any other atom compares by -/// what it is, as `eq` does. AutoLISP answers `(= (type x) 'LIST)` -- the idiom for a type test, -/// since `type` returns a symbol -- where coercing that symbol to a number failed the whole -/// routine with "bad argument type" instead (#1597). A mismatch is `nil`, not an error, so -/// `(= 1 "1")` and `(= '(1) '(1))` answer too. The ordering comparisons stay numbers and strings -/// only, as AutoLISP has them. -fn eq_chain(a: &[Value], want_equal: bool) -> R { - arity(a, 1, usize::MAX)?; - for w in a.windows(2) { - let [x, y] = w else { continue }; - let same = match (x, y) { - (Value::Str(_), Value::Str(_)) => compare(x, y)?.is_eq(), - _ if x.as_number().is_some() && y.as_number().is_some() => compare(x, y)?.is_eq(), - _ => eq(x, y), - }; - if same != want_equal { - return Ok(Value::Nil); - } - } - Ok(Value::t()) -} -fn num_eq(a: &[Value]) -> R { - eq_chain(a, true) -} -fn num_ne(a: &[Value]) -> R { - eq_chain(a, false) -} -fn lt(a: &[Value]) -> R { - chain(a, Ordering::is_lt) -} -fn gt(a: &[Value]) -> R { - chain(a, Ordering::is_gt) -} -fn le(a: &[Value]) -> R { - chain(a, Ordering::is_le) -} -fn ge(a: &[Value]) -> R { - chain(a, Ordering::is_ge) -} - -fn zerop(a: &[Value]) -> R { - arity(a, 1, 1)?; - Ok(Value::bool(num(&a[0])? == 0.0)) -} -fn minusp(a: &[Value]) -> R { - arity(a, 1, 1)?; - Ok(Value::bool(num(&a[0])? < 0.0)) -} -fn numberp(a: &[Value]) -> R { - arity(a, 1, 1)?; - Ok(Value::bool(a[0].as_number().is_some())) -} - -// ---------------- predicates and lists ---------------- - -fn atom(a: &[Value]) -> R { - arity(a, 1, 1)?; - Ok(Value::bool(!matches!(a[0], Value::Cons(_)))) -} -fn listp(a: &[Value]) -> R { - arity(a, 1, 1)?; - Ok(Value::bool(matches!(a[0], Value::Cons(_) | Value::Nil))) -} -fn consp(a: &[Value]) -> R { - arity(a, 1, 1)?; - Ok(Value::bool(matches!(a[0], Value::Cons(_)))) -} -fn null(a: &[Value]) -> R { - arity(a, 1, 1)?; - Ok(Value::bool(a[0].is_nil())) -} -fn type_of(a: &[Value]) -> R { - arity(a, 1, 1)?; - Ok(a[0].type_name().map(Value::sym).unwrap_or_default()) -} -fn eq_fn(a: &[Value]) -> R { - arity(a, 2, 2)?; - Ok(Value::bool(eq(&a[0], &a[1]))) -} -fn equal_fn(a: &[Value]) -> R { - arity(a, 2, 3)?; - let fuzz = a.get(2).map(num).transpose()?.unwrap_or(0.0); - Ok(Value::bool(equal(&a[0], &a[1], fuzz))) -} - -fn cxr(a: &[Value], path: &str) -> R { - arity(a, 1, 1)?; - let mut v = a[0].clone(); - // The path is read right to left: cadr = car of cdr. - for c in path.chars().rev() { - if !matches!(v, Value::Cons(_) | Value::Nil) { - return Err(LispError::bad_arg("consp", &v)); - } - v = if c == 'a' { v.car() } else { v.cdr() }; - } - Ok(v) -} - macro_rules! cxrs { ($($name:ident $path:literal),*) => { $(fn $name(a: &[Value]) -> R { cxr(a, $path) })* @@ -415,942 +21,6 @@ macro_rules! cxrs { cxrs!(car "a", cdr "d", caar "aa", cadr "ad", cdar "da", cddr "dd", caaar "aaa", caadr "aad", cadar "ada", caddr "add", cdaar "daa", cdadr "dad", cddar "dda", cdddr "ddd", cadddr "addd", cddddr "dddd"); -fn cons(a: &[Value]) -> R { - arity(a, 2, 2)?; - Ok(Value::cons(a[0].clone(), a[1].clone())) -} -fn list(a: &[Value]) -> R { - Ok(Value::list(a.to_vec())) -} -fn append(a: &[Value]) -> R { - let mut items = Vec::new(); - for v in a { - items.extend(list_arg(v)?); - } - Ok(Value::list(items)) -} -fn reverse(a: &[Value]) -> R { - arity(a, 1, 1)?; - let mut v = list_arg(&a[0])?; - v.reverse(); - Ok(Value::list(v)) -} -fn length(a: &[Value]) -> R { - arity(a, 1, 1)?; - Ok(Value::Int(i32::try_from(list_arg(&a[0])?.len()).unwrap_or(i32::MAX))) -} -fn nth(a: &[Value]) -> R { - arity(a, 2, 2)?; - let n = int(&a[0])?; - if n < 0 { - return Ok(Value::Nil); - } - Ok(a[1].iter().nth(n as usize).unwrap_or_default()) -} -fn last(a: &[Value]) -> R { - arity(a, 1, 1)?; - Ok(list_arg(&a[0])?.pop().unwrap_or_default()) -} -fn member(a: &[Value]) -> R { - arity(a, 2, 2)?; - let mut v = a[1].clone(); - while let Value::Cons(c) = &v { - if equal(&c.car, &a[0], 0.0) { - return Ok(v); - } - let next = c.cdr.clone(); - v = next; - } - Ok(Value::Nil) -} -fn assoc(a: &[Value]) -> R { - arity(a, 2, 2)?; - Ok(a[1].iter().find(|item| matches!(item, Value::Cons(_)) && equal(&item.car(), &a[0], 0.0)).unwrap_or_default()) -} -fn subst(a: &[Value]) -> R { - arity(a, 3, 3)?; - let (new, old) = (&a[0], &a[1]); - if !a[2].is_list() { - return Ok(if equal(&a[2], old, 0.0) { new.clone() } else { a[2].clone() }); - } - Ok(Value::list(a[2].iter().map(|v| if equal(&v, old, 0.0) { new.clone() } else { v }).collect::>())) -} -fn vl_position(a: &[Value]) -> R { - arity(a, 2, 2)?; - Ok(a[1].iter().position(|v| equal(&v, &a[0], 0.0)).map(|i| Value::Int(i32::try_from(i).unwrap_or(i32::MAX))).unwrap_or_default()) -} -fn vl_remove(a: &[Value]) -> R { - arity(a, 2, 2)?; - Ok(Value::list(list_arg(&a[1])?.into_iter().filter(|v| !equal(v, &a[0], 0.0)).collect::>())) -} -fn vl_list_length(a: &[Value]) -> R { - arity(a, 1, 1)?; - Ok(if a[0].is_list() { Value::Int(i32::try_from(a[0].to_vec().len()).unwrap_or(i32::MAX)) } else { Value::Nil }) -} -fn acad_strlsort(a: &[Value]) -> R { - arity(a, 1, 1)?; - let mut v = list_arg(&a[0])?.iter().map(string).collect::, _>>()?; - v.sort_by_key(|s| s.to_lowercase()); - Ok(Value::list(v.into_iter().map(Value::Str).collect::>())) -} - -// ---------------- strings ---------------- - -fn strcat(a: &[Value]) -> R { - let parts = a.iter().map(string).collect::, _>>()?; - // Check the length before joining: many long arguments could join to more than memory. - check_string_len(parts.iter().fold(0usize, |n, p| n.saturating_add(p.len())))?; - Ok(Value::str(&parts.concat())) -} -fn strlen(a: &[Value]) -> R { - let mut n = 0usize; - for v in a { - n += string(v)?.chars().count(); - } - Ok(Value::Int(i32::try_from(n).unwrap_or(i32::MAX))) -} -fn substr(a: &[Value]) -> R { - arity(a, 2, 3)?; - let s = string(&a[0])?; - let start = int(&a[1])?; - if start < 1 { - return Err(LispError::new(format!("bad argument value: positive {start}"))); - } - let chars = s.chars().skip(start as usize - 1); - let out: String = match a.get(2) { - Some(n) => { - let n = int(n)?; - if n < 0 { - return Err(LispError::new(format!("bad argument value: non-negative {n}"))); - } - chars.take(n as usize).collect() - } - None => chars.collect(), - }; - Ok(Value::str(&out)) -} -fn strcase(a: &[Value]) -> R { - arity(a, 1, 2)?; - let s = string(&a[0])?; - let lower = a.get(1).is_some_and(|v| !v.is_nil()); - Ok(Value::str(&if lower { s.to_lowercase() } else { s.to_uppercase() })) -} -fn itoa(a: &[Value]) -> R { - arity(a, 1, 1)?; - Ok(Value::str(&int(&a[0])?.to_string())) -} -/// C's `atoi`: leading blanks, a sign, digits; 0 when there are none. -fn atoi(a: &[Value]) -> R { - arity(a, 1, 1)?; - let s = string(&a[0])?; - let t = s.trim_start(); - let end = t.char_indices().take_while(|(i, c)| c.is_ascii_digit() || (*i == 0 && (*c == '-' || *c == '+'))).count(); - Ok(Value::Int(t.get(..end).and_then(|n| n.parse::().ok()).map(|n| n as i32).unwrap_or(0))) -} -/// C's `atof`: the longest number at the start; 0.0 when there is none. -fn atof(a: &[Value]) -> R { - arity(a, 1, 1)?; - let s = string(&a[0])?; - // The longest prefix (of the first 64 characters) that reads as a finite number. - let t: String = s.trim_start().chars().take(64).collect(); - let ends: Vec = t.char_indices().map(|(i, _)| i).skip(1).chain(std::iter::once(t.len())).collect(); - let v = ends.iter().rev().find_map(|&e| t.get(..e).and_then(|p| p.parse::().ok()).filter(|v| v.is_finite() && !p_is_word(&t[..e]))); - Ok(Value::Real(v.unwrap_or(0.0))) -} -/// Words Rust reads as numbers that C's `atof` doesn't ("inf", "nan", "infinity"). -fn p_is_word(p: &str) -> bool { - p.chars().any(|c| c.is_ascii_alphabetic() && !matches!(c, 'e' | 'E')) -} - -fn chr(a: &[Value]) -> R { - arity(a, 1, 1)?; - let c = u32::try_from(int(&a[0])?).ok().and_then(char::from_u32).map(String::from).unwrap_or_default(); - Ok(Value::str(&c)) -} -fn ascii(a: &[Value]) -> R { - arity(a, 1, 1)?; - Ok(Value::Int(string(&a[0])?.chars().next().map(|c| c as i32).unwrap_or(0))) -} -fn read_fn(a: &[Value]) -> R { - arity(a, 0, 1)?; - match a.first() { - None | Some(Value::Nil) => Ok(Value::Nil), - Some(v) => read::read_one(&string(v)?), - } -} -fn vl_prin1_to_string(a: &[Value]) -> R { - arity(a, 1, 1)?; - Ok(Value::str(&to_text(&a[0], true))) -} -fn vl_princ_to_string(a: &[Value]) -> R { - arity(a, 1, 1)?; - Ok(Value::str(&to_text(&a[0], false))) -} -fn vl_symbol_name(a: &[Value]) -> R { - arity(a, 1, 1)?; - match &a[0] { - Value::Sym(s) => Ok(Value::str(s.name())), - other => Err(LispError::bad_arg("symbolp", other)), - } -} -fn vl_symbolp(a: &[Value]) -> R { - arity(a, 1, 1)?; - Ok(Value::bool(matches!(a[0], Value::Sym(_)))) -} -fn vl_string_search(a: &[Value]) -> R { - arity(a, 2, 3)?; - let (pat, s) = (string(&a[0])?, string(&a[1])?); - let start = a.get(2).map(int).transpose()?.unwrap_or(0).max(0) as usize; - let chars: Vec = s.chars().collect(); - let tail: String = chars.iter().skip(start).collect(); - Ok(tail.find(&*pat).map(|b| Value::Int((start + tail[..b].chars().count()) as i32)).unwrap_or_default()) -} -fn vl_string_subst(a: &[Value]) -> R { - arity(a, 3, 4)?; - let (new, pat, s) = (string(&a[0])?, string(&a[1])?, string(&a[2])?); - let start = a.get(3).map(int).transpose()?.unwrap_or(0).max(0) as usize; - let byte_start = s.char_indices().nth(start).map(|(b, _)| b).unwrap_or(s.len()); - let out = match s.get(byte_start..).and_then(|t| t.find(&*pat)) { - Some(i) if !pat.is_empty() => format!("{}{}{}", &s[..byte_start + i], new, &s[byte_start + i + pat.len()..]), - _ => s.to_string(), - }; - Ok(Value::str(&out)) -} -fn trim_fn(a: &[Value], left: bool, right: bool) -> R { - arity(a, 2, 2)?; - let (set, s) = (string(&a[0])?, string(&a[1])?); - let is = |c: char| set.contains(c); - let mut t: &str = &s; - if left { - t = t.trim_start_matches(is); - } - if right { - t = t.trim_end_matches(is); - } - Ok(Value::str(t)) -} -fn vl_string_trim(a: &[Value]) -> R { - trim_fn(a, true, true) -} -fn vl_string_left_trim(a: &[Value]) -> R { - trim_fn(a, true, false) -} -fn vl_string_right_trim(a: &[Value]) -> R { - trim_fn(a, false, true) -} -fn vl_string_translate(a: &[Value]) -> R { - arity(a, 3, 3)?; - let (from, to, s) = (string(&a[0])?, string(&a[1])?, string(&a[2])?); - let to: Vec = to.chars().collect(); - let out: String = s.chars().map(|c| from.chars().position(|f| f == c).and_then(|i| to.get(i).or(to.last()).copied()).unwrap_or(c)).collect(); - Ok(Value::str(&out)) -} -fn vl_string_to_list(a: &[Value]) -> R { - arity(a, 1, 1)?; - Ok(Value::list(string(&a[0])?.chars().map(|c| Value::Int(c as i32)).collect::>())) -} -fn vl_list_to_string(a: &[Value]) -> R { - arity(a, 1, 1)?; - let mut s = String::new(); - for v in list_arg(&a[0])? { - s.extend(u32::try_from(int(&v)?).ok().and_then(char::from_u32)); - } - Ok(Value::str(&s)) -} -fn vl_filename_base(a: &[Value]) -> R { - arity(a, 1, 1)?; - let s = string(&a[0])?; - Ok(Value::str(std::path::Path::new(&*s.replace('\\', "/")).file_stem().and_then(OsStr::to_str).unwrap_or(""))) -} -fn vl_filename_extension(a: &[Value]) -> R { - arity(a, 1, 1)?; - let s = string(&a[0])?; - Ok(std::path::Path::new(&*s.replace('\\', "/")).extension().and_then(OsStr::to_str).map(|e| Value::str(&format!(".{e}"))).unwrap_or_default()) -} -fn vl_filename_directory(a: &[Value]) -> R { - arity(a, 1, 1)?; - let s = string(&a[0])?; - let cut = s.rfind(['/', '\\']).unwrap_or(0); - Ok(Value::str(&s[..cut])) -} - -/// `wcmatch`: `#` a digit, `@` a letter, `.` neither, `*` any run, `?` any character, `~` (first) -/// not, `[…]` a set (`[~…]` not in it, ranges `a-z`), `` ` `` the next character as is, `,` -/// between alternative patterns. Letters compare case-sensitively. -fn wcmatch(a: &[Value]) -> R { - arity(a, 2, 2)?; - let (s, pat) = (string(&a[0])?, string(&a[1])?); - let text: Vec = s.chars().collect(); - // Native wildcard matching runs as a single interpreter step, so its internal `*` backtracking - // must not explore exponentially many placements unchecked (CWE-400). Charge every glob step - // against a shared budget and fail the call if it is exhausted. - let mut budget: u64 = WCMATCH_BUDGET; - for p in split_patterns(&pat, &mut budget).map_err(|()| LispError::new("wcmatch: pattern too complex"))? { - match match_pattern(&text, &p, &mut budget) { - Ok(true) => return Ok(Value::bool(true)), - Ok(false) => {} - Err(()) => return Err(LispError::new("wcmatch: pattern too complex")), - } - } - Ok(Value::bool(false)) -} - -/// Shared step budget for one `wcmatch` call's wildcard matching. Large enough for the patterns -/// AutoLISP routines legitimately use (including the pathological-but-bounded cases the tests -/// cover), but finite so truly exponential `*`-heavy patterns are rejected rather than hanging. -const WCMATCH_BUDGET: u64 = 200_000_000; - -/// Spend one wildcard work unit, returning an error when the shared budget is exhausted. -fn charge_match(budget: &mut u64) -> Result<(), ()> { - *budget = budget.checked_sub(1).ok_or(())?; - Ok(()) -} - -/// Split comma-separated wildcard alternatives while preserving escapes and bracket sets. -/// Every consumed character spends from the same budget used for matching. -fn split_patterns(pat: &str, budget: &mut u64) -> Result>, ()> { - let mut out = vec![Vec::new()]; - let mut chars = pat.chars().peekable(); - let mut in_set = false; - while let Some(c) = chars.next() { - charge_match(budget)?; - match c { - '`' => { - if let Some(cur) = out.last_mut() { - cur.push('`'); - if let Some(escaped) = chars.next() { - charge_match(budget)?; - cur.push(escaped); - } - } - } - '[' => { - in_set = true; - if let Some(cur) = out.last_mut() { - cur.push(c); - } - } - ']' => { - in_set = false; - if let Some(cur) = out.last_mut() { - cur.push(c); - } - } - ',' if !in_set => out.push(Vec::new()), - c => { - if let Some(cur) = out.last_mut() { - cur.push(c); - } - } - } - } - Ok(out) -} - -/// Match one wildcard alternative, including leading `~` negation, within the shared budget. -fn match_pattern(text: &[char], pat: &[char], budget: &mut u64) -> Result { - if let Some(('~', rest)) = pat.split_first().map(|(c, r)| (*c, r)) - && !rest.is_empty() - { - return glob(text, rest, 0, budget).map(|m| !m); - } - glob(text, pat, 0, budget) -} - -/// Iterative-enough glob with `*` backtracking; `depth` bounds the recursion on `*`. Every step is -/// charged against `budget`; `Err(())` means the shared budget ran out (treated as too complex). -fn glob(text: &[char], pat: &[char], depth: usize, budget: &mut u64) -> Result { - glob_cached(text, pat, depth, budget, &mut std::collections::HashSet::new()) -} - -/// Match suffixes while memoizing failures; both matching work and retained failure entries are bounded. -fn glob_cached( - text: &[char], - pat: &[char], - depth: usize, - budget: &mut u64, - failed: &mut std::collections::HashSet<(usize, usize, usize)>, -) -> Result { - charge_match(budget)?; - let key = (text.len(), pat.len(), depth); - if failed.contains(&key) { - return Ok(false); - } - let matched = glob_inner(text, pat, depth, budget, failed)?; - // Avoid revisiting the same failed suffix exponentially. Bound the memo table independently - // of the step limit so a very large pattern cannot turn the optimization into an allocation bomb. - if !matched && failed.len() < 100_000 { - failed.insert(key); - } - Ok(matched) -} - -/// Evaluate wildcard tokens and backtracking branches under shared work and recursion limits. -fn glob_inner( - text: &[char], - pat: &[char], - depth: usize, - budget: &mut u64, - failed: &mut std::collections::HashSet<(usize, usize, usize)>, -) -> Result { - if *budget == 0 { - return Err(()); - } - *budget -= 1; - if depth > 256 { - return Err(()); - } - let (mut t, mut p) = (0usize, 0usize); - while p < pat.len() { - charge_match(budget)?; - match pat[p] { - '*' => { - let rest = &pat[p + 1..]; - for i in t..=text.len() { - if glob_cached(&text[i..], rest, depth + 1, budget, failed)? { - return Ok(true); - } - } - return Ok(false); - } - '`' => { - let Some(&lit) = pat.get(p + 1) else { return Ok(false) }; - if text.get(t) != Some(&lit) { - return Ok(false); - } - t += 1; - 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); - } - t += 1; - p = close + 1; - } - pc => { - let Some(&c) = text.get(t) else { return Ok(false) }; - let ok = match pc { - '#' => c.is_ascii_digit(), - '@' => c.is_alphabetic(), - '.' => !c.is_alphanumeric(), - '?' => true, - other => other == c, - }; - if !ok { - return Ok(false); - } - t += 1; - p += 1; - } - } - } - Ok(t == text.len()) -} - -// ---------------- geometry ---------------- - -fn polar(a: &[Value]) -> R { - arity(a, 3, 3)?; - let p = point(&a[0])?; - let (ang, d) = (num(&a[1])?, num(&a[2])?); - let out = [p[0] + d * ang.cos(), p[1] + d * ang.sin(), p[2]]; - Ok(if a[0].iter().count() == 2 { Value::point(&out[..2]) } else { Value::point(&out) }) -} -fn distance(a: &[Value]) -> R { - arity(a, 2, 2)?; - let (p, q) = (point(&a[0])?, point(&a[1])?); - // Two 2D points, or one: a 2D distance. - let dz = if a[0].iter().count() == 2 || a[1].iter().count() == 2 { 0.0 } else { q[2] - p[2] }; - Ok(Value::Real(((q[0] - p[0]).powi(2) + (q[1] - p[1]).powi(2) + dz * dz).sqrt())) -} -fn angle(a: &[Value]) -> R { - arity(a, 2, 2)?; - let (p, q) = (point(&a[0])?, point(&a[1])?); - Ok(Value::Real((q[1] - p[1]).atan2(q[0] - p[0]).rem_euclid(std::f64::consts::TAU))) -} -/// `(inters p1 p2 p3 p4 [onseg])`: where lines p1-p2 and p3-p4 cross; with `onseg` (the default) -/// only within both segments. -fn inters(a: &[Value]) -> R { - arity(a, 4, 5)?; - let (p1, p2, p3, p4) = (point(&a[0])?, point(&a[1])?, point(&a[2])?, point(&a[3])?); - let onseg = a.get(4).is_none_or(|v| !v.is_nil()); - let (d1x, d1y, d2x, d2y) = (p2[0] - p1[0], p2[1] - p1[1], p4[0] - p3[0], p4[1] - p3[1]); - let den = d1x * d2y - d1y * d2x; - if den.abs() < 1e-12 { - return Ok(Value::Nil); - } - let t = ((p3[0] - p1[0]) * d2y - (p3[1] - p1[1]) * d2x) / den; - let u = ((p3[0] - p1[0]) * d1y - (p3[1] - p1[1]) * d1x) / den; - let eps = 1e-10; - if onseg && (t < -eps || t > 1.0 + eps || u < -eps || u > 1.0 + eps) { - return Ok(Value::Nil); - } - let z = p1[2] + t * (p2[2] - p1[2]); - let x = [p1[0] + t * d1x, p1[1] + t * d1y, z]; - let two_d = a.iter().take(4).any(|p| p.iter().count() == 2); - Ok(if two_d { Value::point(&x[..2]) } else { Value::point(&x) }) -} - -// ---------------- output ---------------- - -/// Text printed to a file (an open file argument) or the command line. -fn emit(ctx: &mut Ctx<'_>, text: &str, file: Option<&Value>) -> Result<(), LispError> { - charge_bytes(text.len())?; - match file { - None | Some(Value::Nil) => { - ctx.env.print(text); - Ok(()) - } - Some(Value::File(id)) => match ctx.env.files.get_mut(id) { - Some(f) if f.writing => { - f.text.push_str(text); - Ok(()) - } - _ => Err(LispError::bad_arg("streamp", &Value::File(*id))), - }, - Some(other) => Err(LispError::bad_arg("streamp", other)), - } -} - -/// `(princ)` with no argument: a symbol that prints as nothing, ending a routine quietly. -pub fn null_symbol() -> Value { - Value::Sym(Sym(Arc::from(""))) -} -pub fn is_null_symbol(v: &Value) -> bool { - matches!(v, Value::Sym(s) if s.name().is_empty()) -} - -fn princ(ctx: &mut Ctx<'_>, a: Vec) -> Result { - arity(&a, 0, 2)?; - let Some(v) = a.first() else { return Ok(Flow::Value(null_symbol())) }; - emit(ctx, &to_text(v, false), a.get(1))?; - Ok(Flow::Value(v.clone())) -} -fn prin1(ctx: &mut Ctx<'_>, a: Vec) -> Result { - arity(&a, 0, 2)?; - let Some(v) = a.first() else { return Ok(Flow::Value(null_symbol())) }; - emit(ctx, &to_text(v, true), a.get(1))?; - Ok(Flow::Value(v.clone())) -} -fn print(ctx: &mut Ctx<'_>, a: Vec) -> Result { - arity(&a, 0, 2)?; - let Some(v) = a.first() else { return Ok(Flow::Value(null_symbol())) }; - emit(ctx, &format!("\n{} ", to_text(v, true)), a.get(1))?; - Ok(Flow::Value(v.clone())) -} -fn prompt(ctx: &mut Ctx<'_>, a: Vec) -> Result { - arity(&a, 1, 1)?; - let text = string(&a[0])?; - charge_bytes(text.len())?; - ctx.env.print(&text); - Ok(Flow::Value(Value::Nil)) -} -fn terpri(ctx: &mut Ctx<'_>, a: Vec) -> Result { - arity(&a, 0, 1)?; - emit(ctx, "\n", a.first())?; - Ok(Flow::Value(Value::Nil)) -} -fn alert(ctx: &mut Ctx<'_>, a: Vec) -> Result { - arity(&a, 1, 1)?; - // No dialog from LISP: the message goes to the command line. - let msg = string(&a[0])?; - charge_bytes(msg.len())?; - let out = std::mem::take(&mut ctx.env.out); - if !out.is_empty() { - ctx.s.echo(out); - } - for l in msg.lines() { - ctx.s.echo(l.to_string()); - } - Ok(Flow::Value(Value::Nil)) -} - -// ---------------- files ---------------- - -/// A file opened by `open`: read files are read whole; written ones are written at `close`. -pub struct LispFile { - pub path: String, - pub writing: bool, - pub append: bool, - pub lines: std::collections::VecDeque, - pub text: String, -} - -fn open(ctx: &mut Ctx<'_>, a: Vec) -> Result { - arity(&a, 2, 3)?; - let path = string(&a[0])?.to_string(); - let mode = string(&a[1])?.to_ascii_lowercase(); - let (writing, append) = match mode.as_str() { - "r" => (false, false), - "w" => (true, false), - "a" => (true, true), - _ => return Err(LispError::new(format!("bad argument value: file mode {mode}"))), - }; - let mut f = LispFile { path: path.clone(), writing, append, lines: Default::default(), text: String::new() }; - if !writing { - match read_text_file(&path) { - Some(t) => { - charge_bytes(t.len())?; - f.lines = t.lines().map(str::to_string).collect(); - } - None => return Ok(Flow::Value(Value::Nil)), - } - } else if !can_write(&path) { - return Ok(Flow::Value(Value::Nil)); - } - let id = ctx.env.next_file; - ctx.env.next_file = ctx.env.next_file.wrapping_add(1); - ctx.env.files.insert(id, f); - Ok(Flow::Value(Value::File(id))) -} - -fn close(ctx: &mut Ctx<'_>, a: Vec) -> Result { - arity(&a, 1, 1)?; - let Value::File(id) = a[0] else { return Err(LispError::bad_arg("streamp", &a[0])) }; - let Some(f) = ctx.env.files.remove(&id) else { return Err(LispError::bad_arg("streamp", &a[0])) }; - if f.writing { - write_text_file(&f.path, &f.text, f.append); - } - Ok(Flow::Value(Value::Nil)) -} - -fn read_line(ctx: &mut Ctx<'_>, a: Vec) -> Result { - arity(&a, 0, 1)?; - let Some(Value::File(id)) = a.first() else { return Ok(Flow::Value(Value::Nil)) }; - let line = ctx.env.files.get_mut(id).filter(|f| !f.writing).and_then(|f| f.lines.pop_front()); - Ok(Flow::Value(line.map(|l| Value::str(&l)).unwrap_or_default())) -} - -fn write_line(ctx: &mut Ctx<'_>, a: Vec) -> Result { - arity(&a, 1, 2)?; - let s = string(&a[0])?; - emit(ctx, &format!("{s}\n"), a.get(1))?; - Ok(Flow::Value(Value::Str(s))) -} - -fn findfile(a: &[Value]) -> R { - arity(a, 1, 1)?; - let p = string(&a[0])?; - Ok(if file_exists(&p) { Value::Str(p) } else { Value::Nil }) -} - -/// Read a regular text file up to 64 MiB, replacing invalid UTF-8 and returning `None` on read failure. -#[cfg(not(target_arch = "wasm32"))] -pub fn read_text_file(path: &str) -> Option { - // Read through a byte-limited, regular-file-only reader so a path naming an endless device - // cannot exhaust memory before the 64 MiB cap is enforced (CWE-400). - const MAX: usize = 64 * 1024 * 1024; - let bytes = crate::capped_read::read_capped(std::path::Path::new(path), MAX).ok()?; - Some(String::from_utf8_lossy(&bytes).into_owned()) -} -/// Return `None` on the web, where native filesystem paths are unavailable. -#[cfg(target_arch = "wasm32")] -pub fn read_text_file(_path: &str) -> Option { - None -} -#[cfg(not(target_arch = "wasm32"))] -fn can_write(path: &str) -> bool { - std::path::Path::new(path).parent().is_none_or(|d| d.as_os_str().is_empty() || d.exists()) -} -#[cfg(target_arch = "wasm32")] -fn can_write(_path: &str) -> bool { - false -} -#[cfg(not(target_arch = "wasm32"))] -fn write_text_file(path: &str, text: &str, append: bool) { - use std::io::Write; - let f = std::fs::OpenOptions::new().create(true).write(true).append(append).truncate(!append).open(path); - if let Ok(mut f) = f { - let _ = f.write_all(text.as_bytes()); - } -} -#[cfg(target_arch = "wasm32")] -fn write_text_file(_path: &str, _text: &str, _append: bool) {} -#[cfg(not(target_arch = "wasm32"))] -fn file_exists(path: &str) -> bool { - std::path::Path::new(path).is_file() -} -#[cfg(target_arch = "wasm32")] -fn file_exists(_path: &str) -> bool { - false -} - -// ---------------- functions of functions ---------------- - -/// A function argument: a function, or a symbol naming one (`'car`, `'1+`). -fn callable(ctx: &Ctx<'_>, f: &Value) -> Result { - match f { - Value::Sym(s) => { - let v = super::eval::lookup(ctx.env, s); - if matches!(v, Value::Subr(_) | Value::Func(_)) { Ok(v) } else { Err(LispError::new(format!("no function definition: {}", s.name()))) } - } - Value::Subr(_) | Value::Func(_) => Ok(f.clone()), - // A quoted lambda expression: '(lambda (x) …). - Value::Cons(c) if c.car.as_sym().is_some_and(|s| s.name() == "LAMBDA") => { - let def = c.cdr.clone(); - let arglist = def.car(); - if !arglist.is_list() { - return Err(LispError::bad_arg("listp", &arglist)); - } - let (mut params, mut locals, mut slash) = (Vec::new(), Vec::new(), false); - for p in arglist.iter() { - let Value::Sym(s) = p else { return Err(LispError::bad_arg("symbolp", &p)) }; - if s.name() == "/" { - slash = true; - } else if slash { - locals.push(s); - } else { - params.push(s); - } - } - Ok(Value::Func(Arc::new(super::value::Func { name: None, params, locals, body: def.cdr() }))) - } - other => Err(LispError::new(format!("bad function: {}", to_text(other, true)))), - } -} - -fn apply(ctx: &mut Ctx<'_>, a: Vec) -> Result { - arity(&a, 2, 2)?; - let f = callable(ctx, &a[0])?; - Ok(Flow::Call(f, list_arg(&a[1])?)) -} - -fn mapcar(ctx: &mut Ctx<'_>, a: Vec) -> Result { - arity(&a, 2, usize::MAX)?; - let f = callable(ctx, &a[0])?; - for l in &a[1..] { - list_arg(l)?; - } - Ok(Flow::Map(f, a[1..].to_vec())) -} - -fn eval_fn(_ctx: &mut Ctx<'_>, a: Vec) -> Result { - arity(&a, 1, 1)?; - Ok(Flow::Eval(a[0].clone())) -} - -fn set(ctx: &mut Ctx<'_>, a: Vec) -> Result { - arity(&a, 2, 2)?; - let Value::Sym(s) = &a[0] else { return Err(LispError::bad_arg("symbolp", &a[0])) }; - ctx.env.set(s, a[1].clone()); - Ok(Flow::Value(a[1].clone())) -} - -fn boundp(ctx: &mut Ctx<'_>, a: Vec) -> Result { - arity(&a, 1, 1)?; - let Value::Sym(s) = &a[0] else { return Err(LispError::bad_arg("symbolp", &a[0])) }; - Ok(Flow::Value(Value::bool(ctx.env.globals.contains_key(s)))) -} - -fn vl_symbol_value(ctx: &mut Ctx<'_>, a: Vec) -> Result { - arity(&a, 1, 1)?; - let Value::Sym(s) = &a[0] else { return Err(LispError::bad_arg("symbolp", &a[0])) }; - Ok(Flow::Value(super::eval::lookup(ctx.env, s))) -} - -fn vl_sort(ctx: &mut Ctx<'_>, a: Vec) -> Result { - arity(&a, 2, 2)?; - let f = callable(ctx, &a[1])?; - let mut items = list_arg(&a[0])?; - // A merge sort that calls back into LISP for each comparison. - let mut err = None; - merge_sort(&mut items, &mut |x, y| { - if err.is_some() { - return false; - } - match ctx.call_sync(&f, vec![x.clone(), y.clone()]) { - Ok(v) => !v.is_nil(), - Err(e) => { - err = Some(e); - false - } - } - }); - if let Some(e) = err { - return Err(e); - } - Ok(Flow::Value(Value::list(items))) -} - -/// A stable sort with a fallible-free comparison (`less`). -fn merge_sort(v: &mut Vec, less: &mut dyn FnMut(&Value, &Value) -> bool) { - if v.len() <= 1 { - return; - } - let mut right = v.split_off(v.len() / 2); - merge_sort(v, less); - merge_sort(&mut right, less); - let left = std::mem::take(v); - let (mut l, mut r) = (left.into_iter().peekable(), right.into_iter().peekable()); - while let (Some(x), Some(y)) = (l.peek(), r.peek()) { - if less(y, x) { - v.extend(r.next()); - } else { - v.extend(l.next()); - } - } - v.extend(l); - v.extend(r); -} - -fn filter(ctx: &mut Ctx<'_>, a: Vec, keep_true: bool) -> Result { - arity(&a, 2, 2)?; - let f = callable(ctx, &a[0])?; - let mut out = Vec::new(); - for v in list_arg(&a[1])? { - let r = ctx.call_sync(&f, vec![v.clone()])?; - if r.is_nil() != keep_true { - out.push(v); - } - } - Ok(Flow::Value(Value::list(out))) -} -fn vl_remove_if(ctx: &mut Ctx<'_>, a: Vec) -> Result { - filter(ctx, a, false) -} -fn vl_remove_if_not(ctx: &mut Ctx<'_>, a: Vec) -> Result { - filter(ctx, a, true) -} -fn vl_member_if(ctx: &mut Ctx<'_>, a: Vec) -> Result { - arity(&a, 2, 2)?; - let f = callable(ctx, &a[0])?; - let mut v = a[1].clone(); - while let Value::Cons(c) = v.clone() { - if !ctx.call_sync(&f, vec![c.car.clone()])?.is_nil() { - return Ok(Flow::Value(v)); - } - v = c.cdr.clone(); - } - Ok(Flow::Value(Value::Nil)) -} -fn every_some(ctx: &mut Ctx<'_>, a: Vec, every: bool) -> Result { - arity(&a, 2, usize::MAX)?; - let f = callable(ctx, &a[0])?; - let mut lists: Vec = a[1..].to_vec(); - loop { - if lists.iter().any(|l| !matches!(l, Value::Cons(_))) { - return Ok(Flow::Value(Value::bool(every))); - } - let r = ctx.call_sync(&f, lists.iter().map(Value::car).collect())?; - if every && r.is_nil() { - return Ok(Flow::Value(Value::Nil)); - } - if !every && !r.is_nil() { - return Ok(Flow::Value(r)); - } - lists = lists.iter().map(Value::cdr).collect(); - } -} -fn vl_every(ctx: &mut Ctx<'_>, a: Vec) -> Result { - every_some(ctx, a, true) -} -fn vl_some(ctx: &mut Ctx<'_>, a: Vec) -> Result { - every_some(ctx, a, false) -} - -/// The value `vl-catch-all-apply` returns for an error. -pub fn catch_all_error(msg: &str) -> Value { - Value::cons(Value::sym("*VL-CATCH-ALL-APPLY-ERROR*"), Value::str(msg)) -} -fn catch_all_message(v: &Value) -> Option { - (v.car().as_sym().is_some_and(|s| s.name() == "*VL-CATCH-ALL-APPLY-ERROR*")).then(|| v.cdr()) -} -fn vl_catch_all_apply(ctx: &mut Ctx<'_>, a: Vec) -> Result { - arity(&a, 2, 2)?; - let f = callable(ctx, &a[0])?; - Ok(Flow::Catch(f, list_arg(&a[1])?)) -} -fn vl_catch_all_error_p(a: &[Value]) -> R { - arity(a, 1, 1)?; - Ok(Value::bool(catch_all_message(&a[0]).is_some())) -} -fn vl_catch_all_error_message(a: &[Value]) -> R { - arity(a, 1, 1)?; - catch_all_message(&a[0]).ok_or_else(|| LispError::bad_arg("vl-catch-all-apply-error-p", &a[0])) -} - -fn exit(_ctx: &mut Ctx<'_>, _a: Vec) -> Result { - Err(LispError::quit("quit / exit abort")) -} - -/// `(load file [onfailure])`: evaluate a file's forms; its last value is the result. -fn load(ctx: &mut Ctx<'_>, a: Vec) -> Result { - arity(&a, 1, 2)?; - let name = string(&a[0])?.to_string(); - let text = super::load_source(ctx.s, &name); - match text { - Some(t) => Ok(Flow::Forms(read::read_all(&t)?)), - None => match a.get(1) { - Some(v) => Ok(Flow::Value(v.clone())), - None => Err(LispError::new(format!("LOAD failed: \"{name}\""))), - }, - } -} - -/// `(autoload file '("CMD" …))`: each command loads `file` the first time it is typed, then runs -/// the definition the file gave it. Startup files register commands this way without loading -/// every routine up front. -fn autoload(ctx: &mut Ctx<'_>, a: Vec) -> Result { - arity(&a, 2, 2)?; - let file = string(&a[0])?; - for cmd in list_arg(&a[1])? { - let name = string(&cmd)?.to_ascii_uppercase(); - if name.is_empty() || !name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-')) { - return Err(LispError::bad_arg("command name", &cmd)); - } - // The stub forgets itself, loads the file and calls what it defined. - let file_lit = to_text(&Value::Str(file.clone()), true); - let src = format!( - "(lambda () (setq C:{name} nil) (load {file_lit}) (if C:{name} (C:{name}) (princ \"\\n; error: {} did not define {name}\")) (princ))", - file.replace(['"', '\\'], "/") - ); - let stub = callable(ctx, &read::read_one(&src)?)?; - ctx.env.set(&Sym::new(&format!("C:{name}")), stub); - } - Ok(Flow::Value(Value::Nil)) -} - -fn noop(_a: &[Value]) -> R { - Ok(Value::Nil) -} -fn ver(_a: &[Value]) -> R { - Ok(Value::str("Visual LISP 2026 (en)")) -} - // ---------------- the table ---------------- macro_rules! pure { @@ -1369,6 +39,44 @@ macro_rules! special { }; } +mod args; +mod files; +mod functions; +mod geometry; +mod lists; +mod numbers; +mod output; +mod strings; +mod wcmatch; + +use args::R; +pub use args::args_err; +pub use files::{LispFile, read_text_file}; +use files::{close, findfile, open, read_line, write_line}; +pub use functions::catch_all_error; +use functions::{ + apply, autoload, boundp, eval_fn, exit, load, mapcar, noop, set, ver, vl_catch_all_apply, vl_catch_all_error_message, vl_catch_all_error_p, + vl_every, vl_member_if, vl_remove_if, vl_remove_if_not, vl_some, vl_sort, vl_symbol_value, +}; +use geometry::{angle, distance, inters, polar}; +use lists::{ + acad_strlsort, append, assoc, atom, cons, consp, cxr, eq_fn, equal_fn, last, length, list, listp, member, nth, null, reverse, subst, type_of, + vl_list_length, vl_position, vl_remove, +}; +use numbers::{ + abs, add, atan, cos, div, exp, expt, fix, float, gcd, ge, gt, le, log, logand, logior, lognot, lsh, lt, max, min, minusp, mul, num_eq, num_ne, + numberp, one_minus, one_plus, rem, sin, sqrt, sub, zerop, +}; +use output::{alert, prin1, princ, print, prompt, terpri}; +pub use output::{is_null_symbol, null_symbol, real}; +use strings::{ + ascii, atof, atoi, chr, itoa, read_fn, strcase, strcat, strlen, substr, vl_filename_base, vl_filename_directory, vl_filename_extension, + vl_list_to_string, vl_prin1_to_string, vl_princ_to_string, vl_string_left_trim, vl_string_right_trim, vl_string_search, vl_string_subst, + vl_string_to_list, vl_string_translate, vl_string_trim, vl_symbol_name, vl_symbolp, +}; +use wcmatch::wcmatch; +pub use wcmatch::wild_match; + static BUILTINS: &[Builtin] = &[ special!("QUOTE", Special::Quote), special!("FUNCTION", Special::Function), @@ -1581,34 +289,3 @@ static TEST_PANIC: Builtin = Builtin { name: "CADCRAFT-TEST-PANIC", kind: Builti fn test_panic(_a: &[Value]) -> R { panic!("test panic") } - -/// A real as `princ` shows it (for messages). -pub fn real(r: f64) -> String { - real_text(r) -} - -/// `wcmatch` for other callers (`ssget` filters). -pub fn wild_match(text: &str, pattern: &str) -> bool { - let t: Vec = text.chars().collect(); - let mut budget: u64 = WCMATCH_BUDGET; - split_patterns(pattern, &mut budget).is_ok_and(|patterns| patterns.iter().any(|p| match_pattern(&t, p, &mut budget) == Ok(true))) -} - -#[cfg(test)] -mod wildcard_budget_tests { - use super::*; - - /// Pattern splitting, literal matching, and bracket scans all stop when their shared fuel runs out. - #[test] - fn all_native_scans_consume_fuel() { - assert!(split_patterns("abcdefgh", &mut 4).is_err()); - assert!(split_patterns("`a`b`c", &mut 4).is_err()); - let text: Vec<_> = "abcdefgh".chars().collect(); - assert!(glob(&text, &text, 0, &mut 4).is_err()); - let pattern: Vec<_> = "[abcdefgh]".chars().collect(); - assert!(glob(&['!'], &pattern, 0, &mut 6).is_err()); - // Enough for finding the closing bracket, but not scanning the entire set again. - assert!(glob(&['!'], &pattern, 0, &mut 14).is_err()); - assert_eq!(glob(&['a'], &pattern, 0, &mut 100), Ok(true)); - } -} diff --git a/crates/engine/src/lisp/builtins/args.rs b/crates/engine/src/lisp/builtins/args.rs new file mode 100644 index 000000000..9ac9c3427 --- /dev/null +++ b/crates/engine/src/lisp/builtins/args.rs @@ -0,0 +1,48 @@ +use super::*; + +pub(super) type R = Result; + +/// `too few arguments` / `too many arguments`. +pub fn args_err(few: bool) -> LispError { + LispError::new(if few { "too few arguments" } else { "too many arguments" }) +} + +pub(super) fn arity(a: &[Value], min: usize, max: usize) -> Result<(), LispError> { + if a.len() < min { + return Err(args_err(true)); + } + if a.len() > max { + return Err(args_err(false)); + } + Ok(()) +} + +pub(super) fn num(v: &Value) -> Result { + v.as_number().ok_or_else(|| LispError::bad_arg("numberp", v)) +} + +pub(super) fn int(v: &Value) -> Result { + match v { + Value::Int(i) => Ok(*i), + other => Err(LispError::bad_arg("fixnump", other)), + } +} + +pub(super) fn string(v: &Value) -> Result, LispError> { + match v { + Value::Str(s) => Ok(s.clone()), + other => Err(LispError::bad_arg("stringp", other)), + } +} + +pub(super) fn list_arg(v: &Value) -> Result, LispError> { + if v.is_list() { Ok(v.to_vec()) } else { Err(LispError::bad_arg("listp", v)) } +} + +pub(super) fn point(v: &Value) -> Result<[f64; 3], LispError> { + v.as_point().ok_or_else(|| LispError::bad_arg("2D/3D point", v)) +} + +pub(super) fn real_or_int(r: f64, all_int: bool) -> Value { + if all_int { Value::Int(r as i32) } else { Value::Real(r) } +} diff --git a/crates/engine/src/lisp/builtins/files.rs b/crates/engine/src/lisp/builtins/files.rs new file mode 100644 index 000000000..6a1f90957 --- /dev/null +++ b/crates/engine/src/lisp/builtins/files.rs @@ -0,0 +1,120 @@ +use super::args::{R, arity, string}; +use super::output::emit; +use super::*; + +// ---------------- files ---------------- + +/// A file opened by `open`: read files are read whole; written ones are written at `close`. +pub struct LispFile { + pub path: String, + pub writing: bool, + pub append: bool, + pub lines: std::collections::VecDeque, + pub text: String, +} + +pub(super) fn open(ctx: &mut Ctx<'_>, a: Vec) -> Result { + arity(&a, 2, 3)?; + let path = string(&a[0])?.to_string(); + let mode = string(&a[1])?.to_ascii_lowercase(); + let (writing, append) = match mode.as_str() { + "r" => (false, false), + "w" => (true, false), + "a" => (true, true), + _ => return Err(LispError::new(format!("bad argument value: file mode {mode}"))), + }; + let mut f = LispFile { path: path.clone(), writing, append, lines: Default::default(), text: String::new() }; + if !writing { + match read_text_file(&path) { + Some(t) => { + charge_bytes(t.len())?; + f.lines = t.lines().map(str::to_string).collect(); + } + None => return Ok(Flow::Value(Value::Nil)), + } + } else if !can_write(&path) { + return Ok(Flow::Value(Value::Nil)); + } + let id = ctx.env.next_file; + ctx.env.next_file = ctx.env.next_file.wrapping_add(1); + ctx.env.files.insert(id, f); + Ok(Flow::Value(Value::File(id))) +} + +pub(super) fn close(ctx: &mut Ctx<'_>, a: Vec) -> Result { + arity(&a, 1, 1)?; + let Value::File(id) = a[0] else { return Err(LispError::bad_arg("streamp", &a[0])) }; + let Some(f) = ctx.env.files.remove(&id) else { return Err(LispError::bad_arg("streamp", &a[0])) }; + if f.writing { + write_text_file(&f.path, &f.text, f.append); + } + Ok(Flow::Value(Value::Nil)) +} + +pub(super) fn read_line(ctx: &mut Ctx<'_>, a: Vec) -> Result { + arity(&a, 0, 1)?; + let Some(Value::File(id)) = a.first() else { return Ok(Flow::Value(Value::Nil)) }; + let line = ctx.env.files.get_mut(id).filter(|f| !f.writing).and_then(|f| f.lines.pop_front()); + Ok(Flow::Value(line.map(|l| Value::str(&l)).unwrap_or_default())) +} + +pub(super) fn write_line(ctx: &mut Ctx<'_>, a: Vec) -> Result { + arity(&a, 1, 2)?; + let s = string(&a[0])?; + emit(ctx, &format!("{s}\n"), a.get(1))?; + Ok(Flow::Value(Value::Str(s))) +} + +pub(super) fn findfile(a: &[Value]) -> R { + arity(a, 1, 1)?; + let p = string(&a[0])?; + Ok(if file_exists(&p) { Value::Str(p) } else { Value::Nil }) +} + +/// Read a regular text file up to 64 MiB, replacing invalid UTF-8 and returning `None` on read failure. +#[cfg(not(target_arch = "wasm32"))] +pub fn read_text_file(path: &str) -> Option { + // Read through a byte-limited, regular-file-only reader so a path naming an endless device + // cannot exhaust memory before the 64 MiB cap is enforced (CWE-400). + const MAX: usize = 64 * 1024 * 1024; + let bytes = crate::capped_read::read_capped(std::path::Path::new(path), MAX).ok()?; + Some(String::from_utf8_lossy(&bytes).into_owned()) +} + +/// Return `None` on the web, where native filesystem paths are unavailable. +#[cfg(target_arch = "wasm32")] +pub fn read_text_file(_path: &str) -> Option { + None +} + +#[cfg(not(target_arch = "wasm32"))] +fn can_write(path: &str) -> bool { + std::path::Path::new(path).parent().is_none_or(|d| d.as_os_str().is_empty() || d.exists()) +} + +#[cfg(target_arch = "wasm32")] +fn can_write(_path: &str) -> bool { + false +} + +#[cfg(not(target_arch = "wasm32"))] +fn write_text_file(path: &str, text: &str, append: bool) { + use std::io::Write; + let f = std::fs::OpenOptions::new().create(true).write(true).append(append).truncate(!append).open(path); + if let Ok(mut f) = f { + let _ = f.write_all(text.as_bytes()); + } +} + +#[cfg(target_arch = "wasm32")] +fn write_text_file(_path: &str, _text: &str, _append: bool) {} + +#[cfg(not(target_arch = "wasm32"))] +fn file_exists(path: &str) -> bool { + std::path::Path::new(path).is_file() +} + +#[cfg(target_arch = "wasm32")] +fn file_exists(_path: &str) -> bool { + false +} diff --git a/crates/engine/src/lisp/builtins/functions.rs b/crates/engine/src/lisp/builtins/functions.rs new file mode 100644 index 000000000..2f53d1651 --- /dev/null +++ b/crates/engine/src/lisp/builtins/functions.rs @@ -0,0 +1,255 @@ +use super::args::{R, arity, list_arg, string}; +use super::*; + +// ---------------- functions of functions ---------------- + +/// A function argument: a function, or a symbol naming one (`'car`, `'1+`). +fn callable(ctx: &Ctx<'_>, f: &Value) -> Result { + match f { + Value::Sym(s) => { + let v = crate::lisp::eval::lookup(ctx.env, s); + if matches!(v, Value::Subr(_) | Value::Func(_)) { Ok(v) } else { Err(LispError::new(format!("no function definition: {}", s.name()))) } + } + Value::Subr(_) | Value::Func(_) => Ok(f.clone()), + // A quoted lambda expression: '(lambda (x) …). + Value::Cons(c) if c.car.as_sym().is_some_and(|s| s.name() == "LAMBDA") => { + let def = c.cdr.clone(); + let arglist = def.car(); + if !arglist.is_list() { + return Err(LispError::bad_arg("listp", &arglist)); + } + let (mut params, mut locals, mut slash) = (Vec::new(), Vec::new(), false); + for p in arglist.iter() { + let Value::Sym(s) = p else { return Err(LispError::bad_arg("symbolp", &p)) }; + if s.name() == "/" { + slash = true; + } else if slash { + locals.push(s); + } else { + params.push(s); + } + } + Ok(Value::Func(Arc::new(crate::lisp::value::Func { name: None, params, locals, body: def.cdr() }))) + } + other => Err(LispError::new(format!("bad function: {}", to_text(other, true)))), + } +} + +pub(super) fn apply(ctx: &mut Ctx<'_>, a: Vec) -> Result { + arity(&a, 2, 2)?; + let f = callable(ctx, &a[0])?; + Ok(Flow::Call(f, list_arg(&a[1])?)) +} + +pub(super) fn mapcar(ctx: &mut Ctx<'_>, a: Vec) -> Result { + arity(&a, 2, usize::MAX)?; + let f = callable(ctx, &a[0])?; + for l in &a[1..] { + list_arg(l)?; + } + Ok(Flow::Map(f, a[1..].to_vec())) +} + +pub(super) fn eval_fn(_ctx: &mut Ctx<'_>, a: Vec) -> Result { + arity(&a, 1, 1)?; + Ok(Flow::Eval(a[0].clone())) +} + +pub(super) fn set(ctx: &mut Ctx<'_>, a: Vec) -> Result { + arity(&a, 2, 2)?; + let Value::Sym(s) = &a[0] else { return Err(LispError::bad_arg("symbolp", &a[0])) }; + ctx.env.set(s, a[1].clone()); + Ok(Flow::Value(a[1].clone())) +} + +pub(super) fn boundp(ctx: &mut Ctx<'_>, a: Vec) -> Result { + arity(&a, 1, 1)?; + let Value::Sym(s) = &a[0] else { return Err(LispError::bad_arg("symbolp", &a[0])) }; + Ok(Flow::Value(Value::bool(ctx.env.globals.contains_key(s)))) +} + +pub(super) fn vl_symbol_value(ctx: &mut Ctx<'_>, a: Vec) -> Result { + arity(&a, 1, 1)?; + let Value::Sym(s) = &a[0] else { return Err(LispError::bad_arg("symbolp", &a[0])) }; + Ok(Flow::Value(crate::lisp::eval::lookup(ctx.env, s))) +} + +pub(super) fn vl_sort(ctx: &mut Ctx<'_>, a: Vec) -> Result { + arity(&a, 2, 2)?; + let f = callable(ctx, &a[1])?; + let mut items = list_arg(&a[0])?; + // A merge sort that calls back into LISP for each comparison. + let mut err = None; + merge_sort(&mut items, &mut |x, y| { + if err.is_some() { + return false; + } + match ctx.call_sync(&f, vec![x.clone(), y.clone()]) { + Ok(v) => !v.is_nil(), + Err(e) => { + err = Some(e); + false + } + } + }); + if let Some(e) = err { + return Err(e); + } + Ok(Flow::Value(Value::list(items))) +} + +/// A stable sort with a fallible-free comparison (`less`). +fn merge_sort(v: &mut Vec, less: &mut dyn FnMut(&Value, &Value) -> bool) { + if v.len() <= 1 { + return; + } + let mut right = v.split_off(v.len() / 2); + merge_sort(v, less); + merge_sort(&mut right, less); + let left = std::mem::take(v); + let (mut l, mut r) = (left.into_iter().peekable(), right.into_iter().peekable()); + while let (Some(x), Some(y)) = (l.peek(), r.peek()) { + if less(y, x) { + v.extend(r.next()); + } else { + v.extend(l.next()); + } + } + v.extend(l); + v.extend(r); +} + +fn filter(ctx: &mut Ctx<'_>, a: Vec, keep_true: bool) -> Result { + arity(&a, 2, 2)?; + let f = callable(ctx, &a[0])?; + let mut out = Vec::new(); + for v in list_arg(&a[1])? { + let r = ctx.call_sync(&f, vec![v.clone()])?; + if r.is_nil() != keep_true { + out.push(v); + } + } + Ok(Flow::Value(Value::list(out))) +} + +pub(super) fn vl_remove_if(ctx: &mut Ctx<'_>, a: Vec) -> Result { + filter(ctx, a, false) +} + +pub(super) fn vl_remove_if_not(ctx: &mut Ctx<'_>, a: Vec) -> Result { + filter(ctx, a, true) +} + +pub(super) fn vl_member_if(ctx: &mut Ctx<'_>, a: Vec) -> Result { + arity(&a, 2, 2)?; + let f = callable(ctx, &a[0])?; + let mut v = a[1].clone(); + while let Value::Cons(c) = v.clone() { + if !ctx.call_sync(&f, vec![c.car.clone()])?.is_nil() { + return Ok(Flow::Value(v)); + } + v = c.cdr.clone(); + } + Ok(Flow::Value(Value::Nil)) +} + +fn every_some(ctx: &mut Ctx<'_>, a: Vec, every: bool) -> Result { + arity(&a, 2, usize::MAX)?; + let f = callable(ctx, &a[0])?; + let mut lists: Vec = a[1..].to_vec(); + loop { + if lists.iter().any(|l| !matches!(l, Value::Cons(_))) { + return Ok(Flow::Value(Value::bool(every))); + } + let r = ctx.call_sync(&f, lists.iter().map(Value::car).collect())?; + if every && r.is_nil() { + return Ok(Flow::Value(Value::Nil)); + } + if !every && !r.is_nil() { + return Ok(Flow::Value(r)); + } + lists = lists.iter().map(Value::cdr).collect(); + } +} + +pub(super) fn vl_every(ctx: &mut Ctx<'_>, a: Vec) -> Result { + every_some(ctx, a, true) +} + +pub(super) fn vl_some(ctx: &mut Ctx<'_>, a: Vec) -> Result { + every_some(ctx, a, false) +} + +/// The value `vl-catch-all-apply` returns for an error. +pub fn catch_all_error(msg: &str) -> Value { + Value::cons(Value::sym("*VL-CATCH-ALL-APPLY-ERROR*"), Value::str(msg)) +} + +fn catch_all_message(v: &Value) -> Option { + (v.car().as_sym().is_some_and(|s| s.name() == "*VL-CATCH-ALL-APPLY-ERROR*")).then(|| v.cdr()) +} + +pub(super) fn vl_catch_all_apply(ctx: &mut Ctx<'_>, a: Vec) -> Result { + arity(&a, 2, 2)?; + let f = callable(ctx, &a[0])?; + Ok(Flow::Catch(f, list_arg(&a[1])?)) +} + +pub(super) fn vl_catch_all_error_p(a: &[Value]) -> R { + arity(a, 1, 1)?; + Ok(Value::bool(catch_all_message(&a[0]).is_some())) +} + +pub(super) fn vl_catch_all_error_message(a: &[Value]) -> R { + arity(a, 1, 1)?; + catch_all_message(&a[0]).ok_or_else(|| LispError::bad_arg("vl-catch-all-apply-error-p", &a[0])) +} + +pub(super) fn exit(_ctx: &mut Ctx<'_>, _a: Vec) -> Result { + Err(LispError::quit("quit / exit abort")) +} + +/// `(load file [onfailure])`: evaluate a file's forms; its last value is the result. +pub(super) fn load(ctx: &mut Ctx<'_>, a: Vec) -> Result { + arity(&a, 1, 2)?; + let name = string(&a[0])?.to_string(); + let text = crate::lisp::load_source(ctx.s, &name); + match text { + Some(t) => Ok(Flow::Forms(read::read_all(&t)?)), + None => match a.get(1) { + Some(v) => Ok(Flow::Value(v.clone())), + None => Err(LispError::new(format!("LOAD failed: \"{name}\""))), + }, + } +} + +/// `(autoload file '("CMD" …))`: each command loads `file` the first time it is typed, then runs +/// the definition the file gave it. Startup files register commands this way without loading +/// every routine up front. +pub(super) fn autoload(ctx: &mut Ctx<'_>, a: Vec) -> Result { + arity(&a, 2, 2)?; + let file = string(&a[0])?; + for cmd in list_arg(&a[1])? { + let name = string(&cmd)?.to_ascii_uppercase(); + if name.is_empty() || !name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-')) { + return Err(LispError::bad_arg("command name", &cmd)); + } + // The stub forgets itself, loads the file and calls what it defined. + let file_lit = to_text(&Value::Str(file.clone()), true); + let src = format!( + "(lambda () (setq C:{name} nil) (load {file_lit}) (if C:{name} (C:{name}) (princ \"\\n; error: {} did not define {name}\")) (princ))", + file.replace(['"', '\\'], "/") + ); + let stub = callable(ctx, &read::read_one(&src)?)?; + ctx.env.set(&Sym::new(&format!("C:{name}")), stub); + } + Ok(Flow::Value(Value::Nil)) +} + +pub(super) fn noop(_a: &[Value]) -> R { + Ok(Value::Nil) +} + +pub(super) fn ver(_a: &[Value]) -> R { + Ok(Value::str("Visual LISP 2026 (en)")) +} diff --git a/crates/engine/src/lisp/builtins/geometry.rs b/crates/engine/src/lisp/builtins/geometry.rs new file mode 100644 index 000000000..7de673666 --- /dev/null +++ b/crates/engine/src/lisp/builtins/geometry.rs @@ -0,0 +1,49 @@ +use super::args::{R, arity, num, point}; +use super::*; + +// ---------------- geometry ---------------- + +pub(super) fn polar(a: &[Value]) -> R { + arity(a, 3, 3)?; + let p = point(&a[0])?; + let (ang, d) = (num(&a[1])?, num(&a[2])?); + let out = [p[0] + d * ang.cos(), p[1] + d * ang.sin(), p[2]]; + Ok(if a[0].iter().count() == 2 { Value::point(&out[..2]) } else { Value::point(&out) }) +} + +pub(super) fn distance(a: &[Value]) -> R { + arity(a, 2, 2)?; + let (p, q) = (point(&a[0])?, point(&a[1])?); + // Two 2D points, or one: a 2D distance. + let dz = if a[0].iter().count() == 2 || a[1].iter().count() == 2 { 0.0 } else { q[2] - p[2] }; + Ok(Value::Real(((q[0] - p[0]).powi(2) + (q[1] - p[1]).powi(2) + dz * dz).sqrt())) +} + +pub(super) fn angle(a: &[Value]) -> R { + arity(a, 2, 2)?; + let (p, q) = (point(&a[0])?, point(&a[1])?); + Ok(Value::Real((q[1] - p[1]).atan2(q[0] - p[0]).rem_euclid(std::f64::consts::TAU))) +} + +/// `(inters p1 p2 p3 p4 [onseg])`: where lines p1-p2 and p3-p4 cross; with `onseg` (the default) +/// only within both segments. +pub(super) fn inters(a: &[Value]) -> R { + arity(a, 4, 5)?; + let (p1, p2, p3, p4) = (point(&a[0])?, point(&a[1])?, point(&a[2])?, point(&a[3])?); + let onseg = a.get(4).is_none_or(|v| !v.is_nil()); + let (d1x, d1y, d2x, d2y) = (p2[0] - p1[0], p2[1] - p1[1], p4[0] - p3[0], p4[1] - p3[1]); + let den = d1x * d2y - d1y * d2x; + if den.abs() < 1e-12 { + return Ok(Value::Nil); + } + let t = ((p3[0] - p1[0]) * d2y - (p3[1] - p1[1]) * d2x) / den; + let u = ((p3[0] - p1[0]) * d1y - (p3[1] - p1[1]) * d1x) / den; + let eps = 1e-10; + if onseg && (t < -eps || t > 1.0 + eps || u < -eps || u > 1.0 + eps) { + return Ok(Value::Nil); + } + let z = p1[2] + t * (p2[2] - p1[2]); + let x = [p1[0] + t * d1x, p1[1] + t * d1y, z]; + let two_d = a.iter().take(4).any(|p| p.iter().count() == 2); + Ok(if two_d { Value::point(&x[..2]) } else { Value::point(&x) }) +} diff --git a/crates/engine/src/lisp/builtins/lists.rs b/crates/engine/src/lisp/builtins/lists.rs new file mode 100644 index 000000000..ea5a4fb2c --- /dev/null +++ b/crates/engine/src/lisp/builtins/lists.rs @@ -0,0 +1,145 @@ +use super::args::{R, arity, int, list_arg, num, string}; +use super::*; + +// ---------------- predicates and lists ---------------- + +pub(super) fn atom(a: &[Value]) -> R { + arity(a, 1, 1)?; + Ok(Value::bool(!matches!(a[0], Value::Cons(_)))) +} + +pub(super) fn listp(a: &[Value]) -> R { + arity(a, 1, 1)?; + Ok(Value::bool(matches!(a[0], Value::Cons(_) | Value::Nil))) +} + +pub(super) fn consp(a: &[Value]) -> R { + arity(a, 1, 1)?; + Ok(Value::bool(matches!(a[0], Value::Cons(_)))) +} + +pub(super) fn null(a: &[Value]) -> R { + arity(a, 1, 1)?; + Ok(Value::bool(a[0].is_nil())) +} + +pub(super) fn type_of(a: &[Value]) -> R { + arity(a, 1, 1)?; + Ok(a[0].type_name().map(Value::sym).unwrap_or_default()) +} + +pub(super) fn eq_fn(a: &[Value]) -> R { + arity(a, 2, 2)?; + Ok(Value::bool(eq(&a[0], &a[1]))) +} + +pub(super) fn equal_fn(a: &[Value]) -> R { + arity(a, 2, 3)?; + let fuzz = a.get(2).map(num).transpose()?.unwrap_or(0.0); + Ok(Value::bool(equal(&a[0], &a[1], fuzz))) +} + +pub(super) fn cxr(a: &[Value], path: &str) -> R { + arity(a, 1, 1)?; + let mut v = a[0].clone(); + // The path is read right to left: cadr = car of cdr. + for c in path.chars().rev() { + if !matches!(v, Value::Cons(_) | Value::Nil) { + return Err(LispError::bad_arg("consp", &v)); + } + v = if c == 'a' { v.car() } else { v.cdr() }; + } + Ok(v) +} + +pub(super) fn cons(a: &[Value]) -> R { + arity(a, 2, 2)?; + Ok(Value::cons(a[0].clone(), a[1].clone())) +} + +pub(super) fn list(a: &[Value]) -> R { + Ok(Value::list(a.to_vec())) +} + +pub(super) fn append(a: &[Value]) -> R { + let mut items = Vec::new(); + for v in a { + items.extend(list_arg(v)?); + } + Ok(Value::list(items)) +} + +pub(super) fn reverse(a: &[Value]) -> R { + arity(a, 1, 1)?; + let mut v = list_arg(&a[0])?; + v.reverse(); + Ok(Value::list(v)) +} + +pub(super) fn length(a: &[Value]) -> R { + arity(a, 1, 1)?; + Ok(Value::Int(i32::try_from(list_arg(&a[0])?.len()).unwrap_or(i32::MAX))) +} + +pub(super) fn nth(a: &[Value]) -> R { + arity(a, 2, 2)?; + let n = int(&a[0])?; + if n < 0 { + return Ok(Value::Nil); + } + Ok(a[1].iter().nth(n as usize).unwrap_or_default()) +} + +pub(super) fn last(a: &[Value]) -> R { + arity(a, 1, 1)?; + Ok(list_arg(&a[0])?.pop().unwrap_or_default()) +} + +pub(super) fn member(a: &[Value]) -> R { + arity(a, 2, 2)?; + let mut v = a[1].clone(); + while let Value::Cons(c) = &v { + if equal(&c.car, &a[0], 0.0) { + return Ok(v); + } + let next = c.cdr.clone(); + v = next; + } + Ok(Value::Nil) +} + +pub(super) fn assoc(a: &[Value]) -> R { + arity(a, 2, 2)?; + Ok(a[1].iter().find(|item| matches!(item, Value::Cons(_)) && equal(&item.car(), &a[0], 0.0)).unwrap_or_default()) +} + +pub(super) fn subst(a: &[Value]) -> R { + arity(a, 3, 3)?; + let (new, old) = (&a[0], &a[1]); + if !a[2].is_list() { + return Ok(if equal(&a[2], old, 0.0) { new.clone() } else { a[2].clone() }); + } + Ok(Value::list(a[2].iter().map(|v| if equal(&v, old, 0.0) { new.clone() } else { v }).collect::>())) +} + +pub(super) fn vl_position(a: &[Value]) -> R { + arity(a, 2, 2)?; + Ok(a[1].iter().position(|v| equal(&v, &a[0], 0.0)).map(|i| Value::Int(i32::try_from(i).unwrap_or(i32::MAX))).unwrap_or_default()) +} + +pub(super) fn vl_remove(a: &[Value]) -> R { + arity(a, 2, 2)?; + Ok(Value::list(list_arg(&a[1])?.into_iter().filter(|v| !equal(v, &a[0], 0.0)).collect::>())) +} + +pub(super) fn vl_list_length(a: &[Value]) -> R { + arity(a, 1, 1)?; + Ok(if a[0].is_list() { Value::Int(i32::try_from(a[0].to_vec().len()).unwrap_or(i32::MAX)) } else { Value::Nil }) +} + +pub(super) fn acad_strlsort(a: &[Value]) -> R { + arity(a, 1, 1)?; + let mut v = list_arg(&a[0])?.iter().map(string).collect::, _>>()?; + v.sort_by_key(|s| s.to_lowercase()); + Ok(Value::list(v.into_iter().map(Value::Str).collect::>())) +} diff --git a/crates/engine/src/lisp/builtins/numbers.rs b/crates/engine/src/lisp/builtins/numbers.rs new file mode 100644 index 000000000..e1baa86f8 --- /dev/null +++ b/crates/engine/src/lisp/builtins/numbers.rs @@ -0,0 +1,328 @@ +use super::args::{R, arity, int, num, real_or_int}; +use super::*; + +// ---------------- numbers ---------------- + +/// Integer arithmetic wraps at 32 bits, as AutoLISP's does; any real makes the result real. +fn arith(a: &[Value], op: char) -> R { + let all_int = a.iter().all(|v| matches!(v, Value::Int(_))); + let mut it = a.iter(); + let Some(first) = it.next() else { + return Ok(Value::Int(if op == '*' { 1 } else { 0 })); + }; + if all_int { + let mut acc = int(first)?; + if a.len() == 1 { + return Ok(Value::Int(match op { + '-' => acc.wrapping_neg(), + _ => acc, + })); + } + for v in it { + let x = int(v)?; + acc = match op { + '+' => acc.wrapping_add(x), + '-' => acc.wrapping_sub(x), + '*' => acc.wrapping_mul(x), + _ => { + if x == 0 { + return Err(LispError::new("divide by zero")); + } + acc.wrapping_div(x) + } + }; + } + return Ok(Value::Int(acc)); + } + let mut acc = num(first)?; + if a.len() == 1 { + return Ok(Value::Real(if op == '-' { -acc } else { acc })); + } + for v in it { + let x = num(v)?; + acc = match op { + '+' => acc + x, + '-' => acc - x, + '*' => acc * x, + _ => { + if x == 0.0 { + return Err(LispError::new("divide by zero")); + } + acc / x + } + }; + } + Ok(Value::Real(acc)) +} + +pub(super) fn add(a: &[Value]) -> R { + arith(a, '+') +} + +pub(super) fn sub(a: &[Value]) -> R { + arith(a, '-') +} + +pub(super) fn mul(a: &[Value]) -> R { + arith(a, '*') +} + +pub(super) fn div(a: &[Value]) -> R { + arity(a, 1, usize::MAX)?; + arith(a, '/') +} + +pub(super) fn rem(a: &[Value]) -> R { + arity(a, 1, usize::MAX)?; + if a.iter().all(|v| matches!(v, Value::Int(_))) { + let mut it = a.iter(); + let mut acc = int(it.next().unwrap_or(&Value::Int(0)))?; + for v in it { + let x = int(v)?; + if x == 0 { + return Err(LispError::new("divide by zero")); + } + acc = acc.wrapping_rem(x); + } + return Ok(Value::Int(acc)); + } + let mut it = a.iter(); + let mut acc = num(it.next().unwrap_or(&Value::Int(0)))?; + for v in it { + let x = num(v)?; + if x == 0.0 { + return Err(LispError::new("divide by zero")); + } + acc %= x; + } + Ok(Value::Real(acc)) +} + +pub(super) fn one_plus(a: &[Value]) -> R { + arity(a, 1, 1)?; + arith(&[a[0].clone(), Value::Int(1)], '+') +} + +pub(super) fn one_minus(a: &[Value]) -> R { + arity(a, 1, 1)?; + arith(&[a[0].clone(), Value::Int(1)], '-') +} + +pub(super) fn abs(a: &[Value]) -> R { + arity(a, 1, 1)?; + Ok(match &a[0] { + Value::Int(i) => Value::Int(i.wrapping_abs()), + v => Value::Real(num(v)?.abs()), + }) +} + +fn min_max(a: &[Value], max: bool) -> R { + arity(a, 1, usize::MAX)?; + let all_int = a.iter().all(|v| matches!(v, Value::Int(_))); + let mut best = num(&a[0])?; + for v in &a[1..] { + let x = num(v)?; + if (max && x > best) || (!max && x < best) { + best = x; + } + } + Ok(real_or_int(best, all_int)) +} + +pub(super) fn min(a: &[Value]) -> R { + min_max(a, false) +} + +pub(super) fn max(a: &[Value]) -> R { + min_max(a, true) +} + +fn unary(a: &[Value], f: fn(f64) -> f64) -> R { + arity(a, 1, 1)?; + let r = f(num(&a[0])?); + if r.is_finite() { Ok(Value::Real(r)) } else { Err(LispError::new("function undefined for argument")) } +} + +pub(super) fn sqrt(a: &[Value]) -> R { + if num(a.first().unwrap_or(&Value::Nil))? < 0.0 { + return Err(LispError::new("function undefined for argument")); + } + unary(a, f64::sqrt) +} + +pub(super) fn exp(a: &[Value]) -> R { + unary(a, f64::exp) +} + +pub(super) fn log(a: &[Value]) -> R { + if num(a.first().unwrap_or(&Value::Nil))? <= 0.0 { + return Err(LispError::new("function undefined for argument")); + } + unary(a, f64::ln) +} + +pub(super) fn sin(a: &[Value]) -> R { + unary(a, f64::sin) +} + +pub(super) fn cos(a: &[Value]) -> R { + unary(a, f64::cos) +} + +pub(super) fn atan(a: &[Value]) -> R { + arity(a, 1, 2)?; + let y = num(&a[0])?; + Ok(Value::Real(match a.get(1) { + Some(x) => y.atan2(num(x)?), + None => y.atan(), + })) +} + +pub(super) fn expt(a: &[Value]) -> R { + arity(a, 2, 2)?; + match (&a[0], &a[1]) { + (Value::Int(b), Value::Int(e)) if *e >= 0 => Ok(Value::Int(b.wrapping_pow(u32::try_from(*e).unwrap_or(0)))), + (b, e) => { + let r = num(b)?.powf(num(e)?); + if r.is_finite() { Ok(Value::Real(r)) } else { Err(LispError::new("function undefined for argument")) } + } + } +} + +pub(super) fn fix(a: &[Value]) -> R { + arity(a, 1, 1)?; + Ok(match &a[0] { + Value::Int(i) => Value::Int(*i), + v => { + let r = num(v)?.trunc(); + if r.is_finite() && r >= f64::from(i32::MIN) && r <= f64::from(i32::MAX) { Value::Int(r as i32) } else { Value::Real(r) } + } + }) +} + +pub(super) fn float(a: &[Value]) -> R { + arity(a, 1, 1)?; + Ok(Value::Real(num(&a[0])?)) +} + +pub(super) fn gcd(a: &[Value]) -> R { + arity(a, 2, 2)?; + let (mut x, mut y) = (i64::from(int(&a[0])?).abs(), i64::from(int(&a[1])?).abs()); + if x == 0 && y == 0 { + return Err(LispError::new("bad argument value: 0")); + } + while y != 0 { + let t = x % y; + x = y; + y = t; + } + Ok(Value::Int(i32::try_from(x).unwrap_or(i32::MAX))) +} + +pub(super) fn logand(a: &[Value]) -> R { + a.iter().try_fold(-1i32, |acc, v| Ok(acc & int(v)?)).map(|r| Value::Int(if a.is_empty() { 0 } else { r })) +} + +pub(super) fn logior(a: &[Value]) -> R { + a.iter().try_fold(0i32, |acc, v| Ok(acc | int(v)?)).map(Value::Int) +} + +pub(super) fn lsh(a: &[Value]) -> R { + arity(a, 0, 2)?; + let x = a.first().map(int).transpose()?.unwrap_or(0); + let n = a.get(1).map(int).transpose()?.unwrap_or(0); + let r = if n >= 32 || n <= -32 { + 0 + } else if n >= 0 { + x.wrapping_shl(n as u32) + } else { + ((x as u32) >> ((-n) as u32)) as i32 + }; + Ok(Value::Int(r)) +} + +pub(super) fn lognot(a: &[Value]) -> R { + arity(a, 1, 1)?; + Ok(Value::Int(!int(&a[0])?)) +} + +/// Numbers compare by value; strings by text. +fn compare(a: &Value, b: &Value) -> Result { + match (a, b) { + (Value::Str(x), Value::Str(y)) => Ok(x.cmp(y)), + _ => num(a)?.partial_cmp(&num(b)?).ok_or_else(|| LispError::new("bad argument value")), + } +} + +fn chain(a: &[Value], ok: fn(std::cmp::Ordering) -> bool) -> R { + arity(a, 1, usize::MAX)?; + for w in a.windows(2) { + if let [x, y] = w + && !ok(compare(x, y)?) + { + return Ok(Value::Nil); + } + } + Ok(Value::t()) +} + +/// `=` and `/=`: numbers and strings compare as [`compare`] does, and any other atom compares by +/// what it is, as `eq` does. AutoLISP answers `(= (type x) 'LIST)` -- the idiom for a type test, +/// since `type` returns a symbol -- where coercing that symbol to a number failed the whole +/// routine with "bad argument type" instead (#1597). A mismatch is `nil`, not an error, so +/// `(= 1 "1")` and `(= '(1) '(1))` answer too. The ordering comparisons stay numbers and strings +/// only, as AutoLISP has them. +fn eq_chain(a: &[Value], want_equal: bool) -> R { + arity(a, 1, usize::MAX)?; + for w in a.windows(2) { + let [x, y] = w else { continue }; + let same = match (x, y) { + (Value::Str(_), Value::Str(_)) => compare(x, y)?.is_eq(), + _ if x.as_number().is_some() && y.as_number().is_some() => compare(x, y)?.is_eq(), + _ => eq(x, y), + }; + if same != want_equal { + return Ok(Value::Nil); + } + } + Ok(Value::t()) +} + +pub(super) fn num_eq(a: &[Value]) -> R { + eq_chain(a, true) +} + +pub(super) fn num_ne(a: &[Value]) -> R { + eq_chain(a, false) +} + +pub(super) fn lt(a: &[Value]) -> R { + chain(a, Ordering::is_lt) +} + +pub(super) fn gt(a: &[Value]) -> R { + chain(a, Ordering::is_gt) +} + +pub(super) fn le(a: &[Value]) -> R { + chain(a, Ordering::is_le) +} + +pub(super) fn ge(a: &[Value]) -> R { + chain(a, Ordering::is_ge) +} + +pub(super) fn zerop(a: &[Value]) -> R { + arity(a, 1, 1)?; + Ok(Value::bool(num(&a[0])? == 0.0)) +} + +pub(super) fn minusp(a: &[Value]) -> R { + arity(a, 1, 1)?; + Ok(Value::bool(num(&a[0])? < 0.0)) +} + +pub(super) fn numberp(a: &[Value]) -> R { + arity(a, 1, 1)?; + Ok(Value::bool(a[0].as_number().is_some())) +} diff --git a/crates/engine/src/lisp/builtins/output.rs b/crates/engine/src/lisp/builtins/output.rs new file mode 100644 index 000000000..21db3f87d --- /dev/null +++ b/crates/engine/src/lisp/builtins/output.rs @@ -0,0 +1,87 @@ +use super::args::{arity, string}; +use super::*; + +// ---------------- output ---------------- + +/// Text printed to a file (an open file argument) or the command line. +pub(super) fn emit(ctx: &mut Ctx<'_>, text: &str, file: Option<&Value>) -> Result<(), LispError> { + charge_bytes(text.len())?; + match file { + None | Some(Value::Nil) => { + ctx.env.print(text); + Ok(()) + } + Some(Value::File(id)) => match ctx.env.files.get_mut(id) { + Some(f) if f.writing => { + f.text.push_str(text); + Ok(()) + } + _ => Err(LispError::bad_arg("streamp", &Value::File(*id))), + }, + Some(other) => Err(LispError::bad_arg("streamp", other)), + } +} + +/// `(princ)` with no argument: a symbol that prints as nothing, ending a routine quietly. +pub fn null_symbol() -> Value { + Value::Sym(Sym(Arc::from(""))) +} + +pub fn is_null_symbol(v: &Value) -> bool { + matches!(v, Value::Sym(s) if s.name().is_empty()) +} + +pub(super) fn princ(ctx: &mut Ctx<'_>, a: Vec) -> Result { + arity(&a, 0, 2)?; + let Some(v) = a.first() else { return Ok(Flow::Value(null_symbol())) }; + emit(ctx, &to_text(v, false), a.get(1))?; + Ok(Flow::Value(v.clone())) +} + +pub(super) fn prin1(ctx: &mut Ctx<'_>, a: Vec) -> Result { + arity(&a, 0, 2)?; + let Some(v) = a.first() else { return Ok(Flow::Value(null_symbol())) }; + emit(ctx, &to_text(v, true), a.get(1))?; + Ok(Flow::Value(v.clone())) +} + +pub(super) fn print(ctx: &mut Ctx<'_>, a: Vec) -> Result { + arity(&a, 0, 2)?; + let Some(v) = a.first() else { return Ok(Flow::Value(null_symbol())) }; + emit(ctx, &format!("\n{} ", to_text(v, true)), a.get(1))?; + Ok(Flow::Value(v.clone())) +} + +pub(super) fn prompt(ctx: &mut Ctx<'_>, a: Vec) -> Result { + arity(&a, 1, 1)?; + let text = string(&a[0])?; + charge_bytes(text.len())?; + ctx.env.print(&text); + Ok(Flow::Value(Value::Nil)) +} + +pub(super) fn terpri(ctx: &mut Ctx<'_>, a: Vec) -> Result { + arity(&a, 0, 1)?; + emit(ctx, "\n", a.first())?; + Ok(Flow::Value(Value::Nil)) +} + +pub(super) fn alert(ctx: &mut Ctx<'_>, a: Vec) -> Result { + arity(&a, 1, 1)?; + // No dialog from LISP: the message goes to the command line. + let msg = string(&a[0])?; + charge_bytes(msg.len())?; + let out = std::mem::take(&mut ctx.env.out); + if !out.is_empty() { + ctx.s.echo(out); + } + for l in msg.lines() { + ctx.s.echo(l.to_string()); + } + Ok(Flow::Value(Value::Nil)) +} + +/// A real as `princ` shows it (for messages). +pub fn real(r: f64) -> String { + real_text(r) +} diff --git a/crates/engine/src/lisp/builtins/strings.rs b/crates/engine/src/lisp/builtins/strings.rs new file mode 100644 index 000000000..08456d3ca --- /dev/null +++ b/crates/engine/src/lisp/builtins/strings.rs @@ -0,0 +1,207 @@ +use super::args::{R, arity, int, list_arg, string}; +use super::*; + +// ---------------- strings ---------------- + +pub(super) fn strcat(a: &[Value]) -> R { + let parts = a.iter().map(string).collect::, _>>()?; + // Check the length before joining: many long arguments could join to more than memory. + check_string_len(parts.iter().fold(0usize, |n, p| n.saturating_add(p.len())))?; + Ok(Value::str(&parts.concat())) +} + +pub(super) fn strlen(a: &[Value]) -> R { + let mut n = 0usize; + for v in a { + n += string(v)?.chars().count(); + } + Ok(Value::Int(i32::try_from(n).unwrap_or(i32::MAX))) +} + +pub(super) fn substr(a: &[Value]) -> R { + arity(a, 2, 3)?; + let s = string(&a[0])?; + let start = int(&a[1])?; + if start < 1 { + return Err(LispError::new(format!("bad argument value: positive {start}"))); + } + let chars = s.chars().skip(start as usize - 1); + let out: String = match a.get(2) { + Some(n) => { + let n = int(n)?; + if n < 0 { + return Err(LispError::new(format!("bad argument value: non-negative {n}"))); + } + chars.take(n as usize).collect() + } + None => chars.collect(), + }; + Ok(Value::str(&out)) +} + +pub(super) fn strcase(a: &[Value]) -> R { + arity(a, 1, 2)?; + let s = string(&a[0])?; + let lower = a.get(1).is_some_and(|v| !v.is_nil()); + Ok(Value::str(&if lower { s.to_lowercase() } else { s.to_uppercase() })) +} + +pub(super) fn itoa(a: &[Value]) -> R { + arity(a, 1, 1)?; + Ok(Value::str(&int(&a[0])?.to_string())) +} + +/// C's `atoi`: leading blanks, a sign, digits; 0 when there are none. +pub(super) fn atoi(a: &[Value]) -> R { + arity(a, 1, 1)?; + let s = string(&a[0])?; + let t = s.trim_start(); + let end = t.char_indices().take_while(|(i, c)| c.is_ascii_digit() || (*i == 0 && (*c == '-' || *c == '+'))).count(); + Ok(Value::Int(t.get(..end).and_then(|n| n.parse::().ok()).map(|n| n as i32).unwrap_or(0))) +} + +/// C's `atof`: the longest number at the start; 0.0 when there is none. +pub(super) fn atof(a: &[Value]) -> R { + arity(a, 1, 1)?; + let s = string(&a[0])?; + // The longest prefix (of the first 64 characters) that reads as a finite number. + let t: String = s.trim_start().chars().take(64).collect(); + let ends: Vec = t.char_indices().map(|(i, _)| i).skip(1).chain(std::iter::once(t.len())).collect(); + let v = ends.iter().rev().find_map(|&e| t.get(..e).and_then(|p| p.parse::().ok()).filter(|v| v.is_finite() && !p_is_word(&t[..e]))); + Ok(Value::Real(v.unwrap_or(0.0))) +} + +/// Words Rust reads as numbers that C's `atof` doesn't ("inf", "nan", "infinity"). +fn p_is_word(p: &str) -> bool { + p.chars().any(|c| c.is_ascii_alphabetic() && !matches!(c, 'e' | 'E')) +} + +pub(super) fn chr(a: &[Value]) -> R { + arity(a, 1, 1)?; + let c = u32::try_from(int(&a[0])?).ok().and_then(char::from_u32).map(String::from).unwrap_or_default(); + Ok(Value::str(&c)) +} + +pub(super) fn ascii(a: &[Value]) -> R { + arity(a, 1, 1)?; + Ok(Value::Int(string(&a[0])?.chars().next().map(|c| c as i32).unwrap_or(0))) +} + +pub(super) fn read_fn(a: &[Value]) -> R { + arity(a, 0, 1)?; + match a.first() { + None | Some(Value::Nil) => Ok(Value::Nil), + Some(v) => read::read_one(&string(v)?), + } +} + +pub(super) fn vl_prin1_to_string(a: &[Value]) -> R { + arity(a, 1, 1)?; + Ok(Value::str(&to_text(&a[0], true))) +} + +pub(super) fn vl_princ_to_string(a: &[Value]) -> R { + arity(a, 1, 1)?; + Ok(Value::str(&to_text(&a[0], false))) +} + +pub(super) fn vl_symbol_name(a: &[Value]) -> R { + arity(a, 1, 1)?; + match &a[0] { + Value::Sym(s) => Ok(Value::str(s.name())), + other => Err(LispError::bad_arg("symbolp", other)), + } +} + +pub(super) fn vl_symbolp(a: &[Value]) -> R { + arity(a, 1, 1)?; + Ok(Value::bool(matches!(a[0], Value::Sym(_)))) +} + +pub(super) fn vl_string_search(a: &[Value]) -> R { + arity(a, 2, 3)?; + let (pat, s) = (string(&a[0])?, string(&a[1])?); + let start = a.get(2).map(int).transpose()?.unwrap_or(0).max(0) as usize; + let chars: Vec = s.chars().collect(); + let tail: String = chars.iter().skip(start).collect(); + Ok(tail.find(&*pat).map(|b| Value::Int((start + tail[..b].chars().count()) as i32)).unwrap_or_default()) +} + +pub(super) fn vl_string_subst(a: &[Value]) -> R { + arity(a, 3, 4)?; + let (new, pat, s) = (string(&a[0])?, string(&a[1])?, string(&a[2])?); + let start = a.get(3).map(int).transpose()?.unwrap_or(0).max(0) as usize; + let byte_start = s.char_indices().nth(start).map(|(b, _)| b).unwrap_or(s.len()); + let out = match s.get(byte_start..).and_then(|t| t.find(&*pat)) { + Some(i) if !pat.is_empty() => format!("{}{}{}", &s[..byte_start + i], new, &s[byte_start + i + pat.len()..]), + _ => s.to_string(), + }; + Ok(Value::str(&out)) +} + +fn trim_fn(a: &[Value], left: bool, right: bool) -> R { + arity(a, 2, 2)?; + let (set, s) = (string(&a[0])?, string(&a[1])?); + let is = |c: char| set.contains(c); + let mut t: &str = &s; + if left { + t = t.trim_start_matches(is); + } + if right { + t = t.trim_end_matches(is); + } + Ok(Value::str(t)) +} + +pub(super) fn vl_string_trim(a: &[Value]) -> R { + trim_fn(a, true, true) +} + +pub(super) fn vl_string_left_trim(a: &[Value]) -> R { + trim_fn(a, true, false) +} + +pub(super) fn vl_string_right_trim(a: &[Value]) -> R { + trim_fn(a, false, true) +} + +pub(super) fn vl_string_translate(a: &[Value]) -> R { + arity(a, 3, 3)?; + let (from, to, s) = (string(&a[0])?, string(&a[1])?, string(&a[2])?); + let to: Vec = to.chars().collect(); + let out: String = s.chars().map(|c| from.chars().position(|f| f == c).and_then(|i| to.get(i).or(to.last()).copied()).unwrap_or(c)).collect(); + Ok(Value::str(&out)) +} + +pub(super) fn vl_string_to_list(a: &[Value]) -> R { + arity(a, 1, 1)?; + Ok(Value::list(string(&a[0])?.chars().map(|c| Value::Int(c as i32)).collect::>())) +} + +pub(super) fn vl_list_to_string(a: &[Value]) -> R { + arity(a, 1, 1)?; + let mut s = String::new(); + for v in list_arg(&a[0])? { + s.extend(u32::try_from(int(&v)?).ok().and_then(char::from_u32)); + } + Ok(Value::str(&s)) +} + +pub(super) fn vl_filename_base(a: &[Value]) -> R { + arity(a, 1, 1)?; + let s = string(&a[0])?; + Ok(Value::str(std::path::Path::new(&*s.replace('\\', "/")).file_stem().and_then(OsStr::to_str).unwrap_or(""))) +} + +pub(super) fn vl_filename_extension(a: &[Value]) -> R { + arity(a, 1, 1)?; + let s = string(&a[0])?; + Ok(std::path::Path::new(&*s.replace('\\', "/")).extension().and_then(OsStr::to_str).map(|e| Value::str(&format!(".{e}"))).unwrap_or_default()) +} + +pub(super) fn vl_filename_directory(a: &[Value]) -> R { + arity(a, 1, 1)?; + let s = string(&a[0])?; + let cut = s.rfind(['/', '\\']).unwrap_or(0); + Ok(Value::str(&s[..cut])) +} diff --git a/crates/engine/src/lisp/builtins/wcmatch.rs b/crates/engine/src/lisp/builtins/wcmatch.rs new file mode 100644 index 000000000..1f171b253 --- /dev/null +++ b/crates/engine/src/lisp/builtins/wcmatch.rs @@ -0,0 +1,242 @@ +use super::args::{R, arity, string}; +use super::*; + +/// `wcmatch`: `#` a digit, `@` a letter, `.` neither, `*` any run, `?` any character, `~` (first) +/// not, `[…]` a set (`[~…]` not in it, ranges `a-z`), `` ` `` the next character as is, `,` +/// between alternative patterns. Letters compare case-sensitively. +pub(super) fn wcmatch(a: &[Value]) -> R { + arity(a, 2, 2)?; + let (s, pat) = (string(&a[0])?, string(&a[1])?); + let text: Vec = s.chars().collect(); + // Native wildcard matching runs as a single interpreter step, so its internal `*` backtracking + // must not explore exponentially many placements unchecked (CWE-400). Charge every glob step + // against a shared budget and fail the call if it is exhausted. + let mut budget: u64 = WCMATCH_BUDGET; + for p in split_patterns(&pat, &mut budget).map_err(|()| LispError::new("wcmatch: pattern too complex"))? { + match match_pattern(&text, &p, &mut budget) { + Ok(true) => return Ok(Value::bool(true)), + Ok(false) => {} + Err(()) => return Err(LispError::new("wcmatch: pattern too complex")), + } + } + Ok(Value::bool(false)) +} + +/// Shared step budget for one `wcmatch` call's wildcard matching. Large enough for the patterns +/// AutoLISP routines legitimately use (including the pathological-but-bounded cases the tests +/// cover), but finite so truly exponential `*`-heavy patterns are rejected rather than hanging. +const WCMATCH_BUDGET: u64 = 200_000_000; + +/// Spend one wildcard work unit, returning an error when the shared budget is exhausted. +fn charge_match(budget: &mut u64) -> Result<(), ()> { + *budget = budget.checked_sub(1).ok_or(())?; + Ok(()) +} + +/// Split comma-separated wildcard alternatives while preserving escapes and bracket sets. +/// Every consumed character spends from the same budget used for matching. +fn split_patterns(pat: &str, budget: &mut u64) -> Result>, ()> { + let mut out = vec![Vec::new()]; + let mut chars = pat.chars().peekable(); + let mut in_set = false; + while let Some(c) = chars.next() { + charge_match(budget)?; + match c { + '`' => { + if let Some(cur) = out.last_mut() { + cur.push('`'); + if let Some(escaped) = chars.next() { + charge_match(budget)?; + cur.push(escaped); + } + } + } + '[' => { + in_set = true; + if let Some(cur) = out.last_mut() { + cur.push(c); + } + } + ']' => { + in_set = false; + if let Some(cur) = out.last_mut() { + cur.push(c); + } + } + ',' if !in_set => out.push(Vec::new()), + c => { + if let Some(cur) = out.last_mut() { + cur.push(c); + } + } + } + } + Ok(out) +} + +/// Match one wildcard alternative, including leading `~` negation, within the shared budget. +fn match_pattern(text: &[char], pat: &[char], budget: &mut u64) -> Result { + if let Some(('~', rest)) = pat.split_first().map(|(c, r)| (*c, r)) + && !rest.is_empty() + { + return glob(text, rest, 0, budget).map(|m| !m); + } + glob(text, pat, 0, budget) +} + +/// Iterative-enough glob with `*` backtracking; `depth` bounds the recursion on `*`. Every step is +/// charged against `budget`; `Err(())` means the shared budget ran out (treated as too complex). +fn glob(text: &[char], pat: &[char], depth: usize, budget: &mut u64) -> Result { + glob_cached(text, pat, depth, budget, &mut std::collections::HashSet::new()) +} + +/// Match suffixes while memoizing failures; both matching work and retained failure entries are bounded. +fn glob_cached( + text: &[char], + pat: &[char], + depth: usize, + budget: &mut u64, + failed: &mut std::collections::HashSet<(usize, usize, usize)>, +) -> Result { + charge_match(budget)?; + let key = (text.len(), pat.len(), depth); + if failed.contains(&key) { + return Ok(false); + } + let matched = glob_inner(text, pat, depth, budget, failed)?; + // Avoid revisiting the same failed suffix exponentially. Bound the memo table independently + // of the step limit so a very large pattern cannot turn the optimization into an allocation bomb. + if !matched && failed.len() < 100_000 { + failed.insert(key); + } + Ok(matched) +} + +/// 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], + pat: &[char], + depth: usize, + budget: &mut u64, + failed: &mut std::collections::HashSet<(usize, usize, usize)>, +) -> Result { + if *budget == 0 { + return Err(()); + } + *budget -= 1; + if depth > 256 { + return Err(()); + } + let (mut t, mut p) = (0usize, 0usize); + while p < pat.len() { + charge_match(budget)?; + match pat[p] { + '*' => { + let rest = &pat[p + 1..]; + for i in t..=text.len() { + if glob_cached(&text[i..], rest, depth + 1, budget, failed)? { + return Ok(true); + } + } + return Ok(false); + } + '`' => { + let Some(&lit) = pat.get(p + 1) else { return Ok(false) }; + if text.get(t) != Some(&lit) { + return Ok(false); + } + t += 1; + p += 2; + } + '[' => { + let Some(close) = class_end(text, pat, p, t, budget)? else { return Ok(false) }; + t += 1; + p = close + 1; + } + pc => { + let Some(&c) = text.get(t) else { return Ok(false) }; + let ok = match pc { + '#' => c.is_ascii_digit(), + '@' => c.is_alphabetic(), + '.' => !c.is_alphanumeric(), + '?' => true, + other => other == c, + }; + if !ok { + return Ok(false); + } + t += 1; + p += 1; + } + } + } + Ok(t == text.len()) +} + +/// `wcmatch` for other callers (`ssget` filters). +pub fn wild_match(text: &str, pattern: &str) -> bool { + let t: Vec = text.chars().collect(); + let mut budget: u64 = WCMATCH_BUDGET; + split_patterns(pattern, &mut budget).is_ok_and(|patterns| patterns.iter().any(|p| match_pattern(&t, p, &mut budget) == Ok(true))) +} + +#[cfg(test)] +mod wildcard_budget_tests { + use super::*; + + /// Pattern splitting, literal matching, and bracket scans all stop when their shared fuel runs out. + #[test] + fn all_native_scans_consume_fuel() { + assert!(split_patterns("abcdefgh", &mut 4).is_err()); + assert!(split_patterns("`a`b`c", &mut 4).is_err()); + let text: Vec<_> = "abcdefgh".chars().collect(); + assert!(glob(&text, &text, 0, &mut 4).is_err()); + let pattern: Vec<_> = "[abcdefgh]".chars().collect(); + assert!(glob(&['!'], &pattern, 0, &mut 6).is_err()); + // Enough for finding the closing bracket, but not scanning the entire set again. + assert!(glob(&['!'], &pattern, 0, &mut 14).is_err()); + assert_eq!(glob(&['a'], &pattern, 0, &mut 100), Ok(true)); + } +} diff --git a/crates/engine/src/running.rs b/crates/engine/src/running.rs new file mode 100644 index 000000000..afcd0b38f --- /dev/null +++ b/crates/engine/src/running.rs @@ -0,0 +1,16 @@ +use super::*; + +/// An interactive command in progress. +pub struct Running { + pub id: String, + pub machine: Box, + pub before: Arc, + pub selection_before: Vec, +} + +/// A pending window/crossing selection started by a click on empty space. +#[derive(Clone, Copy, Debug, PartialEq, Serialize)] +pub struct PendingWindow { + pub corner: Vec2, + pub during_command: bool, +} diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs new file mode 100644 index 000000000..ef9bf8d73 --- /dev/null +++ b/crates/engine/src/session.rs @@ -0,0 +1,12 @@ +use super::doc_state::{DocState, Snapshot}; +use super::error::{EngineError, Result}; +use super::running::{PendingWindow, Running}; +use super::view::View; +use super::*; + +mod cmdline; +mod execute; +mod files; +mod interactive; +mod picking; +mod zoom; diff --git a/crates/engine/src/session/cmdline.rs b/crates/engine/src/session/cmdline.rs new file mode 100644 index 000000000..5a57e329e --- /dev/null +++ b/crates/engine/src/session/cmdline.rs @@ -0,0 +1,225 @@ +use super::*; + +impl Session { + /// Process one line typed at the command line (Enter pressed). Spaces separate inputs as in a + /// script line (see [`Session::script`]), so `MOVE L 0,0 5,5` ends the selection with the + /// second space and `ERASE L ` with the trailing one. Input after the command ends is ignored. + pub fn cmdline(&mut self, text: &str) -> Result<()> { + let t = text.trim_end_matches(['\r', '\n']); + // The command line types spaces literally at free-text prompts: the line is one input. + if self.current_prompt().is_some_and(|p| p.accept.text && !p.accept.point && !p.accept.number) { + return self.typed(t); + } + // At `Command:` leading blanks before a command name are not Enter (`" LINE"` from the + // control channel starts LINE); a line of only blanks still is. + let t = if self.running.is_none() && !t.trim().is_empty() { t.trim_start() } else { t }; + self.input_line(t, false) + } + + /// One line of input: each space ends an input and an empty input (two spaces in a row, a + /// trailing space, an empty line) is Enter, which at `Command:` repeats the last command. In a + /// script, a free-text prompt (TEXT/MTEXT contents) takes the rest of the line, a prompt with + /// keywords still splits so `I 15` reaches the keyword and the value, and the line goes on + /// after its command ends; at the command line the rest after the command ends is ignored. + fn input_line(&mut self, line: &str, script: bool) -> Result<()> { + let mut rest = line; + let mut first = true; + loop { + if self.running.is_none() { + if !script && !first { + return Ok(()); + } + // `cmd {json}`: programmatic call with parameters. + if let Some((name, json)) = rest.trim().split_once(' ') + && json.trim_start().starts_with('{') + { + let params: Value = + serde_json::from_str(json.trim()).map_err(|e| EngineError::BadParams { cmd: name.into(), msg: e.to_string() })?; + let id = cmd::resolve_alias(&name.to_ascii_lowercase()); + let r = self.execute(&id, ¶ms)?; + if !r.is_null() { + self.echo(r.to_string()); + } + return Ok(()); + } + } else if script && self.current_prompt().is_some_and(|p| p.accept.text && !p.accept.point && !p.accept.number && p.keywords.is_empty()) { + return self.typed(rest); + } + first = false; + let (tok, more) = next_token(rest); + if self.running.is_some() || self.lisp_prompt().is_some() { + self.typed(tok)?; + } else if tok.trim().is_empty() { + self.input(Input::Enter)?; + } else if lisp_expression(tok) { + // `(expression)` or `!variable` at Command: evaluates LISP and shows the value. + let src = tok.trim().strip_prefix('!').unwrap_or(tok.trim()).to_string(); + if let Err(e) = self.lisp_eval(&src, true, false, Vec::new()) { + self.echo(e.to_string()); + } + } else { + self.start(tok)?; + } + match more { + Some(more) => rest = more, + None => return Ok(()), + } + } + } + + /// One token typed while a command runs. + pub(crate) fn typed(&mut self, t: &str) -> Result<()> { + let Some(prompt) = self.current_prompt() else { return Ok(()) }; + let tt = t.trim(); + if tt.is_empty() { + return self.input(Input::Enter); + } + // FROM, M2P, point filters, ` v, + Ok(_) => return Ok(()), + Err(e) => { + self.echo(e.to_string()); + return Ok(()); + } + }; + return match v { + lisp::value::Value::Nil => Ok(()), + lisp::value::Value::Int(i) => self.typed(&i.to_string()), + lisp::value::Value::Real(r) => self.typed(&format!("{r}")), + lisp::value::Value::Str(s) => self.typed(&s), + other => match other.as_point() { + Some(p) => self.input(Input::Point(Vec2::new(p[0], p[1]))), + None => { + self.echo(format!("Invalid input: {}", lisp::value::to_text(&other, true))); + Ok(()) + } + }, + }; + } + // At "Select objects" prompts the selection modes (Previous, Last, ALL) win over a command + // keyword sharing the letter (HATCH's "picK internal point" against `P`). + let selection_mode = prompt.accept.select + && matches!(tt.strip_prefix('_').unwrap_or(tt).to_ascii_lowercase().as_str(), "all" | "l" | "last" | "p" | "previous"); + // A leading underscore asks for a keyword by its English name, whatever the interface + // language (`_M`, `_Yes`): scripts and LISP routines write them that way. (Snap + // overrides such as `_cen` were taken above.) + if !selection_mode + && let Some(k) = prompt.match_keyword(tt).or_else(|| tt.strip_prefix('_').and_then(|r| prompt.match_keyword(r))) + && !(prompt.accept.number && tt.parse::().is_ok()) + { + return self.input(Input::Keyword(k)); + } + if prompt.accept.point { + if let Some(p) = prompt::parse_point_with(tt, self.last_point, &self.angle_settings()) { + self.last_polar = prompt::parse_polar_text(tt, &self.angle_settings()); + return self.input(Input::Point(p)); + } + // Direct distance entry along the tracking path the cursor is on, from the point it + // comes from (an acquired or temporary tracking point needs no base point). + if let Some(dist) = units::parse_distance(tt).filter(|_| !prompt.accept.number) + && let Some((from, dir)) = self.tracking.last.as_ref().filter(|t| t.point.near(self.cursor, 1e-9)).and_then(|t| t.along()) + { + return self.input(Input::Point(from + dir * dist)); + } + // Direct distance entry along the rubber band. + if let (Some(base), Some(dist)) = (prompt.base, units::parse_distance(tt)) + && !prompt.accept.number + { + let mut c = self.cursor; + if self.settings.orthomode && !prompt.corner { + c = snap::iso::constrain(&self.drafting_settings(), base, c); + } + let dir = (c - base).normalized(); + let dir = if dir == Vec2::ZERO { Vec2::X } else { dir }; + return self.input(Input::Point(base + dir * dist)); + } + } + self.input(Input::Text(tt.to_string())) + } + + /// Run a script, as AutoCAD reads one: a space or a line end ends an input, and an empty + /// input (two spaces in a row, a space at the end of a line, an empty line) is Enter. At + /// free-text prompts spaces are part of the text. Lines starting with `;` are comments. + /// + /// A script cannot run a script: the SCRIPT command (or another `cmd {json}` call) inside a + /// running one is refused, so two files that run each other cannot recurse until the stack dies. + pub fn script(&mut self, text: &str) -> Result<()> { + if self.in_script { + return Err(EngineError::BadParams { cmd: "script".into(), msg: "a script is already running; nested scripts are not supported".into() }); + } + self.in_script = true; + let r = self.script_lines(text); + self.in_script = false; + r + } + + fn script_lines(&mut self, text: &str) -> Result<()> { + for line in text.lines() { + // Trailing spaces are inputs (Enter); other trailing whitespace (`\r`) is not. + let line = line.trim_end_matches(|c: char| c.is_whitespace() && c != ' '); + if line.trim_start().starts_with(';') { + continue; + } + self.input_line(line, true)?; + } + Ok(()) + } +} + +/// Typed text that is LISP: `(expression)` or `!variable`. +fn lisp_expression(t: &str) -> bool { + let t = t.trim(); + t.starts_with('(') || (t.starts_with('!') && t.len() > 1) +} + +/// The next command-line input in `rest` and what follows it: up to a space or tab, except that +/// a LISP expression runs to its closing parenthesis (spaces and quoted strings inside it). +fn next_token(rest: &str) -> (&str, Option<&str>) { + let lead = rest.len() - rest.trim_start_matches('!').len(); + if rest.get(lead..).is_some_and(|r| r.starts_with('(')) { + let (mut depth, mut in_str, mut escaped) = (0i32, false, false); + for (i, c) in rest.char_indices().skip(lead) { + if in_str { + match (escaped, c) { + (true, _) => escaped = false, + (false, '\\') => escaped = true, + (false, '"') => in_str = false, + _ => {} + } + continue; + } + match c { + '"' => in_str = true, + '(' => depth += 1, + ')' => { + depth -= 1; + if depth == 0 { + let end = i + 1; + // As after any input, a space ends it and a trailing one is Enter. + let more = rest.get(end..).filter(|m| !m.is_empty()).map(|m| m.strip_prefix([' ', '\t']).unwrap_or(m)); + return (&rest[..end], more); + } + } + _ => {} + } + } + // Unbalanced: all of it (the reader reports the error). + return (rest, None); + } + match rest.split_once([' ', '\t']) { + Some((tok, more)) => (tok, Some(more)), + None => (rest, None), + } +} diff --git a/crates/engine/src/session/execute.rs b/crates/engine/src/session/execute.rs new file mode 100644 index 000000000..165ec0863 --- /dev/null +++ b/crates/engine/src/session/execute.rs @@ -0,0 +1,95 @@ +use super::*; + +impl Session { + // ---------------- programmatic execution ---------------- + + /// Run a command with JSON parameters (no dialogs). Records undo when the drawing changes. + pub fn execute(&mut self, id: &str, params: &Value) -> Result { + let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?; + (spec.enabled)(self).map_err(|m| EngineError::Disabled(spec.id.into(), m))?; + if let Some(why) = cmd::blocks::bedit::refused_in_editor(self, spec.id) { + return Err(EngineError::Disabled(spec.id.into(), why)); + } + let before = self.state().ok().map(|s| (s.doc.clone(), s.selection.clone(), s.uid)); + let editing = self.state().is_ok_and(|s| s.block_edit.is_some()); + let run = spec.run; + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run(self, params))); + let result = match result { + Ok(r) => r, + Err(p) => { + let msg = p + .downcast_ref::<&str>() + .map(ToString::to_string) + .or_else(|| p.downcast_ref::().cloned()) + .unwrap_or_else(|| "panic".into()); + // Restore the drawing as it was. + if let Some((doc, sel, uid)) = &before + && let Some(st) = self.docs.iter_mut().find(|d| d.uid == *uid) + { + st.doc = doc.clone(); + st.selection = sel.clone(); + } + return Err(EngineError::Internal(id.into(), msg)); + } + }; + let violation = before.as_ref().and_then(|b| cmd::blocks::refedit::after_command(self, &b.0, spec.id)); + let result = match (violation, result) { + (Some(m), Ok(_)) => Err(EngineError::Other(m)), + (_, r) => r, + }; + // Opening or closing the Block Editor swaps the whole drawing: nothing in it changed. + if self.state().is_ok_and(|s| s.block_edit.is_some()) == editing { + self.after_command(before.as_ref().map(|b| &b.0), before.as_ref().map(|b| b.2), spec.undoable && result.is_ok()); + } + let result = match (result, &before) { + (Ok(v), Some((doc, sel, uid))) if spec.undoable => self.refuse_non_finite(Some(*uid), doc, sel, spec.id).map(|()| v), + (r, _) => r, + }; + let grouped = self.lisp_groups_undo(); + if spec.undoable + && let Some((doc, sel, uid)) = before + && let Some(st) = self.docs.iter_mut().find(|d| d.uid == uid) + && !Arc::ptr_eq(&doc, &st.doc) + { + // Inside a LISP routine the routine's single undo step records it. + if !grouped { + st.push_undo(Snapshot { label: spec.label.to_string(), doc, selection: sel }); + } + st.revision += 1; + } + result + } + + /// Bump the revision without an undo step (view-only or settings changes that redraw). + pub fn touch(&mut self) { + if let Ok(st) = self.state_mut() { + st.revision += 1; + } + } + + // ---------------- undo ---------------- + + pub fn undo(&mut self) -> Result> { + let st = self.state_mut()?; + let Some(snap) = st.undo.pop() else { return Ok(None) }; + let cur = Snapshot { label: snap.label.clone(), doc: st.doc.clone(), selection: st.selection.clone() }; + st.doc = snap.doc; + st.follow_layout(&cur.doc); + st.selection = Vec::new(); + st.redo.push(cur); + st.revision += 1; + Ok(Some(snap.label)) + } + + pub fn redo(&mut self) -> Result> { + let st = self.state_mut()?; + let Some(snap) = st.redo.pop() else { return Ok(None) }; + let cur = Snapshot { label: snap.label.clone(), doc: st.doc.clone(), selection: st.selection.clone() }; + st.doc = snap.doc; + st.follow_layout(&cur.doc); + st.selection = Vec::new(); + st.undo.push(cur); + st.revision += 1; + Ok(Some(snap.label)) + } +} diff --git a/crates/engine/src/session/files.rs b/crates/engine/src/session/files.rs new file mode 100644 index 000000000..f0270c798 --- /dev/null +++ b/crates/engine/src/session/files.rs @@ -0,0 +1,61 @@ +use super::*; + +impl Session { + /// 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() }; + let mut st = DocState::new(d, &format!("Drawing{}", self.untitled_counter), None); + st.set_view_quiet(if metric { View { center: Vec2::new(210.0, 148.5), height: 297.0 } } else { View::default() }); + 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); + if !ext.is_empty() { + st.set_view_quiet(View { + center: ext.center(), + height: (ext.height().max(ext.width() * self.viewport_px.1 / self.viewport_px.0.max(1.0)) * 1.1).max(1e-6), + }); + } + 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) { + if self.running.is_some() { + self.cancel(); + } + } + + /// Add a file read off-thread with [`cmd::file::read_file`] as the active drawing (OPEN's fast + /// half). A panic while adding it is reported as an error, never propagated. + pub fn finish_open(&mut self, f: cmd::file::ReadFile) -> Result { + let n = self.docs.len(); + match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| cmd::file::finish_open(self, f))) { + Ok(r) => r, + 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())) + } + } + } +} diff --git a/crates/engine/src/session/interactive.rs b/crates/engine/src/session/interactive.rs new file mode 100644 index 000000000..335004349 --- /dev/null +++ b/crates/engine/src/session/interactive.rs @@ -0,0 +1,511 @@ +use super::*; + +impl Session { + // ---------------- interactive commands ---------------- + + /// Start a command by name or alias as if typed at the command line. + pub fn start(&mut self, name: &str) -> Result<()> { + self.last_polar = None; + let name = name.trim(); + let (transparent, name) = match name.strip_prefix('\'') { + Some(n) => (true, n), + None => (false, name), + }; + let lower = name.trim_start_matches(['_', '.', '-']).to_ascii_lowercase(); + // A command-line form with an id of its own (-ATTEDIT) is not the command it prefixes. + let dashed = cmd::resolve_alias(&name.trim_start_matches(['_', '.']).to_ascii_lowercase()); + let id = if find_command(&dashed).is_some() { dashed } else { cmd::resolve_alias(&lower) }; + let Some(spec) = find_command(&id) else { + // A command defined in LISP: (defun c:NAME …). + if !transparent && let Some(f) = self.lisp_command(&lower) { + if self.running.is_some() { + self.cancel(); + } + self.last_command = Some(lower.clone()); + self.echo(format!("Command: {}", lower.to_ascii_uppercase())); + return self.lisp_run_command(&lower, f); + } + // A bare system variable name (ORTHOMODE, GRIDMODE…) acts as SETVAR on that variable. + let var = lower.to_ascii_uppercase(); + if !name.starts_with(['_', '.', '-']) && sysvars::get(self, &var).is_some() { + self.start(if transparent { "'setvar" } else { "setvar" })?; + return self.input(Input::Text(var)); + } + return Err(EngineError::UnknownCommand(name.to_string())); + }; + if transparent && spec.transparent && self.running.is_some() { + // Transparent commands (zoom, pan…) run without cancelling the active one. + if spec.interactive.is_none() { + self.echo(format!(">>{}", spec.label)); + return self.execute(spec.id, &Value::Null).map(|_| ()); + } + if self.suspended.is_some() { + self.echo("A transparent command is already in progress."); + return Ok(()); + } + } + let nested = transparent && spec.transparent && self.running.is_some() && spec.interactive.is_some(); + // Ctrl+Z / U / UNDO while a command's prompt offers its own Undo keyword (LINE, PLINE…) + // takes back the last segment and keeps the command running, as the keyword does. + if !nested && matches!(spec.id, "u" | "undo") && self.current_prompt().is_some_and(|p| p.keywords.iter().any(|k| k == "Undo")) { + self.echo(format!("Command: {}", spec.id.to_ascii_uppercase())); + return self.input(Input::Keyword("Undo".into())); + } + if !nested && self.running.is_some() { + self.cancel(); + } + (spec.enabled)(self).map_err(|m| EngineError::Disabled(spec.id.into(), m))?; + if let Some(why) = cmd::blocks::bedit::refused_in_editor(self, spec.id) { + return Err(EngineError::Disabled(spec.id.into(), why)); + } + if nested { + // The outer command stays the one Enter repeats. + self.echo(format!(">>{}", spec.id.to_ascii_uppercase())); + } else { + self.last_command = Some(spec.id.to_string()); + self.echo(format!("Command: {}", spec.id.to_ascii_uppercase())); + } + match spec.interactive { + Some(factory) => { + let machine = guard::Guarded::create(spec.id, factory, self)?; + let st = self.state()?; + let before = st.doc.clone(); + let selection_before = st.selection.clone(); + if nested { + self.suspended = self.running.take(); + } + self.running = Some(Running { id: spec.id.to_string(), machine, before, selection_before }); + self.pending_window = None; + // Some commands complete immediately (e.g. ERASE with a pickfirst selection). + self.feed(None)?; + Ok(()) + } + None => { + let r = self.execute(spec.id, &Value::Null)?; + if let Some(s) = r.as_str() { + self.echo(s.to_string()); + } else if !r.is_null() + && let Some(msg) = r.get("message").and_then(Value::as_str) + { + for l in msg.lines() { + self.echo(l.to_string()); + } + } + Ok(()) + } + } + } + + /// Feed an object-snap pick, retaining its viewport origin for measurements. Point modifiers + /// that combine or constrain coordinates produce ordinary drawing-space points. + pub fn input_snapped(&mut self, hit: snap::SnapHit) -> Result<()> { + let source = hit.viewport.filter(|_| self.point_mods.iter().all(|f| matches!(f, pointmod::Frame::Snap { mode } if *mode != 0))); + let previous = std::mem::replace(&mut self.picked_viewport, source); + let result = self.input(hit.input()); + self.picked_viewport = previous; + result + } + + /// Give an input to the running command. With no command running, `Enter` repeats the last + /// command (as in AutoCAD) and points do nothing. A LISP routine waiting in a `get…` + /// function takes the input instead; one paused in `command` carries on after it. + pub fn input(&mut self, input: Input) -> Result<()> { + if self.lisp_input(input.clone()) { + return Ok(()); + } + let r = self.input_to_command(input); + self.lisp_after_command_input(); + r + } + + fn input_to_command(&mut self, input: Input) -> Result<()> { + if self.running.is_none() { + if input == Input::Enter + && let Some(last) = self.last_command.clone() + { + return self.start(&last); + } + return Ok(()); + } + if !self.point_mods.is_empty() { + return self.modifier_input(input); + } + // A deferred snap is only meaningful where the prompt resolves it; elsewhere it is the + // point it was picked at. + let input = match input { + Input::Deferred(d) if !self.current_prompt().is_some_and(|p| p.deferred) => Input::Point(d.at), + other => other, + }; + // Generic selection handling during "Select objects:" prompts. + let input = self.preprocess_selection(input)?; + match input { + Some(i) => { + // SIngle: the first selection that finds objects also ends the prompt. + let single = self.select_single && matches!(&i, Input::Pick(hs) if !hs.is_empty()); + self.feed(Some(i))?; + if single { + self.select_single = false; + if self.current_prompt().is_some_and(|p| p.accept.select) { + self.feed(Some(Input::Enter))?; + } + } + Ok(()) + } + None => Ok(()), + } + } + + fn preprocess_selection(&mut self, input: Input) -> Result> { + // The command's own prompt: a typed selection mode only applies at its "Select objects". + let Some(prompt) = self.running.as_ref().map(|r| r.machine.prompt(self)) else { return Ok(Some(input)) }; + if !prompt.accept.select { + return Ok(Some(input)); + } + if self.select_mode.is_some() { + return self.select_mode_input(input); + } + // `_L`, `_ALL`, `_W`: the English name of a selection keyword, as scripts write it. + let input = match input { + Input::Text(t) => Input::Text(t.trim().strip_prefix('_').map(str::to_string).unwrap_or(t)), + other => other, + }; + if let Input::Text(t) | Input::Keyword(t) = &input + && let Some(m) = select::SelectMode::from_keyword(t) + { + self.pending_window = None; + self.select_mode = Some(m); + return Ok(None); + } + if let Input::Text(t) | Input::Keyword(t) = &input + && !prompt.keywords.iter().any(|k| k.eq_ignore_ascii_case(t.trim())) + { + match t.trim().to_ascii_lowercase().as_str() { + "si" | "single" => { + self.select_single = true; + return Ok(None); + } + // AUto (a pick, or a window where nothing is picked) is how points always select; + // Multiple picks the same way until Enter. + "au" | "auto" | "m" | "multiple" => return Ok(None), + _ => {} + } + } + let space = self.space(); + match input { + Input::Point(p) => { + let ap = self.pixel_size() * self.settings.pickbox.max(1.0) * 1.5; + if let Some(pw) = self.pending_window.take() { + let crossing = p.x < pw.corner.x; + let hs = select::select_window(self.doc()?, &space, Bounds2::new(pw.corner, p), crossing); + return Ok(Some(Input::Pick(cmd::groups::expand(self, hs)))); + } + match select::pick(self.doc()?, &space, p, ap) { + Some(h) => Ok(Some(Input::Pick(cmd::groups::expand(self, vec![h])))), + None => { + self.pending_window = Some(PendingWindow { corner: p, during_command: true }); + Ok(None) + } + } + } + Input::Text(ref t) | Input::Keyword(ref t) => { + let tl = t.trim().to_ascii_lowercase(); + let d = self.doc()?; + let store = d.space(&space); + let picked = match tl.as_str() { + "all" => store.map(|s| s.iter().filter(|e| d.is_visible(e)).map(|e| e.handle).collect()), + "l" | "last" => store.and_then(|s| s.last()).map(|e| vec![e.handle]), + "p" | "previous" => Some(self.state()?.previous_selection.clone()), + _ => None, + }; + match picked { + Some(hs) => Ok(Some(Input::Pick(cmd::groups::expand(self, hs)))), + None => Ok(Some(input)), + } + } + Input::Cancel => { + self.pending_window = None; + Ok(Some(Input::Cancel)) + } + other => Ok(Some(other)), + } + } + + /// Input while a typed selection mode (W, C, F, WP, CP) collects its points: the selected + /// objects go to the command as a pick once the mode is complete. + fn select_mode_input(&mut self, input: Input) -> Result> { + let Some(mut mode) = self.select_mode.take() else { return Ok(Some(input)) }; + if mode.kind == select::SelectKind::Group { + return match input { + Input::Text(name) | Input::Keyword(name) => match select::select_group(self.doc()?, &self.space(), &name) { + Some(hs) => Ok(Some(Input::Pick(hs))), + None => { + self.echo(format!("No group named \"{}\".", name.trim())); + Ok(None) + } + }, + // Enter or Esc leaves the group name prompt. + Input::Cancel => Ok(Some(input)), + Input::Enter => Ok(None), + _ => { + self.echo("Invalid group name."); + self.select_mode = Some(mode); + Ok(None) + } + }; + } + match input { + Input::Point(p) => { + if mode.push(p) { + return Ok(mode.select(self.doc()?, &self.space()).map(Input::Pick)); + } + } + Input::Keyword(k) if k == "Undo" => { + mode.pts.pop(); + } + // Enter ends a fence or polygon; with too few points the mode is dropped. + Input::Enter => return Ok(mode.select(self.doc()?, &self.space()).map(Input::Pick)), + Input::Cancel | Input::Pick(_) => return Ok(Some(input)), + _ => self.echo("Invalid point."), + } + self.select_mode = Some(mode); + Ok(None) + } + + pub(crate) fn feed(&mut self, input: Option) -> Result<()> { + let base = if matches!(input, Some(Input::Point(_))) { self.current_prompt().and_then(|p| p.base) } else { None }; + let Some(mut run) = self.running.take() else { return Ok(()) }; + let step = match input { + Some(Input::Cancel) => Ok(Step::Cancel), + Some(i) => { + if let Input::Point(p) = &i { + self.last_point = *p; + if let Some(b) = base.filter(|b| b.is_finite() && p.is_finite() && b.dist(*p) > 1e-12) { + self.last_segment = Some((b, *p)); + } + // A temporary tracking point (TT) serves one point. + self.tracking.release_temporary(); + } + let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run.machine.input(self, i))); + match r { + Ok(r) => r, + Err(_) => Err(EngineError::Internal(run.id.clone(), "panic".into())), + } + } + // A command starts with no modifiers pending. + None => { + self.point_mods.clear(); + run.machine.begin(self) + } + }; + // Redraw what the command has added so far (LINE adds a segment per point), not only when it ends. + if let Ok(st) = self.state_mut() + && !Arc::ptr_eq(&run.before, &st.doc) + { + st.revision += 1; + } + match step { + Ok(Step::Continue) => { + self.running = Some(run); + Ok(()) + } + Ok(Step::Done) => { + self.finish(run, false); + self.resume_suspended(); + Ok(()) + } + Ok(Step::Cancel) => { + self.echo("*Cancel*"); + self.finish(run, true); + self.resume_suspended(); + Ok(()) + } + Err(EngineError::Internal(id, m)) => { + // Restore and end the command. + if let Ok(st) = self.state_mut() { + st.doc = run.before.clone(); + } + self.echo(format!("Internal error in {id}: {m}")); + self.resume_suspended(); + Err(EngineError::Internal(id, m)) + } + Err(e) => { + // Invalid input: report and keep prompting (AutoCAD re-prompts). + self.echo(e.to_string()); + self.running = Some(run); + Ok(()) + } + } + } + + fn finish(&mut self, run: Running, cancelled: bool) { + // A typed selection mode or SIngle never outlives its command. + self.select_mode = None; + self.select_single = false; + if find_command(&run.id).is_some_and(|c| c.undoable) + && let Err(e) = self.refuse_non_finite(None, &run.before, &run.selection_before, &run.id) + { + self.echo(e.to_string()); + } + // A command outside undo that changed the drawing (UNDO, which swapped it for an earlier + // one) records no undo step, as in `execute`. + if find_command(&run.id).is_some_and(|c| !c.undoable) && self.state().is_ok_and(|st| !Arc::ptr_eq(&run.before, &st.doc)) { + self.pending_window = None; + return; + } + self.pending_window = None; + self.select_mode = None; + let label = find_command(&run.id).map(|c| c.label).unwrap_or("Command"); + let _ = cancelled; + if let Some(m) = cmd::blocks::refedit::after_command(self, &run.before, &run.id) { + self.echo(m); + } + self.after_command(Some(&run.before), None, true); + // A transparent command's changes belong to the command it ran inside, and a command a + // LISP routine ran belongs to the routine's single undo step. + if self.suspended.is_some() || self.lisp_groups_undo() { + if let Ok(st) = self.state_mut() + && !Arc::ptr_eq(&run.before, &st.doc) + { + st.revision += 1; + } + return; + } + if let Ok(st) = self.state_mut() + && !Arc::ptr_eq(&run.before, &st.doc) + { + st.push_undo(Snapshot { label: label.to_string(), doc: run.before, selection: run.selection_before }); + st.revision += 1; + } + } + + /// The post-command passes, in order, run after every command — by [`Session::execute`] and + /// when an interactive command finishes — so their edits land in the command's own undo step: + /// group membership is pruned, constraints are inferred, purged and re-solved, and + /// associative dimensions and hatches follow their sources. A new pass is added here, once. + /// + /// `before` is the drawing the command started from; `uid` (when known) is the document it + /// started in, so associativity is not applied across a document switch; `ok` is false for a + /// failed or non-undoable programmatic command, which skips the constraint pass. + pub(super) fn after_command(&mut self, before: Option<&Arc>, uid: Option, ok: bool) { + cmd::groups::after_command(self, before); + cmd::constraints::after_command(self, before, ok); + assoc::after_command(self, before, uid); + } + + fn resume_suspended(&mut self) { + if let Some(outer) = self.suspended.take() { + self.echo(format!("Resuming {} command.", outer.id.to_ascii_uppercase())); + self.running = Some(outer); + } + } + + /// Cancel the running command (Esc). With no command, clears the selection. Inside a + /// transparent command, cancels it and the command it interrupted. + pub fn cancel(&mut self) { + self.point_mods.clear(); + // Esc stops a LISP routine waiting for input, and the command it paused in. + if self.lisp_waiting() { + if self.running.is_some() { + let _ = self.feed(Some(Input::Cancel)); + } + self.echo("*Cancel*"); + self.lisp_abort("Function cancelled"); + return; + } + if self.running.is_some() { + if self.suspended.is_some() { + if let Some(nested) = self.running.take() { + self.finish(nested, true); + } + self.running = self.suspended.take(); + } + let _ = self.feed(Some(Input::Cancel)); + } else { + self.pending_window = None; + self.set_selection(Vec::new()); + } + } + + /// The multiline text the running command is collecting, for the in-place editor + /// ([`Interactive::editing_mtext`]). + pub fn editing_mtext(&self) -> Option { + self.running.as_ref().and_then(|r| r.machine.editing_mtext()) + } + + pub fn current_prompt(&self) -> Option { + if let Some(p) = self.lisp_prompt() { + return Some(p); + } + self.running.as_ref().map(|r| { + let p = r.machine.prompt(self); + match &self.select_mode { + Some(m) if p.accept.select => m.prompt(), + _ => p, + } + }) + } + + /// Whether the cursor picks objects right now, so the UI shows the pick box (PICKBOX) at the + /// crosshair: at prompts that select objects, and with no command running when PICKFIRST is on + /// (noun-verb selection). + pub fn picking_objects(&self) -> bool { + match self.current_prompt() { + Some(p) => p.picks_objects(), + None => self.settings.pickfirst, + } + } + + /// Whether the object under the cursor is highlighted (SELECTIONPREVIEW): the cursor picks + /// objects ([`Self::picking_objects`]) and the bit for a command asking for them (2) or for + /// no command (1) is on. + pub fn previews_selection(&self) -> bool { + let bit = if self.current_prompt().is_some() { 2 } else { 1 }; + self.picking_objects() && self.settings.selectionpreview & bit != 0 + } + + /// Whether selected objects show grips while `selected` objects are selected (GRIPS, + /// GRIPOBJLIMIT). + pub fn shows_grips(&self, selected: usize) -> bool { + let limit = self.settings.gripobjlimit; + self.settings.grips > 0 && (limit == 0 || selected <= limit as usize) + } + + /// The text shown on the command line: the active prompt, or "Command:". + pub fn prompt_text(&self) -> String { + if let Some(p) = self.lisp_prompt() { + return p.display(); + } + match &self.running { + Some(r) if self.suspended.is_some() => format!(">>{} {}", r.id.to_ascii_uppercase(), r.machine.prompt(self).display()), + Some(r) => format!("{} {}", r.id.to_ascii_uppercase(), r.machine.prompt(self).display()), + None => "Command:".into(), + } + } + + /// Preview entities for the cursor: objects drawn with their own properties + /// ([`Interactive::preview_objects`]), then the rubber band with the current properties, in + /// the grip blue over such objects (so it shows over their fills, whatever their colour). + pub fn preview(&self, cursor: Vec2) -> Vec { + let mut common = self.current_common(); + let (kinds, mut out) = match (self.lisp_preview(cursor), &self.running) { + (Some(k), _) => (k, Vec::new()), + (None, Some(r)) => (r.machine.preview(self, cursor), r.machine.preview_objects(self, cursor)), + (None, None) => (Vec::new(), Vec::new()), + }; + if !out.is_empty() { + common.color = cadcraft_doc::color::Color::True(cadcraft_doc::color::Rgb(0x2f, 0x6b, 0xff)); + } + out.extend(kinds.into_iter().map(|k| Entity { handle: Handle(0), common: common.clone(), kind: k })); + out + } + + /// The active command's preview as a transform of existing objects (see + /// [`Interactive::drag`]), with its extra rubber-band geometry as entities. + pub fn drag(&self, cursor: Vec2) -> Option<(Drag, Vec)> { + let r = self.running.as_ref()?; + let d = r.machine.drag(self, cursor)?; + let common = self.current_common(); + let extra = d.extra.iter().map(|k| Entity { handle: Handle(0), common: common.clone(), kind: k.clone() }).collect(); + Some((d, extra)) + } +} diff --git a/crates/engine/src/session/picking.rs b/crates/engine/src/session/picking.rs new file mode 100644 index 000000000..2c7a61108 --- /dev/null +++ b/crates/engine/src/session/picking.rs @@ -0,0 +1,128 @@ +use super::*; + +impl Session { + // ---------------- picking without a command ---------------- + + /// Open a selection window with one corner at `corner`, for press-and-drag selection + /// (AutoCAD's PICKDRAG = 2: a drag opens the window wherever it starts, even over an object; + /// the next point, where the drag ends, closes it). Only while objects are being selected: no + /// command running, or a command asking for objects. Returns whether a window was opened. + pub fn begin_window(&mut self, corner: Vec2) -> bool { + let during_command = self.running.is_some(); + if !self.settings.pickdrag || !corner.is_finite() || (during_command && !self.current_prompt().is_some_and(|p| p.accept.select)) { + return false; + } + self.pending_window = Some(PendingWindow { corner, during_command }); + true + } + + /// A click with no command running: pick/toggle objects or start a selection window. + /// + /// PICKADD on (the default): picks add to the selection and Shift removes. PICKADD off: each pick + /// replaces the selection and Shift adds (Shift+pick on a selected object removes it). + pub fn idle_click(&mut self, p: Vec2, shift: bool) -> Result<()> { + let space = self.space(); + let pickadd = self.settings.pickadd; + if let Some(pw) = self.pending_window.take() { + let crossing = p.x < pw.corner.x; + let hs = select::select_window(self.doc()?, &space, Bounds2::new(pw.corner, p), crossing); + let hs = cmd::groups::expand(self, hs); + let mut sel = if pickadd || shift { self.selection() } else { Vec::new() }; + if shift && pickadd { + sel.retain(|h| !hs.contains(h)); + } else { + sel.extend(hs); + } + self.set_selection(sel); + return Ok(()); + } + let ap = self.pixel_size() * self.settings.pickbox.max(1.0) * 1.5; + match select::pick(self.doc()?, &space, p, ap) { + Some(h) => self.idle_pick(h, shift), + None => self.pending_window = Some(PendingWindow { corner: p, during_command: false }), + } + Ok(()) + } + + /// An idle click that picked `h`, with [`Self::idle_click`]'s PICKADD and Shift rules. + fn idle_pick(&mut self, h: Handle, shift: bool) { + let pickadd = self.settings.pickadd; + // With group selection the object's selectable groups come (or go) with it. + let hs = cmd::groups::expand(self, vec![h]); + let mut sel = if pickadd || shift { self.selection() } else { Vec::new() }; + let had = sel.contains(&h); + if shift && (pickadd || had) { + sel.retain(|x| !hs.contains(x)); + } else if !had { + sel.extend(hs); + } + self.set_selection(sel); + } + + /// Whether a click at `p` selects objects by picking: no command running, or a command at + /// "Select objects" with no selection window or typed mode under way. + pub fn click_picks(&self) -> bool { + self.pending_window.is_none() + && match self.running.as_ref() { + None => self.lisp_prompt().is_none(), + Some(r) => self.select_mode.is_none() && r.machine.prompt(self).accept.select, + } + } + + /// The objects under the pick box at `p` when selection cycling is on and more than one is + /// there: what a click at `p` would choose between. + fn cycling_hits(&self, p: Vec2) -> Vec { + if !self.settings.selection_cycling { + return Vec::new(); + } + let hs = self.pick_candidates(p); + if hs.len() > 1 { hs } else { Vec::new() } + } + + /// Every object under the pick box at `p`, nearest first, when a click there picks objects + /// (empty otherwise): what Shift+Space steps the highlight through. + pub fn pick_candidates(&self, p: Vec2) -> Vec { + if !self.click_picks() { + return Vec::new(); + } + let ap = self.pixel_size() * self.settings.pickbox.max(1.0) * 1.5; + self.doc().map(|d| select::pick_all(d, &self.space(), p, ap)).unwrap_or_default() + } + + /// Whether selection cycling marks `p` with its badge (SELECTIONCYCLING 1 or 2): overlapping + /// objects a click there would choose between. + pub fn cycling_badge(&self, p: Vec2) -> bool { + self.settings.selection_cycling_display >= 1 && !self.cycling_hits(p).is_empty() + } + + /// The objects a click at `p` lists to choose from (SELECTIONCYCLING 2), nearest first; empty + /// when the click should pick as usual. + pub fn cycling_candidates(&self, p: Vec2) -> Vec { + if self.settings.selection_cycling_display < 2 { + return Vec::new(); + } + self.cycling_hits(p) + } + + /// Pick `h` from the selection cycling list, as a click on it alone would have: into the + /// command at "Select objects", else into the selection (Shift as for [`Self::idle_click`]). + pub fn pick_cycled(&mut self, h: Handle, shift: bool) -> Result<()> { + // The list may be older than an undo or an erase. + if self.doc()?.entity(h).is_none() { + return Ok(()); + } + if self.running.is_some() { + let hs = cmd::groups::expand(self, vec![h]); + return self.input(Input::Pick(hs)); + } + self.idle_pick(h, shift); + Ok(()) + } + + /// Remember the selection used by a command as "Previous". + pub fn remember_selection(&mut self, sel: &[Handle]) { + if let Ok(st) = self.state_mut() { + st.previous_selection = sel.to_vec(); + } + } +} diff --git a/crates/engine/src/session/zoom.rs b/crates/engine/src/session/zoom.rs new file mode 100644 index 000000000..68be05a38 --- /dev/null +++ b/crates/engine/src/session/zoom.rs @@ -0,0 +1,44 @@ +use super::*; + +impl Session { + // ---------------- views ---------------- + + /// The limits of the space being edited: the sheet in paper space, else LIMMIN/LIMMAX. + pub fn zoom_limits(&self) -> Result { + let d = self.doc()?; + if let Space::Paper(name) = self.space() + && let Some(sheet) = cadcraft_render::sheet(d, &name) + { + return Ok(sheet.bounds()); + } + let lo = d.header.point("LIMMIN").map(|p| p.xy()).unwrap_or(Vec2::ZERO); + let hi = d.header.point("LIMMAX").map(|p| p.xy()).unwrap_or(Vec2::new(12.0, 9.0)); + Ok(Bounds2::new(lo, hi)) + } + + pub fn zoom_extents(&mut self) -> Result<()> { + let space = self.space(); + let ext = self.doc()?.extents(&space); + let (w, h) = self.viewport_px; + let ext = if ext.is_empty() { self.zoom_limits()? } else { ext }; + let st = self.state_mut()?; + let aspect = st.zoom_aspect(w / h.max(1.0)); + let height = ext.height().max(ext.width() / aspect.max(1e-6)).max(1e-6) * 1.05; + st.set_zoom_frame(View { center: ext.center(), height }); + Ok(()) + } + + /// Zoom by `factor` (> 1 zooms in) keeping `about` fixed on screen. + pub fn zoom_about(&mut self, factor: f64, about: Vec2) -> Result<()> { + if !(factor.is_finite() && factor > 0.0) { + return Ok(()); + } + let st = self.state_mut()?; + let v = st.view(); + let h = (v.height / factor).clamp(1e-9, 1e12); + let k = h / v.height; + let c = about + (v.center - about) * k; + st.set_view_quiet(View { center: c, height: h }); + Ok(()) + } +} diff --git a/crates/engine/src/settings.rs b/crates/engine/src/settings.rs new file mode 100644 index 000000000..de936b0c7 --- /dev/null +++ b/crates/engine/src/settings.rs @@ -0,0 +1,196 @@ +use super::*; + +/// Session-level drafting settings. [`Session::drafting_settings`] supplies the active drawing's +/// snap/grid spacing in the corresponding fields for UI and JSON consumers. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Settings { + pub osmode: u32, + pub orthomode: bool, + pub polarmode: bool, + /// POLARANG (radians). + pub polarang: f64, + pub gridmode: bool, + pub snapmode: bool, + /// SNAPUNIT in a drafting settings snapshot; stored in the drawing header. + pub snapunit: Vec2, + /// OSNAPHATCH: object snaps find hatch objects (off by default, as in AutoCAD). + pub osnaphatch: bool, + /// GRIDUNIT in a drafting settings snapshot; zero components follow SNAPUNIT. + pub gridunit: Vec2, + /// Major grid line every N minor lines (GRIDMAJOR). + pub gridmajor: u32, + /// ZOOMFACTOR: how far one mouse wheel step zooms (3..=100). + pub zoomfactor: u32, + /// MEASUREINIT: the units of a new drawing made without a template (NEW, QNEW and the first + /// drawing at startup): `false` (0, the default) imperial, `true` (1) metric. + pub measureinit: bool, + /// -VIEW sEttings > Layer snapshot: named views save the layer settings with them. + pub view_layer_snapshot: bool, + pub dynmode: bool, + /// DYNDIGRIP: which dimensions grip dimensional input shows while a grip is stretched (bits: + /// 1 resulting length, 2 length change, 4 absolute angle, 8 angle change, 16 radius); read + /// when DYNDIVIS = 2. + pub dyndigrip: u32, + /// DYNDIVIS: how many of them: 0 the first, 1 the first two (default), 2 all DYNDIGRIP asks for. + pub dyndivis: u8, + /// DYNPIFORMAT = 1: Dynamic Input shows second and next points as Cartesian `x,y` instead of + /// polar `distance, + /// SNAPTYPE 1: snap mode (F9) snaps along tracking paths at POLARDIST steps (PolarSnap) + /// instead of to the grid. + pub polarsnap: bool, + /// POLARDIST: the PolarSnap step; 0 uses the snap X spacing. + pub polardist: f64, + /// AUDITCTL: 1 creates an audit report (.adt) file when AUDIT is run (default: 0). + pub auditctl: bool, +} + +impl Default for Settings { + fn default() -> Self { + Settings { + osmode: 4133, + orthomode: false, + polarmode: true, + polarang: std::f64::consts::FRAC_PI_2, + gridmode: true, + snapmode: false, + snapunit: Vec2::new(0.5, 0.5), + osnaphatch: false, + gridunit: Vec2::new(0.5, 0.5), + gridmajor: 5, + zoomfactor: 60, + measureinit: false, + view_layer_snapshot: true, + dynmode: true, + dyndigrip: 31, + dyndivis: 1, + dynpi_cartesian: false, + dynpi_absolute: false, + dynprompt: true, + lwdisplay: false, + transparency_display: true, + plot_transparency_override: 1, + selection_cycling: false, + selection_cycling_display: 2, + selectsimilarmode: 130, + otrack: true, + pickbox: 3.0, + aperture: 10.0, + pickfirst: true, + acadlspasdoc: false, + cmdecho: true, + filedia: true, + pickadd: true, + pickdrag: true, + pickstyle: 1, + gripsize: 5.0, + cursorsize: 5.0, + maxarray: 100_000, + isodraft: false, + isoplane: snap::iso::IsoPlane::Left, + annoallvisible: true, + annoautoscale: false, + qpmode: false, + highlight: true, + selectioneffect: 1, + selectionpreview: 3, + previeweffect: 2, + previewfilter: 3, + grips: 1, + gripobjlimit: 100, + selectionarea: true, + windowareacolor: 150, + crossingareacolor: 100, + selectionareaopacity: 25, + polar_flags: 0, + polaraddang: Vec::new(), + polarsnap: false, + polardist: 0.0, + auditctl: false, + } + } +} diff --git a/crates/engine/src/view.rs b/crates/engine/src/view.rs new file mode 100644 index 000000000..9fd1e3f79 --- /dev/null +++ b/crates/engine/src/view.rs @@ -0,0 +1,40 @@ +use super::*; + +/// A 2D view of a space: world centre and visible height in drawing units. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct View { + pub center: Vec2, + pub height: f64, +} + +impl Default for View { + fn default() -> Self { + View { center: Vec2::new(6.0, 4.5), height: 10.0 } + } +} + +pub(crate) fn usable_view(v: &View) -> bool { + v.center.is_finite() && v.height.is_finite() && v.height > 1e-12 +} + +/// Tiled model viewports (VPORTS): the session side of the drawing's `*Active` configuration. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ModelTiles { + /// The tile corners these views belong to (when the drawing's tiles change, by VPORTS or + /// undo, its views take over). + pub rects: Vec<(Vec2, Vec2)>, + /// Each tile's view; the current tile shows the model space view itself. + pub views: Vec, + /// The current tile. + pub active: usize, +} + +/// One tile of the model space drawing area, for the UI ([`Session::model_tiles`]): its corners +/// as fractions of the drawing area (0,0 lower left) and its view. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ModelTile { + pub min: Vec2, + pub max: Vec2, + pub view: View, + pub active: bool, +} diff --git a/crates/engine/tests/it/block_description.rs b/crates/engine/tests/it/block_description.rs new file mode 100644 index 000000000..8a0e5a81f --- /dev/null +++ b/crates/engine/tests/it/block_description.rs @@ -0,0 +1,211 @@ +//! Block Description (blocks.description): a block's description changes in the drawing, and in +//! the Block Editor BSAVE saves the edited block's new description. + +use cadcraft_engine::Session; +use serde_json::{Value, json}; + +/// A drawing with block "Door" (one line, described "Hinged door") and one reference to it, which +/// is returned. +fn door(s: &mut Session) -> Value { + door_with(s, false) +} + +fn door_with(s: &mut Session, annotative: bool) -> Value { + let l = s.execute("line", &json!({ "points": [[10, 10], [12, 10]] })).unwrap()["handles"][0].clone(); + let p = json!({ "name": "Door", "base": [10, 10], "handles": [l], "description": "Hinged door", "annotative": annotative }); + s.execute("block", &p).unwrap()["insert"].clone() +} + +fn description(s: &Session, block: &str) -> Option { + s.doc().unwrap().block(block).map(|b| b.description.clone()) +} + +/// The description of `block` in the drawing outside the Block Editor. +fn saved_description(s: &Session, block: &str) -> Option { + let st = s.state().unwrap(); + let d = st.block_edit.as_ref().map_or(&st.doc, |be| &be.host.doc); + d.block(block).map(|b| b.description.clone()) +} + +#[test] +fn the_description_of_a_block_changes_and_undo_puts_it_back() { + let mut s = Session::new(); + door(&mut s); + let r = s.execute("blocks.description", &json!({ "name": "door", "description": "Sliding door" })).unwrap(); + assert_eq!(r, json!({ "block": "Door", "description": "Sliding door" })); + assert_eq!(description(&s, "Door").as_deref(), Some("Sliding door")); + s.execute("undo", &json!({})).unwrap(); + assert_eq!(description(&s, "Door").as_deref(), Some("Hinged door")); + + // An empty description clears it; 255 characters are the most a description takes. + s.execute("blocks.description", &json!({ "name": "Door", "description": "" })).unwrap(); + assert_eq!(description(&s, "Door").as_deref(), Some("")); + let longest = "é".repeat(255); + s.execute("blocks.description", &json!({ "name": "Door", "description": longest })).unwrap(); + assert_eq!(description(&s, "Door"), Some(longest)); +} + +#[test] +fn what_block_description_refuses() { + let mut s = Session::new(); + door(&mut s); + for p in [ + json!({ "name": "Door", "description": "x".repeat(256) }), + json!({ "name": "Door", "description": "two\nlines" }), + json!({ "name": "Door", "description": "carriage\rreturn" }), + json!({ "name": "Window", "description": "x" }), + json!({ "name": "Door" }), + json!({ "name": "Door", "description": 7 }), + json!({ "description": "nothing selected" }), + ] { + assert!(s.execute("blocks.description", &p).is_err(), "{p}"); + } + assert_eq!(description(&s, "Door").as_deref(), Some("Hinged door")); +} + +#[test] +fn block_refuses_a_description_a_block_cannot_take() { + let mut s = Session::new(); + for cmd in ["block", "-block"] { + for description in ["x".repeat(256), "two\nlines".into()] { + let l = s.execute("line", &json!({ "points": [[0, 0], [1, 0]] })).unwrap()["handles"][0].clone(); + let p = json!({ "name": "B", "base": [0, 0], "handles": [l], "description": description }); + assert!(s.execute(cmd, &p).is_err(), "{cmd} {description:?}"); + assert!(s.doc().unwrap().block("B").is_none()); + } + } + for (cmd, name) in [("block", "B"), ("-block", "C")] { + let l = s.execute("line", &json!({ "points": [[0, 0], [1, 0]] })).unwrap()["handles"][0].clone(); + s.execute(cmd, &json!({ "name": name, "base": [0, 0], "handles": [l], "description": "x".repeat(255) })).unwrap(); + assert_eq!(description(&s, name), Some("x".repeat(255)), "{cmd}"); + } +} + +#[test] +fn the_selected_reference_names_the_block_at_the_prompts() { + let mut s = Session::new(); + let r = door(&mut s); + s.execute("select", &json!({ "handles": [r] })).unwrap(); + s.cmdline("blocks.description").unwrap(); + assert!(s.prompt_text().contains("Enter description for block \"Door\" "), "{}", s.prompt_text()); + // Enter keeps the description. + s.cmdline("").unwrap(); + assert!(s.running.is_none()); + assert_eq!(description(&s, "Door").as_deref(), Some("Hinged door")); + + s.execute("select", &json!({ "handles": [] })).unwrap(); + s.script("blocks.description\nDoor\nSliding door\n").unwrap(); + assert!(s.running.is_none()); + assert_eq!(description(&s, "Door").as_deref(), Some("Sliding door")); +} + +#[test] +fn the_block_editor_saves_the_new_description_with_the_block() { + let mut s = Session::new(); + door(&mut s); + s.execute("bedit", &json!({ "name": "Door" })).unwrap(); + s.execute("blocks.description", &json!({ "description": "Sliding door" })).unwrap(); + assert!(s.state().unwrap().block_unsaved()); + assert_eq!(saved_description(&s, "Door").as_deref(), Some("Hinged door")); + s.execute("bsave", &json!({})).unwrap(); + assert_eq!(saved_description(&s, "Door").as_deref(), Some("Sliding door")); + + // Closing without saving leaves the drawing's description as it was. + s.execute("blocks.description", &json!({ "description": "Pocket door" })).unwrap(); + s.execute("bclose", &json!({ "save": false })).unwrap(); + assert_eq!(description(&s, "Door").as_deref(), Some("Sliding door")); +} + +#[test] +fn bsaveas_gives_the_new_description_to_the_new_block_only() { + let mut s = Session::new(); + door_with(&mut s, true); + s.execute("bedit", &json!({ "name": "Door" })).unwrap(); + s.execute("blocks.description", &json!({ "description": "Sliding door" })).unwrap(); + s.execute("bsaveas", &json!({ "name": "Slider" })).unwrap(); + assert_eq!(saved_description(&s, "Slider").as_deref(), Some("Sliding door")); + assert_eq!(saved_description(&s, "Door").as_deref(), Some("Hinged door")); + + // The editor now edits the new block; saving again leaves the old block's description alone + // and keeps the new block's other properties. + s.execute("blocks.description", &json!({ "description": "Sliding door, 900" })).unwrap(); + s.execute("bclose", &json!({})).unwrap(); + assert_eq!(description(&s, "Slider").as_deref(), Some("Sliding door, 900")); + assert_eq!(description(&s, "Door").as_deref(), Some("Hinged door")); + assert!(s.doc().unwrap().block("Slider").unwrap().annotative); +} + +#[test] +fn bsaveas_over_another_block_keeps_the_saved_description() { + let mut s = Session::new(); + door(&mut s); + let l = s.execute("line", &json!({ "points": [[0, 0], [1, 0]] })).unwrap()["handles"][0].clone(); + s.execute("block", &json!({ "name": "Window", "base": [0, 0], "handles": [l], "description": "Casement" })).unwrap(); + s.execute("bedit", &json!({ "name": "Door" })).unwrap(); + s.execute("blocks.description", &json!({ "description": "Sliding door" })).unwrap(); + s.execute("bsaveas", &json!({ "name": "Window", "replace": true })).unwrap(); + assert_eq!(saved_description(&s, "Window").as_deref(), Some("Sliding door")); + // The prompt shows the saved description, and a later save keeps it. + s.cmdline("blocks.description").unwrap(); + assert!(s.prompt_text().contains(""), "{}", s.prompt_text()); + s.cmdline("").unwrap(); + s.execute("line", &json!({ "points": [[0, 0], [0, 1]] })).unwrap(); + s.execute("bclose", &json!({})).unwrap(); + assert_eq!(description(&s, "Window").as_deref(), Some("Sliding door")); + assert_eq!(description(&s, "Door").as_deref(), Some("Hinged door")); + + // An undo in the editor after saving over Window leaves Window's new description. + s.execute("bedit", &json!({ "name": "Door" })).unwrap(); + s.execute("blocks.description", &json!({ "description": "Pocket door" })).unwrap(); + s.execute("line", &json!({ "points": [[0, 0], [0, 2]] })).unwrap(); + s.execute("bsaveas", &json!({ "name": "Window", "replace": true })).unwrap(); + s.execute("undo", &json!({})).unwrap(); + s.execute("bsave", &json!({})).unwrap(); + s.execute("bclose", &json!({})).unwrap(); + assert_eq!(description(&s, "Window").as_deref(), Some("Pocket door")); + assert_eq!(description(&s, "Door").as_deref(), Some("Hinged door")); +} + +#[test] +fn undo_after_bsaveas_does_not_give_the_old_block_the_new_description() { + let mut s = Session::new(); + door(&mut s); + s.execute("bedit", &json!({ "name": "Door" })).unwrap(); + s.execute("blocks.description", &json!({ "description": "Sliding door" })).unwrap(); + s.execute("line", &json!({ "points": [[0, 0], [0, 3]] })).unwrap(); + s.execute("bsaveas", &json!({ "name": "Slider" })).unwrap(); + // Undo goes back past the line to the editor's drawing as it was before BSAVEAS. + s.execute("undo", &json!({})).unwrap(); + s.execute("bsave", &json!({})).unwrap(); + assert_eq!(saved_description(&s, "Door").as_deref(), Some("Hinged door")); + assert_eq!(saved_description(&s, "Slider").as_deref(), Some("Sliding door")); + + // In the editor, only the edited block's description changes. + assert!(s.execute("blocks.description", &json!({ "name": "Door", "description": "x" })).is_err()); + s.execute("bclose", &json!({})).unwrap(); + assert_eq!(description(&s, "Door").as_deref(), Some("Hinged door")); + assert_eq!(description(&s, "Slider").as_deref(), Some("Sliding door")); +} + +#[test] +fn a_new_block_in_the_block_editor_takes_its_description() { + let mut s = Session::new(); + s.execute("bedit", &json!({ "name": "Table", "create": true })).unwrap(); + s.execute("line", &json!({ "points": [[0, 0], [5, 0]] })).unwrap(); + s.execute("blocks.description", &json!({ "description": "Desk" })).unwrap(); + assert_eq!(saved_description(&s, "Table"), None); + + // Saved under another name first, the started block is not created empty. + s.execute("bsaveas", &json!({ "name": "Desk" })).unwrap(); + assert_eq!(saved_description(&s, "Desk").as_deref(), Some("Desk")); + assert_eq!(saved_description(&s, "Table"), None); + s.execute("bclose", &json!({})).unwrap(); + assert!(s.doc().unwrap().block("Table").is_none()); + + s.execute("bedit", &json!({ "name": "Chair", "create": true })).unwrap(); + s.execute("line", &json!({ "points": [[0, 0], [1, 0]] })).unwrap(); + s.execute("blocks.description", &json!({ "name": "chair", "description": "Office chair" })).unwrap(); + s.execute("bclose", &json!({})).unwrap(); + assert_eq!(description(&s, "Chair").as_deref(), Some("Office chair")); + assert_eq!(s.doc().unwrap().block("Chair").unwrap().entities.len(), 1); +} diff --git a/crates/engine/tests/it/inquiry_keywords.rs b/crates/engine/tests/it/inquiry_keywords.rs index 5a2c4cb6e..b6f78c005 100644 --- a/crates/engine/tests/it/inquiry_keywords.rs +++ b/crates/engine/tests/it/inquiry_keywords.rs @@ -34,6 +34,45 @@ fn area_object_add_and_subtract_keep_a_running_total() { assert!(s.running.is_none(), "{}", s.prompt_text()); } +#[test] +fn area_too_large_to_hold_is_refused() { + // A 2e154 square has an area of 4e308, past f64's range; its perimeter fits. + let big = json!([[0, 0], [2e154, 0], [2e154, 2e154], [0, 2e154]]); + let mut s = Session::new(); + let err = s.execute("area", &json!({ "points": big })).unwrap_err(); + assert!(err.to_string().contains("the area is too large"), "{err}"); + // Two sides of 1e308: the area fits, the perimeter does not. + let long = json!([[0, 0], [1e308, 0], [1e308, 1e-300]]); + let err = s.execute("area", &json!({ "points": long })).unwrap_err(); + assert!(err.to_string().contains("the perimeter is too large"), "{err}"); + + // Typed, nothing is reported. + let (_, log) = run(&["area", "0,0", "2e154,0", "2e154,2e154", "0,2e154", ""]); + assert!(has(&log, "The area is too large."), "{log:?}"); + assert!(!has(&log, "Area = "), "{log:?}"); + + // Areas of 8.1e307 that each fit overflow the running total at the third; the total keeps two. + let sq = ["0,0", "9e153,0", "9e153,9e153", "0,9e153", ""]; + for mode in ["a", "s"] { + let mut lines = vec!["area", mode]; + for _ in 0..3 { + lines.extend(sq); + } + let (s, log) = run(&lines); + assert!(has(&log, "The total area is too large."), "{mode}: {log:?}"); + assert_eq!(log.iter().filter(|l| l.contains("Total area = ")).count(), 2, "{mode}: {log:?}"); + assert!(s.running.is_some(), "{mode}: the command goes on"); + } + + // MEASUREGEOM's Volume option does not keep such an area waiting for a height: the next area is measured. + let mut lines = vec!["measuregeom", "v", "a", "0,0", "2e154,0", "2e154,2e154", "0,2e154", ""]; + lines.extend(["0,0", "1,0", "1,1", "0,1", "", "2"]); + let (s, log) = run(&lines); + assert!(has(&log, "The area is too large."), "{log:?}"); + assert!(has(&log, "Total volume = 2"), "{log:?}"); + assert!(s.running.is_some(), "the command goes on"); +} + #[test] fn area_object_on_an_open_polyline_closes_the_area_but_not_the_perimeter() { // Open, 160 long; the closing side back to 0,0 would add 116.619. @@ -42,10 +81,7 @@ fn area_object_on_an_open_polyline_closes_the_area_but_not_the_perimeter() { } #[test] -fn area_unsupported_keywords_say_so_and_length_extends_the_last_segment() { - let (s, log) = run(&["area", "0,0", "a"]); - assert!(has(&log, "not available yet"), "{log:?}"); - assert!(s.running.is_some()); +fn area_length_extends_the_last_segment() { // Length continues along the previous segment: 0,0 -> 4,0 -> +6 -> 10,0, then 10,10. let (_, log) = run(&["area", "0,0", "4,0", "l", "6", "10,10", ""]); assert!(has(&log, "Area = 50.0000"), "{log:?}"); @@ -58,6 +94,91 @@ fn dist_multiple_points_sums_the_segments() { assert!(s.running.is_none(), "{}", s.prompt_text()); } +#[test] +fn area_arc_option_measures_arc_segments() { + // A 10 x 10 square whose right side is a half circle of radius 5 bulging out: + // area 100 + 12.5 pi, perimeter 30 + 5 pi. + let expected = "Area = 139.2699, Perimeter = 45.7080"; + + // Arc mode: a point ends an arc that continues the last segment; Line goes back to lines. + let (s, _) = run(&["area", "0,0", "10,0", "a"]); + assert!(s.prompt_text().contains("Specify endpoint of arc"), "{}", s.prompt_text()); + assert!(s.prompt_text().contains("CEnter"), "{}", s.prompt_text()); + let (s, log) = run(&["area", "0,0", "10,0", "a", "10,10", "l", "0,10", ""]); + assert!(has(&log, expected), "{log:?}"); + assert!(s.running.is_none(), "{}", s.prompt_text()); + + // The same half circle by its center, by its included angle and by a second point. + for arc in [&["ce", "10,5", "10,10"][..], &["a", "180", "10,10"], &["s", "15,5", "10,10"]] { + let mut lines = vec!["area", "0,0", "10,0", "a"]; + lines.extend_from_slice(arc); + lines.extend(["l", "0,10", ""]); + let (_, log) = run(&lines); + assert!(has(&log, expected), "{arc:?}: {log:?}"); + } + + // After the arc, Length continues along its end tangent (-X): 10 to 0,10. + let (_, log) = run(&["area", "0,0", "10,0", "a", "10,10", "l", "l", "10", ""]); + assert!(has(&log, expected), "{log:?}"); + + // Undo removes the arc segment; arc mode stays on. + let (s, _) = run(&["area", "0,0", "10,0", "a", "10,10", "u"]); + assert!(s.prompt_text().contains("Specify endpoint of arc"), "{}", s.prompt_text()); + + // CLose ends with an arc back to the first point: center 0,10, radius 10, 270 degrees. + let (s, log) = run(&["area", "0,0", "10,0", "10,10", "a", "cl"]); + assert!(has(&log, "Area = 335.6194, Perimeter = 67.1239"), "{log:?}"); + assert!(s.running.is_none(), "{}", s.prompt_text()); + // No arc continues the last segment back to the first point when it lies straight behind. + let (s, log) = run(&["area", "0,0", "10,0", "a", "cl"]); + assert!(has(&log, "Invalid arc endpoint."), "{log:?}"); + assert!(s.prompt_text().contains("Specify endpoint of arc"), "{}", s.prompt_text()); + + // Add mode keeps its prefix in arc mode and adds the curved area to the total. + let (s, _) = run(&["area", "a", "0,0", "10,0", "a"]); + assert!(s.prompt_text().contains("(ADD mode) Specify endpoint of arc"), "{}", s.prompt_text()); + let (_, log) = run(&["area", "a", "0,0", "10,0", "a", "10,10", "l", "0,10", ""]); + assert!(has(&log, "Total area = 139.2699"), "{log:?}"); + + // MEASUREGEOM's Volume option measures the same area times the height. + let (_, log) = run(&["measuregeom", "v", "0,0", "10,0", "a", "10,10", "l", "0,10", "", "2"]); + assert!(has(&log, "Volume = 278.5398"), "{log:?}"); +} + +#[test] +fn dist_multiple_points_arc_option_adds_arc_lengths() { + // 10 along X, then a half circle of radius 5 up to 10,10: 10 + 5 pi. + let (s, log) = run(&["dist", "0,0", "m", "10,0", "a", "10,10"]); + assert!(has(&log, "Distance = 25.7080"), "{log:?}"); + assert!(s.prompt_text().contains("Specify endpoint of arc"), "{}", s.prompt_text()); + + // Line, then Length continues along the tangent at the arc's end (-X): 5 more. + let (s, log) = run(&["dist", "0,0", "m", "10,0", "a", "10,10", "l", "l", "5", ""]); + assert!(has(&log, "Distance = 30.7080"), "{log:?}"); + assert!(s.running.is_none(), "{}", s.prompt_text()); + + // Undo removes the arc segment. + let (_, log) = run(&["dist", "0,0", "m", "10,0", "a", "10,10", "u", ""]); + assert_eq!(log.iter().filter(|l| l.contains("Distance = 10.0000")).count(), 2, "{log:?}"); + + // CLose adds an arc back to the first point and ends: 20 + 15 pi. + let (s, log) = run(&["dist", "0,0", "m", "10,0", "10,10", "a", "cl"]); + assert!(has(&log, "Distance = 67.1239"), "{log:?}"); + assert!(s.running.is_none(), "{}", s.prompt_text()); + + // CLose needs a second point. + let (s, log) = run(&["dist", "0,0", "m", "a", "cl"]); + assert!(has(&log, "CLose needs at least two points."), "{log:?}"); + assert!(s.running.is_some()); + + // No arc continues the last segment to a point straight behind it. + for end in ["5,0", "cl"] { + let (s, log) = run(&["dist", "0,0", "m", "10,0", "a", end]); + assert!(has(&log, "Invalid arc endpoint."), "{end}: {log:?}"); + assert!(s.prompt_text().contains("Specify endpoint of arc"), "{end}: {}", s.prompt_text()); + } +} + #[test] fn area_of_an_open_elliptical_arc_is_refused_and_a_full_ellipse_is_measured() { for end in [90, 45] { diff --git a/crates/engine/tests/it/layout_prompts.rs b/crates/engine/tests/it/layout_prompts.rs index 775230541..afa784e0b 100644 --- a/crates/engine/tests/it/layout_prompts.rs +++ b/crates/engine/tests/it/layout_prompts.rs @@ -39,7 +39,7 @@ fn typed_layout_options_prompt_for_names() { s.script("LAYOUT\n\nModel\n").unwrap(); assert_eq!(s.layout_space(), Space::Model); - // ? lists; Template and SAveas report and end without taking the next input. + // ? lists; Template reports and ends without taking the next input. let n = s.log.len(); s.cmdline("LAYOUT ? Sheet*").unwrap(); assert!(s.log[n..].iter().any(|l| l == "Layout: Sheet A (2)"), "{:?}", &s.log[n..]); diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index 8babc2c49..c4c4a1ce6 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -10,6 +10,7 @@ mod attedit_prompts; mod attrib_modes; mod attrib_props; mod attribute_tools; +mod block_description; mod block_keep; mod block_redefine; mod blockreplace; @@ -75,6 +76,7 @@ mod modify_curves; mod mtext_columns; mod mtext_options; mod multiline_attributes; +mod named_viewports; mod named_views; mod non_finite_edits; mod offset_closed_spline; diff --git a/crates/engine/tests/it/measure_distance_snapping.rs b/crates/engine/tests/it/measure_distance_snapping.rs index adb365774..9842b65e0 100644 --- a/crates/engine/tests/it/measure_distance_snapping.rs +++ b/crates/engine/tests/it/measure_distance_snapping.rs @@ -161,6 +161,99 @@ fn multiple_points_length_and_undo_keep_the_measurement_units() { assert_eq!(s.log.last().unwrap(), "Distance = 20.0000"); } +#[test] +fn multiple_points_arc_options_keep_the_measurement_units() { + let (d, _) = drawing(); + let mut s = session(d); + s.start("dist").unwrap(); + s.input_snapped(pick(&s, Vec2::new(95.0, 50.0), mode::END)).unwrap(); + s.input(Input::Keyword("Multiple points".into())).unwrap(); + s.input_snapped(pick(&s, Vec2::new(100.0, 50.0), mode::INT)).unwrap(); + // A half circle of radius 2.5 on paper, ended by its included angle, is radius 5 in the + // model: 10 + 5 pi. + s.input(Input::Keyword("Arc".into())).unwrap(); + s.input(Input::Keyword("CEnter".into())).unwrap(); + s.input(Input::Point(Vec2::new(100.0, 52.5))).unwrap(); + s.input(Input::Keyword("Angle".into())).unwrap(); + s.input(Input::Text("180".into())).unwrap(); + assert_eq!(s.log.last().unwrap(), "Distance = 25.7080"); + let base = s.current_prompt().unwrap().base.unwrap(); + assert!(base.near(Vec2::new(100.0, 55.0), 1e-9), "the path stays in paper coordinates: {base:?}"); + s.input(Input::Enter).unwrap(); + assert_eq!(s.log.last().unwrap(), "Distance = 25.7080"); + + // A typed radius is in model units: 5 is 2.5 on paper, the same half circle. + let start = |s: &mut Session| { + s.start("dist").unwrap(); + s.input_snapped(pick(s, Vec2::new(95.0, 50.0), mode::END)).unwrap(); + s.input(Input::Keyword("Multiple points".into())).unwrap(); + s.input_snapped(pick(s, Vec2::new(100.0, 50.0), mode::INT)).unwrap(); + s.input(Input::Keyword("Arc".into())).unwrap(); + }; + start(&mut s); + for i in [Input::Keyword("Radius".into()), Input::Text("5".into()), Input::Keyword("Angle".into()), Input::Text("180".into()), Input::Enter] { + s.input(i).unwrap(); + } + assert_eq!(s.log.last().unwrap(), "Distance = 25.7080"); + + // A snapped end point off the circle gives the direction of the end: the model end is the + // arc's, not the snapped point (0,0). Radius 5 from angle -90 to the angle of (-2,-1). + start(&mut s); + s.input(Input::Keyword("CEnter".into())).unwrap(); + s.input(Input::Point(Vec2::new(100.0, 52.5))).unwrap(); + s.input_snapped(pick(&s, Vec2::new(95.0, 50.0), mode::END)).unwrap(); + let sweep = (-1.0f64).atan2(-2.0) + std::f64::consts::FRAC_PI_2 + std::f64::consts::TAU; + assert_eq!(s.log.last().unwrap(), &format!("Distance = {:.4}", 10.0 + 5.0 * sweep)); + s.input(Input::Enter).unwrap(); + + // A typed end point is a paper point, as in line mode: the path is measured in paper units. + start(&mut s); + s.input(Input::Keyword("CEnter".into())).unwrap(); + s.input(Input::Point(Vec2::new(100.0, 52.5))).unwrap(); + s.input(Input::Point(Vec2::new(100.0, 55.0))).unwrap(); + assert_eq!(s.log.last().unwrap(), "Distance = 12.8540"); +} + +#[test] +fn an_arc_end_picked_through_another_viewport_uses_paper_units() { + let (mut d, paper) = drawing(); + let mut vp = match &d.entity(viewport(&d, &paper)).unwrap().kind { + EntityKind::Viewport(vp) => vp.clone(), + _ => panic!("viewport fixture"), + }; + vp.center.x = 120.0; + vp.view_height = 20.0; + vp.id = 3; + d.add(&paper, Common::default(), EntityKind::Viewport(vp)).unwrap(); + let mut s = session(d); + let mut measure = |snap: bool| { + s.start("dist").unwrap(); + let first = pick(&s, Vec2::new(95.0, 50.0), mode::END); + let second = pick(&s, Vec2::new(100.0, 50.0), mode::INT); + let end = pick(&s, Vec2::new(110.0, 50.0), mode::END); + assert_ne!(first.viewport.unwrap().viewport, end.viewport.unwrap().viewport); + for (n, hit) in [first, second].into_iter().enumerate() { + if n == 1 { + s.input(Input::Keyword("Multiple points".into())).unwrap(); + } + if snap { s.input_snapped(hit).unwrap() } else { s.input(Input::Point(hit.point)).unwrap() } + } + s.input(Input::Keyword("Arc".into())).unwrap(); + s.input(Input::Keyword("Second pt".into())).unwrap(); + s.input(Input::Point(Vec2::new(105.0, 52.0))).unwrap(); + if snap { + s.input_snapped(end).unwrap() + } else { + s.input(Input::Point(end.point)).unwrap() + } + s.input(Input::Enter).unwrap(); + s.log.last().unwrap().clone() + }; + let typed = measure(false); + assert_eq!(typed, "Distance = 16.0347", "5 along X, then the arc through 105,52 to 110,50"); + assert_eq!(measure(true), typed); +} + #[test] fn explicit_endpoint_and_intersection_overrides_work_from_the_command_line() { let (d, _) = drawing(); diff --git a/crates/engine/tests/it/named_viewports.rs b/crates/engine/tests/it/named_viewports.rs new file mode 100644 index 000000000..03ebe9ea4 --- /dev/null +++ b/crates/engine/tests/it/named_viewports.rs @@ -0,0 +1,101 @@ +//! View > Viewports > Named Viewports...: the saved model space viewport configurations, the one +//! the Model tab shows, and restoring or deleting one (its tiles in the Model tab, layout +//! viewports in a layout). + +use cadcraft_engine::Session; +use cadcraft_engine::doc::{EntityKind, Space}; +use serde_json::json; + +/// A drawing with "Two" (two tiles side by side) and "Quad" (four tiles) saved, showing one view. +fn session() -> Session { + let mut s = Session::new(); + s.viewport_px = (1200.0, 800.0); + s.execute("line", &json!({ "points": [[0, 0], [100, 50]] })).unwrap(); + s.execute("-vports", &json!({ "option": "2", "arrangement": "vertical" })).unwrap(); + s.execute("-vports", &json!({ "option": "save", "name": "Two" })).unwrap(); + s.execute("-vports", &json!({ "option": "4", "applyTo": "display" })).unwrap(); + s.execute("-vports", &json!({ "option": "save", "name": "Quad" })).unwrap(); + s.execute("-vports", &json!({ "option": "single" })).unwrap(); + s +} + +fn rects(s: &Session) -> Vec<[f64; 4]> { + let mut r: Vec<[f64; 4]> = s.model_tiles().iter().map(|t| [t.min.x, t.min.y, t.max.x, t.max.y]).collect(); + r.sort_by(|a, b| a.partial_cmp(b).unwrap()); + r +} + +fn layout_viewports(s: &Session) -> usize { + let d = s.doc().unwrap(); + d.space(&Space::Paper("Layout1".into())).unwrap().iter().filter(|e| matches!(e.kind, EntityKind::Viewport(_))).count() +} + +#[test] +fn named_viewports_lists_restores_and_deletes_in_the_model_tab() { + let mut s = session(); + let list = s.execute("vports.named", &json!({})).unwrap(); + assert_eq!(list["configurations"], json!([{ "name": "Two", "viewports": 2 }, { "name": "Quad", "viewports": 4 }])); + assert_eq!(list["current"], json!(null), "one view matches no saved configuration"); + assert_eq!(s.viewport_configuration(), None); + + s.execute("vports.named", &json!({ "name": "two" })).unwrap(); + assert_eq!(rects(&s), vec![[0.0, 0.0, 0.5, 1.0], [0.5, 0.0, 1.0, 1.0]]); + assert_eq!(s.viewport_configuration().as_deref(), Some("Two")); + assert_eq!(s.execute("vports.named", &json!({})).unwrap()["current"], json!("Two")); + s.undo().unwrap(); + assert!(rects(&s).is_empty(), "restoring is one undo step"); + + let r = s.execute("vports.named", &json!({ "name": "QUAD", "delete": true })).unwrap(); + assert_eq!(r["deleted"], json!("Quad")); + let names: Vec = s.doc().unwrap().vports.iter().filter(|c| !c.is_active()).map(|c| c.name.clone()).collect(); + assert_eq!(names, vec!["Two".to_string()]); + s.undo().unwrap(); + assert!(s.doc().unwrap().vports.iter().any(|c| c.name == "Quad"), "deleting is one undo step"); +} + +#[test] +fn named_viewports_refuses_unknown_names_and_a_delete_that_is_not_a_boolean() { + let mut s = session(); + for bad in [ + json!({ "name": "nope" }), + json!({ "name": "*Active" }), + json!({ "name": "Two", "delete": "yes" }), + json!({ "name": "nope", "delete": true }), + ] { + assert!(s.execute("vports.named", &bad).is_err(), "{bad}"); + } + // Only the exact name is deleted, never a wildcard match. + assert!(s.execute("vports.named", &json!({ "name": "*", "delete": true })).is_err()); + assert_eq!(s.doc().unwrap().vports.iter().filter(|c| !c.is_active()).count(), 2); +} + +#[test] +fn named_viewports_places_a_configuration_in_a_layout() { + let mut s = session(); + s.execute("layout.set", &json!({ "name": "Layout1" })).unwrap(); + let before = layout_viewports(&s); + let r = s.execute("vports.named", &json!({ "name": "Quad", "p1": [10, 10], "p2": [210, 110] })).unwrap(); + assert_eq!(r["configuration"], json!("Quad")); + assert_eq!(layout_viewports(&s), before + 4); + assert!(s.model_tiles().is_empty(), "the Model tab's tiles do not change"); +} + +#[test] +fn typed_named_viewports_asks_for_the_configuration() { + let mut s = session(); + s.start("vports.named").unwrap(); + assert!(s.prompt_text().contains("Enter name of viewport configuration to restore"), "{}", s.prompt_text()); + s.cmdline("Two").unwrap(); + assert!(s.running.is_none()); + assert_eq!(rects(&s).len(), 2); + + s.execute("layout.set", &json!({ "name": "Layout1" })).unwrap(); + let before = layout_viewports(&s); + s.start("vports.named").unwrap(); + assert!(s.prompt_text().contains("Enter viewport configuration name"), "{}", s.prompt_text()); + s.cmdline("Quad").unwrap(); + assert!(s.prompt_text().contains("Specify first corner"), "{}", s.prompt_text()); + s.cmdline("").unwrap(); + assert!(s.running.is_none()); + assert_eq!(layout_viewports(&s), before + 4, "Enter fits the configuration to the printable area"); +} diff --git a/crates/engine/tests/templates.rs b/crates/engine/tests/templates.rs index e85eb2242..c518e3b06 100644 --- a/crates/engine/tests/templates.rs +++ b/crates/engine/tests/templates.rs @@ -1,5 +1,5 @@ //! Drawing templates (#552): SAVEAS to a .dwt, NEW/QNEW from a template file, and the default -//! template (QNEWTEMPLATE). The io hooks here keep drawings in memory: a "file" holds the number +//! template (QNEWTEMPLATE), and a layout saved as a template (LAYOUT SAveas). The io hooks here keep drawings in memory: a "file" holds the number //! of the drawing written, so the test sees exactly what the engine hands the file formats. use std::sync::Mutex; @@ -105,3 +105,95 @@ fn a_template_that_cannot_be_read_makes_no_drawing() { assert!(s.execute("new", &json!({ "template": 42 })).is_err()); assert_eq!(s.docs.len(), docs); } + +/// The drawing the io hooks were last handed for a file whose name ends with `name`. +fn written(name: &str) -> Drawing { + FILES.lock().unwrap().iter().rev().find(|(n, _)| n.ends_with(name)).map(|f| f.1.clone()).unwrap() +} + +fn layout_names(d: &Drawing) -> Vec { + d.layouts.iter().map(|l| l.name.clone()).collect() +} + +/// LAYOUT SAveas: the template holds the layout alone, with the named objects it uses, and the +/// drawing it came from keeps everything. +#[test] +fn layout_saveas_writes_the_layout_and_the_named_objects_it_uses() { + hooks(); + let mut s = Session::new(); + for name in ["TITLE", "PARTS-LAYER", "UNUSED", "WALLS", "NOTES"] { + s.execute("layer.new", &json!({ "name": name })).unwrap(); + } + s.execute("layer.current", &json!({ "name": "PARTS-LAYER" })).unwrap(); + let c = s.execute("circle", &json!({ "center": [5, 5], "radius": 2 })).unwrap()["handle"].clone(); + s.execute("block", &json!({ "name": "PARTS", "base": [0, 0], "handles": [c], "keep": "retain" })).unwrap(); + // WALLS has model space objects only, and NOTES none; the sheet's viewport freezes one and recolors the other. + s.execute("layer.current", &json!({ "name": "WALLS" })).unwrap(); + s.execute("line", &json!({ "points": [[0, 0], [0, 10]] })).unwrap(); + let vp = s.execute("layout.new", &json!({ "name": "Sheet" })).unwrap()["viewport"].clone(); + s.execute("viewport.set", &json!({ "handle": vp, "freeze": ["WALLS"], "colors": { "NOTES": 1 } })).unwrap(); + s.execute("layout.set", &json!({ "name": "Sheet" })).unwrap(); + s.execute("layer.current", &json!({ "name": "TITLE" })).unwrap(); + s.execute("line", &json!({ "points": [[0, 0], [10, 0]] })).unwrap(); + s.execute("layer.current", &json!({ "name": "0" })).unwrap(); + let sheet_objects = s.doc().unwrap().layout("Sheet").unwrap().entities.iter().count(); + + let p = path("sheet.dwt"); + let r = s.execute("layout.saveas", &json!({ "path": p })).unwrap(); + assert_eq!(r["name"], json!("Sheet"), "the current layout by default"); + assert_eq!(r["path"], json!(p)); + let t = written("sheet.dwt"); + assert_eq!(layout_names(&t), ["Sheet"]); + assert_eq!(t.active_layout.as_deref(), Some("Sheet")); + assert_eq!(t.model.iter().count(), 0, "no model space objects"); + assert_eq!(t.layout("Sheet").unwrap().entities.iter().count(), sheet_objects, "the layout's viewport and line"); + assert!(t.layer("TITLE").is_some() && t.layer("0").is_some()); + assert!(t.layer("UNUSED").is_none() && t.layer("PARTS-LAYER").is_none(), "unused layers are left out"); + assert!(t.block("PARTS").is_none(), "unused blocks are left out"); + // Layers the viewport freezes or recolors stay, and so do those settings. + assert!(t.layer("WALLS").is_some() && t.layer("NOTES").is_some()); + let sheet_vp = t.layout("Sheet").unwrap().entities.iter().find_map(|e| match &e.kind { + cadcraft_engine::doc::EntityKind::Viewport(v) if v.id != 1 => Some(v.clone()), + _ => None, + }); + let sheet_vp = sheet_vp.unwrap(); + assert_eq!(sheet_vp.frozen_layers, ["WALLS"]); + assert_eq!(sheet_vp.layer_colors.len(), 1); + + // The drawing is not changed. + let d = s.doc().unwrap(); + assert_eq!(layout_names(d), ["Layout1", "Layout2", "Sheet"]); + assert_eq!(d.model.iter().count(), 2); + assert!(d.layer("UNUSED").is_some() && d.block("PARTS").is_some()); + + // A drawing started from the template has the layout. + s.execute("new", &json!({ "template": p })).unwrap(); + assert_eq!(layout_names(s.doc().unwrap()), ["Sheet"]); +} + +#[test] +fn typed_layout_saveas_asks_for_the_layout_and_the_template_file() { + hooks(); + let mut s = Session::new(); + s.cmdline("LAYOUT").unwrap(); + s.cmdline("sa").unwrap(); + assert_eq!(s.current_prompt().unwrap().display(), "Enter layout to save to template :"); + s.cmdline("Layout2").unwrap(); + assert_eq!(s.current_prompt().unwrap().display(), "Enter template file name :"); + // No extension: the template is a .dwt. + let p = path("typed"); + s.cmdline(&p).unwrap(); + assert!(s.current_prompt().is_none()); + let saved = format!("{p}.dwt"); + assert!(s.log.iter().any(|l| *l == format!("Layout \"Layout2\" saved to template \"{saved}\".")), "{:?}", s.log); + assert_eq!(layout_names(&written("typed.dwt")), ["Layout2"]); + + // The JSON form of LAYOUT, other file types, a missing path and an unknown layout. + let dxf = path("sheet1.dxf"); + let r = s.execute("layout", &json!({ "option": "saveas", "name": "layout1", "path": dxf })).unwrap(); + assert_eq!(r["name"], json!("Layout1")); + assert_eq!(layout_names(&written("sheet1.dxf")), ["Layout1"]); + assert!(s.execute("layout.saveas", &json!({ "path": path("sheet.png") })).is_err()); + assert!(s.execute("layout.saveas", &json!({})).is_err()); + assert!(s.execute("layout.saveas", &json!({ "name": "Nope", "path": path("nope.dwt") })).is_err()); +} 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/region.rs b/crates/geom/src/region.rs index bef60b530..c186534c9 100644 --- a/crates/geom/src/region.rs +++ b/crates/geom/src/region.rs @@ -447,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, @@ -474,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))); diff --git a/crates/geom/src/spline.rs b/crates/geom/src/spline.rs index 502df13c2..d954e66b1 100644 --- a/crates/geom/src/spline.rs +++ b/crates/geom/src/spline.rs @@ -318,317 +318,4 @@ pub(crate) fn solve(mut a: Vec>, mut b: Vec) -> Option> { } #[cfg(test)] -mod tests { - use super::*; - - /// `basis` before it evaluated on the stack, kept as the reference. - fn basis_ref(span: usize, u: f64, p: usize, knots: &[f64]) -> Vec { - let mut n = vec![0.0; p + 1]; - let mut left = vec![0.0; p + 1]; - let mut right = vec![0.0; p + 1]; - n[0] = 1.0; - for j in 1..=p { - let k = |i: isize| knots.get(i.max(0) as usize).copied().unwrap_or(0.0); - left[j] = u - k(span as isize + 1 - j as isize); - right[j] = k(span as isize + j as isize) - u; - let mut saved = 0.0; - for r in 0..j { - let den = right[r + 1] + left[j - r]; - let tmp = if den.abs() > 1e-300 { n[r] / den } else { 0.0 }; - n[r] = saved + right[r + 1] * tmp; - saved = left[j - r] * tmp; - } - n[j] = saved; - } - n - } - - /// `eval` before it evaluated on the stack, kept as the reference. - fn eval_ref(s: &Spline, t: f64) -> Vec2 { - let n = s.control.len() - 1; - let (lo, hi) = s.domain(); - let t = t.clamp(lo, hi); - let span = find_span(n, s.degree, t, &s.knots); - let b = basis_ref(span, t, s.degree, &s.knots); - let mut p = Vec2::ZERO; - let mut wsum = 0.0; - for (k, nb) in b.iter().enumerate() { - let idx = span + k - s.degree; - let w = if s.weights.is_empty() { 1.0 } else { s.weights[idx] }; - p += s.control[idx] * (nb * w); - wsum += nb * w; - } - if wsum.abs() > 1e-300 { p / wsum } else { p } - } - - /// Deterministic pseudo-random numbers in `0..1`. - fn noise(seed: &mut u64) -> f64 { - *seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); - (*seed >> 11) as f64 / (1u64 << 53) as f64 - } - - /// Splines of degree 1..=11 over 14 control points: clamped and unclamped (uniform) knots, - /// unit and random weights. - fn samples() -> Vec { - let mut seed = 7; - let mut out = Vec::new(); - for degree in 1..=11 { - let control: Vec = (0..14).map(|i| Vec2::new(i as f64 * 3.0 + noise(&mut seed), 10.0 * noise(&mut seed) - 5.0)).collect(); - let weights: Vec = (0..14).map(|_| 0.5 + 1.5 * noise(&mut seed)).collect(); - let clamped = Spline::from_control(control.clone(), degree); - let uniform = Spline { knots: (0..control.len() + degree + 1).map(|i| i as f64 * 0.25).collect(), ..clamped.clone() }; - for base in [clamped, uniform] { - out.push(Spline { weights: weights.clone(), ..base.clone() }); - out.push(base); - } - } - out - } - - #[test] - fn stack_basis_matches_the_heap_basis_bit_for_bit() { - for s in samples() { - assert!(s.is_valid()); - let (lo, hi) = s.domain(); - for i in -20..=1020 { - let t = lo + (hi - lo) * i as f64 / 1000.0; - let got = s.eval(t); - let want = eval_ref(&s, t); - assert_eq!( - (got.x.to_bits(), got.y.to_bits()), - (want.x.to_bits(), want.y.to_bits()), - "degree {} at {t}: {got:?} vs {want:?}", - s.degree - ); - let span = find_span(s.control.len() - 1, s.degree, t.clamp(lo, hi), &s.knots); - let b = basis(span, t.clamp(lo, hi), s.degree, &s.knots); - let r = basis_ref(span, t.clamp(lo, hi), s.degree, &s.knots); - assert!(b.iter().zip(&r).all(|(a, b)| a.to_bits() == b.to_bits()) && b.len() == r.len(), "degree {}", s.degree); - } - } - } - - #[test] - fn basis_into_needs_room_for_every_function() { - let knots = clamped_uniform_knots(6, 3); - let (mut n, mut left, mut right) = ([0.5; 3], [0.0; 4], [0.0; 4]); - basis_into(3, 0.5, 3, &knots, &mut n, &mut left, &mut right); - assert_eq!(n, [0.5; 3]); - } - - /// A cubic through 300 points of a sine wave, open or closed. - fn wiggly(closed: bool) -> Spline { - let fit: Vec = (0..300) - .map(|i| { - let x = i as f64 / 299.0; - Vec2::new(x * 100.0, 10.0 * (x * 60.0).sin()) - }) - .collect(); - Spline::from_fit(&fit, closed) - } - - /// The box from 4000 evaluations, which `bounds` approximates. - fn fine_box(s: &Spline) -> Bounds2 { - uniform_box(s, MAX_SAMPLES) - } - - /// The box of `n + 1` samples evenly spaced in parameter over the domain, as `bounds` sampled - /// before it spread its samples over the knot spans. - fn uniform_box(s: &Spline, n: usize) -> Bounds2 { - let (lo, hi) = s.domain(); - Bounds2::from_points((0..=n).map(|i| s.eval(lo + (hi - lo) * i as f64 / n as f64))) - } - - /// A degree-2 spline whose fourth control point, the only one off the x axis, is supported - /// on the single knot span [0.5003, 0.5004) (double knots either side): the curve is flat - /// except for a peak of 50 at t = 0.50035, inside a span of width 1e-4 that no point of a - /// uniform grid of up to 4000 hits (0.50025 and 0.5005 are the nearest). - fn narrow_span() -> Spline { - let control = (0..7).map(|i| Vec2::new(i as f64, if i == 3 { 100.0 } else { 0.0 })).collect(); - Spline { degree: 2, knots: vec![0.0, 0.0, 0.0, 0.5003, 0.5003, 0.5004, 0.5004, 1.0, 1.0, 1.0], control, ..Spline::default() } - } - - /// A span held to the floor samples its peak off the grid: the sag stays under 1% of the - /// spline's size, which is what `bounds` promises for it. - #[test] - fn an_off_grid_peak_in_a_narrow_span_is_within_a_percent() { - let mut s = narrow_span(); - // y(s) = 200 s (1 - s) + 30 s² on the narrow span: its maximum is at s = 10 / 17, between - // the samples of an eight-segment grid. - s.control[4].y = 30.0; - assert!(s.is_valid()); - let (a, b) = (0.5003, 0.5004); - let peak = (0..=10_000).map(|i| s.eval(a + (b - a) * i as f64 / 10_000.0)).fold(Vec2::ZERO, |m, p| if p.y > m.y { p } else { m }); - assert!(peak.y > 58.0 && peak.y < 59.0, "{peak:?}"); - let hull = Bounds2::from_points(s.control.iter().copied()); - let diag = hull.min.dist(hull.max); - let got = s.bounds(); - assert!(got.max.y <= peak.y + 1e-9 * diag && got.max.y >= peak.y - 1e-2 * diag, "{got:?} vs peak {peak:?}"); - // Four segments, the floor before, missed it by more than that. - let four = (0..=4).map(|i| s.eval(a + (b - a) * i as f64 / 4.0)).fold(f64::MIN, |m, p| m.max(p.y)); - assert!(four < peak.y - 1e-2 * diag, "{four} vs {peak:?}"); - } - - #[test] - fn bounds_see_an_extreme_inside_a_narrow_knot_span() { - let s = narrow_span(); - assert!(s.is_valid()); - let (a, b) = (0.5003, 0.5004); - let peak = (0..=1000).map(|i| s.eval(a + (b - a) * i as f64 / 1000.0)).fold(Vec2::ZERO, |m, p| if p.y > m.y { p } else { m }); - assert!((peak.x - 3.0).abs() < 1e-9 && (peak.y - 50.0).abs() < 1e-9, "{peak:?}"); - let hull = Bounds2::from_points(s.control.iter().copied()); - let diag = hull.min.dist(hull.max); - // One uniform grid, at the count `bounds` used to take or at the cap, steps over the span - // and sees only the flat parts of the curve. - let old = s.segments_for(s.polygon_length() / (diag * 1e-3)).max((s.control.len() - s.degree) * 4).min(MAX_SAMPLES); - for n in [old, MAX_SAMPLES] { - let grid = uniform_box(&s, n); - assert!(grid.max.y < 1e-9 && !grid.contains(peak), "{n} segments: {grid:?}"); - } - let got = s.bounds(); - assert!(got.expand(1e-9 * diag).contains(peak), "{got:?} misses {peak:?}"); - assert!((got.max.y - peak.y).abs() <= 1e-9 * diag, "{got:?}"); - } - - #[test] - fn bounds_keep_their_cap_over_many_spans() { - // 1500 spans: eight per span would pass the cap, so two; 2500 spans: more than the cap - // allows two for, so the control box, which contains the curve. A smooth curve stays - // within the margin either way. - for n in [1502, 2502] { - let control: Vec = (0..n).map(|i| Vec2::from_angle(i as f64 / n as f64 * std::f64::consts::TAU) * 50.0).collect(); - let hull = Bounds2::from_points(control.iter().copied()); - let s = Spline::from_control(control, 2); - let (b, want) = (s.bounds(), fine_box(&s)); - if n > MAX_SAMPLES / 2 { - assert_eq!(b, hull, "{n} control points"); - assert!(b.contains(want.min) && b.contains(want.max), "{b:?} vs {want:?}"); - } - let diag = want.min.dist(want.max); - let err = [b.min.x - want.min.x, b.min.y - want.min.y, b.max.x - want.max.x, b.max.y - want.max.y] - .into_iter() - .map(f64::abs) - .fold(0.0, f64::max); - assert!(err <= 1e-3 * diag, "{n} control points: {b:?} vs {want:?}"); - } - } - - #[test] - fn bounds_keep_their_cap_over_huge_knot_spans() { - // Knots a hostile edit can produce (lengthening by 1e308): a span's count must not - // overflow into a boundless loop or a sum that panics. - let control = vec![Vec2::ZERO, Vec2::new(1.0, 2.0), Vec2::new(3.0, 0.0), Vec2::new(4.0, 1.0)]; - for knots in [ - vec![0.0, 0.0, 0.0, 1e308, 1.5e308, 1.5e308, 1.5e308], - vec![0.0, 0.0, 0.0, f64::MAX, f64::MAX, f64::MAX, f64::MAX], - vec![-1e308, -1e308, -1e308, 0.0, 1e308, 1e308, 1e308], - ] { - let s = Spline { knots, ..Spline::from_control(control.clone(), 2) }; - assert!(s.is_valid()); - let b = s.bounds(); - assert!(b.is_empty() || Bounds2::from_points(control.iter().copied()).expand(1e-9).contains_box(&b), "{b:?}"); - } - } - - fn scaled(s: &Spline, k: f64) -> Spline { - Spline { control: s.control.iter().map(|c| *c * k).collect(), fit: s.fit.iter().map(|c| *c * k).collect(), ..s.clone() } - } - - #[test] - fn bounds_stay_within_a_small_margin_of_the_finely_sampled_box() { - let mut all = samples(); - all.push(wiggly(false)); - all.push(wiggly(true)); - all.push(Spline::from_fit_points_closed(&[Vec2::ZERO, Vec2::new(10.0, 0.0), Vec2::new(10.0, 10.0), Vec2::new(0.0, 10.0)])); - for s in all { - // Whatever the drawing's units: the same relative margin at any scale, including - // splines far smaller than any absolute tolerance floor. - for k in [1e-12, 1e-9, 1e-6, 1.0, 1e6] { - let s = scaled(&s, k); - let (b, want) = (s.bounds(), fine_box(&s)); - let diag = want.min.dist(want.max); - let err = [b.min.x - want.min.x, b.min.y - want.min.y, b.max.x - want.max.x, b.max.y - want.max.y] - .into_iter() - .map(f64::abs) - .fold(0.0, f64::max); - assert!(err <= 1e-3 * diag, "degree {} with {} control points at scale {k}: {b:?} vs {want:?}", s.degree, s.control.len()); - // The curve lies in its control points' convex hull (positive weights), up to rounding. - assert!(Bounds2::from_points(s.control.iter().copied()).expand(1e-9 * diag).contains_box(&b), "degree {} at scale {k}", s.degree); - } - } - } - - /// A negative weight voids the convex hull property: a degree-1 spline is then not its - /// control polygon, so it is sampled like any other. - #[test] - fn a_degree_one_spline_with_a_negative_weight_is_sampled() { - let mut s = Spline::from_control(vec![Vec2::new(1.0, 0.0), Vec2::new(2.0, 0.0)], 1); - s.weights = vec![1.0, -0.5]; - assert!(s.is_valid()); - let far = s.eval(0.75); - assert!((far.x - 4.0).abs() < 1e-9, "{far:?}"); - let b = s.bounds(); - assert!(b.contains(far), "{b:?} misses {far:?}"); - s.weights = vec![1.0, 0.5]; - assert_eq!(s.bounds(), Bounds2::new(Vec2::new(1.0, 0.0), Vec2::new(2.0, 0.0))); - } - - /// Zero weights evaluate to the origin, so a spline whose control points coincide is not its - /// control box either; it is sampled. - #[test] - fn zero_weights_are_sampled_not_boxed() { - let mut s = Spline::from_control(vec![Vec2::new(3.0, 4.0); 2], 1); - s.weights = vec![0.0, 0.0]; - assert!(s.is_valid()); - assert_eq!(s.eval(0.5), Vec2::ZERO); - let b = s.bounds(); - assert!(b.contains(Vec2::ZERO), "{b:?}"); - } - - #[test] - fn degenerate_splines_get_their_control_box() { - let point = Spline::from_control(vec![Vec2::new(3.0, 4.0); 4], 3); - assert_eq!(point.bounds(), Bounds2::new(Vec2::new(3.0, 4.0), Vec2::new(3.0, 4.0))); - let invalid = Spline { knots: vec![0.0; 3], ..Spline::from_control(vec![Vec2::ZERO, Vec2::new(1.0, 2.0), Vec2::new(5.0, -1.0)], 2) }; - assert!(!invalid.is_valid()); - assert_eq!(invalid.bounds(), Bounds2::new(Vec2::new(0.0, -1.0), Vec2::new(5.0, 2.0))); - let hostile = Spline::from_control(vec![Vec2::new(f64::NAN, 0.0), Vec2::new(1.0, f64::INFINITY), Vec2::new(2.0, 2.0)], 2); - assert_eq!(hostile.bounds(), Bounds2::new(Vec2::new(2.0, 2.0), Vec2::new(2.0, 2.0))); - // A non-finite control point among finite ones poisons every sample: the box of the - // finite control points, not the empty box. - for bad in [Vec2::new(f64::NAN, 5.0), Vec2::new(5.0, f64::NAN), Vec2::new(f64::INFINITY, 5.0), Vec2::new(5.0, f64::NEG_INFINITY)] { - let s = Spline::from_control(vec![Vec2::ZERO, bad, Vec2::new(10.0, 10.0), Vec2::new(20.0, 0.0)], 3); - assert!(s.is_valid()); - assert_eq!(s.bounds(), Bounds2::new(Vec2::ZERO, Vec2::new(20.0, 10.0)), "{bad:?}"); - } - // A non-finite weight poisons the samples the same way. - for bad in [f64::NAN, f64::INFINITY] { - let mut s = Spline::from_control(vec![Vec2::ZERO, Vec2::new(5.0, 5.0), Vec2::new(10.0, 10.0), Vec2::new(20.0, 0.0)], 3); - s.weights = vec![1.0, bad, 1.0, 1.0]; - assert!(s.is_valid()); - assert_eq!(s.bounds(), Bounds2::new(Vec2::ZERO, Vec2::new(20.0, 10.0)), "weight {bad:?}"); - } - assert!(Spline::default().bounds().is_empty()); - } - - #[test] - fn a_huge_degree_spline_is_invalid_and_cheap_to_tessellate() { - // A HATCH/DXF spline edge can declare a very high degree with matching knots. Such a - // spline is treated as invalid, so eval/tessellate fall back to the control polygon - // instead of doing O(degree^2) work per sample (CWE-407, rows 18/28). - let degree = 50_000usize; - let control = vec![Vec2::ZERO; degree + 2]; - let knots = clamped_uniform_knots(control.len(), degree); - let s = Spline { degree, knots, control: control.clone(), ..Spline::default() }; - assert!(!s.is_valid(), "a degree-{degree} spline must be rejected as invalid"); - // Returns the control polygon rather than sampling, so this is fast. - assert_eq!(s.tessellate(1e-3).len(), control.len()); - } - - #[test] - fn a_normal_low_degree_spline_is_valid() { - let s = Spline::from_control(vec![Vec2::new(0.0, 0.0), Vec2::new(1.0, 1.0), Vec2::new(2.0, 0.0), Vec2::new(3.0, 1.0)], 3); - assert!(s.is_valid()); - assert!(s.tessellate(1e-3).len() > 4); - } -} +mod tests; diff --git a/crates/geom/src/spline/tests.rs b/crates/geom/src/spline/tests.rs new file mode 100644 index 000000000..d953cfabd --- /dev/null +++ b/crates/geom/src/spline/tests.rs @@ -0,0 +1,305 @@ +use super::*; + +/// `basis` before it evaluated on the stack, kept as the reference. +fn basis_ref(span: usize, u: f64, p: usize, knots: &[f64]) -> Vec { + let mut n = vec![0.0; p + 1]; + let mut left = vec![0.0; p + 1]; + let mut right = vec![0.0; p + 1]; + n[0] = 1.0; + for j in 1..=p { + let k = |i: isize| knots.get(i.max(0) as usize).copied().unwrap_or(0.0); + left[j] = u - k(span as isize + 1 - j as isize); + right[j] = k(span as isize + j as isize) - u; + let mut saved = 0.0; + for r in 0..j { + let den = right[r + 1] + left[j - r]; + let tmp = if den.abs() > 1e-300 { n[r] / den } else { 0.0 }; + n[r] = saved + right[r + 1] * tmp; + saved = left[j - r] * tmp; + } + n[j] = saved; + } + n +} + +/// `eval` before it evaluated on the stack, kept as the reference. +fn eval_ref(s: &Spline, t: f64) -> Vec2 { + let n = s.control.len() - 1; + let (lo, hi) = s.domain(); + let t = t.clamp(lo, hi); + let span = find_span(n, s.degree, t, &s.knots); + let b = basis_ref(span, t, s.degree, &s.knots); + let mut p = Vec2::ZERO; + let mut wsum = 0.0; + for (k, nb) in b.iter().enumerate() { + let idx = span + k - s.degree; + let w = if s.weights.is_empty() { 1.0 } else { s.weights[idx] }; + p += s.control[idx] * (nb * w); + wsum += nb * w; + } + if wsum.abs() > 1e-300 { p / wsum } else { p } +} + +/// Deterministic pseudo-random numbers in `0..1`. +fn noise(seed: &mut u64) -> f64 { + *seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + (*seed >> 11) as f64 / (1u64 << 53) as f64 +} + +/// Splines of degree 1..=11 over 14 control points: clamped and unclamped (uniform) knots, +/// unit and random weights. +fn samples() -> Vec { + let mut seed = 7; + let mut out = Vec::new(); + for degree in 1..=11 { + let control: Vec = (0..14).map(|i| Vec2::new(i as f64 * 3.0 + noise(&mut seed), 10.0 * noise(&mut seed) - 5.0)).collect(); + let weights: Vec = (0..14).map(|_| 0.5 + 1.5 * noise(&mut seed)).collect(); + let clamped = Spline::from_control(control.clone(), degree); + let uniform = Spline { knots: (0..control.len() + degree + 1).map(|i| i as f64 * 0.25).collect(), ..clamped.clone() }; + for base in [clamped, uniform] { + out.push(Spline { weights: weights.clone(), ..base.clone() }); + out.push(base); + } + } + out +} + +#[test] +fn stack_basis_matches_the_heap_basis_bit_for_bit() { + for s in samples() { + assert!(s.is_valid()); + let (lo, hi) = s.domain(); + for i in -20..=1020 { + let t = lo + (hi - lo) * i as f64 / 1000.0; + let got = s.eval(t); + let want = eval_ref(&s, t); + assert_eq!((got.x.to_bits(), got.y.to_bits()), (want.x.to_bits(), want.y.to_bits()), "degree {} at {t}: {got:?} vs {want:?}", s.degree); + let span = find_span(s.control.len() - 1, s.degree, t.clamp(lo, hi), &s.knots); + let b = basis(span, t.clamp(lo, hi), s.degree, &s.knots); + let r = basis_ref(span, t.clamp(lo, hi), s.degree, &s.knots); + assert!(b.iter().zip(&r).all(|(a, b)| a.to_bits() == b.to_bits()) && b.len() == r.len(), "degree {}", s.degree); + } + } +} + +#[test] +fn basis_into_needs_room_for_every_function() { + let knots = clamped_uniform_knots(6, 3); + let (mut n, mut left, mut right) = ([0.5; 3], [0.0; 4], [0.0; 4]); + basis_into(3, 0.5, 3, &knots, &mut n, &mut left, &mut right); + assert_eq!(n, [0.5; 3]); +} + +/// A cubic through 300 points of a sine wave, open or closed. +fn wiggly(closed: bool) -> Spline { + let fit: Vec = (0..300) + .map(|i| { + let x = i as f64 / 299.0; + Vec2::new(x * 100.0, 10.0 * (x * 60.0).sin()) + }) + .collect(); + Spline::from_fit(&fit, closed) +} + +/// The box from 4000 evaluations, which `bounds` approximates. +fn fine_box(s: &Spline) -> Bounds2 { + uniform_box(s, MAX_SAMPLES) +} + +/// The box of `n + 1` samples evenly spaced in parameter over the domain, as `bounds` sampled +/// before it spread its samples over the knot spans. +fn uniform_box(s: &Spline, n: usize) -> Bounds2 { + let (lo, hi) = s.domain(); + Bounds2::from_points((0..=n).map(|i| s.eval(lo + (hi - lo) * i as f64 / n as f64))) +} + +/// A degree-2 spline whose fourth control point, the only one off the x axis, is supported +/// on the single knot span [0.5003, 0.5004) (double knots either side): the curve is flat +/// except for a peak of 50 at t = 0.50035, inside a span of width 1e-4 that no point of a +/// uniform grid of up to 4000 hits (0.50025 and 0.5005 are the nearest). +fn narrow_span() -> Spline { + let control = (0..7).map(|i| Vec2::new(i as f64, if i == 3 { 100.0 } else { 0.0 })).collect(); + Spline { degree: 2, knots: vec![0.0, 0.0, 0.0, 0.5003, 0.5003, 0.5004, 0.5004, 1.0, 1.0, 1.0], control, ..Spline::default() } +} + +/// A span held to the floor samples its peak off the grid: the sag stays under 1% of the +/// spline's size, which is what `bounds` promises for it. +#[test] +fn an_off_grid_peak_in_a_narrow_span_is_within_a_percent() { + let mut s = narrow_span(); + // y(s) = 200 s (1 - s) + 30 s² on the narrow span: its maximum is at s = 10 / 17, between + // the samples of an eight-segment grid. + s.control[4].y = 30.0; + assert!(s.is_valid()); + let (a, b) = (0.5003, 0.5004); + let peak = (0..=10_000).map(|i| s.eval(a + (b - a) * i as f64 / 10_000.0)).fold(Vec2::ZERO, |m, p| if p.y > m.y { p } else { m }); + assert!(peak.y > 58.0 && peak.y < 59.0, "{peak:?}"); + let hull = Bounds2::from_points(s.control.iter().copied()); + let diag = hull.min.dist(hull.max); + let got = s.bounds(); + assert!(got.max.y <= peak.y + 1e-9 * diag && got.max.y >= peak.y - 1e-2 * diag, "{got:?} vs peak {peak:?}"); + // Four segments, the floor before, missed it by more than that. + let four = (0..=4).map(|i| s.eval(a + (b - a) * i as f64 / 4.0)).fold(f64::MIN, |m, p| m.max(p.y)); + assert!(four < peak.y - 1e-2 * diag, "{four} vs {peak:?}"); +} + +#[test] +fn bounds_see_an_extreme_inside_a_narrow_knot_span() { + let s = narrow_span(); + assert!(s.is_valid()); + let (a, b) = (0.5003, 0.5004); + let peak = (0..=1000).map(|i| s.eval(a + (b - a) * i as f64 / 1000.0)).fold(Vec2::ZERO, |m, p| if p.y > m.y { p } else { m }); + assert!((peak.x - 3.0).abs() < 1e-9 && (peak.y - 50.0).abs() < 1e-9, "{peak:?}"); + let hull = Bounds2::from_points(s.control.iter().copied()); + let diag = hull.min.dist(hull.max); + // One uniform grid, at the count `bounds` used to take or at the cap, steps over the span + // and sees only the flat parts of the curve. + let old = s.segments_for(s.polygon_length() / (diag * 1e-3)).max((s.control.len() - s.degree) * 4).min(MAX_SAMPLES); + for n in [old, MAX_SAMPLES] { + let grid = uniform_box(&s, n); + assert!(grid.max.y < 1e-9 && !grid.contains(peak), "{n} segments: {grid:?}"); + } + let got = s.bounds(); + assert!(got.expand(1e-9 * diag).contains(peak), "{got:?} misses {peak:?}"); + assert!((got.max.y - peak.y).abs() <= 1e-9 * diag, "{got:?}"); +} + +#[test] +fn bounds_keep_their_cap_over_many_spans() { + // 1500 spans: eight per span would pass the cap, so two; 2500 spans: more than the cap + // allows two for, so the control box, which contains the curve. A smooth curve stays + // within the margin either way. + for n in [1502, 2502] { + let control: Vec = (0..n).map(|i| Vec2::from_angle(i as f64 / n as f64 * std::f64::consts::TAU) * 50.0).collect(); + let hull = Bounds2::from_points(control.iter().copied()); + let s = Spline::from_control(control, 2); + let (b, want) = (s.bounds(), fine_box(&s)); + if n > MAX_SAMPLES / 2 { + assert_eq!(b, hull, "{n} control points"); + assert!(b.contains(want.min) && b.contains(want.max), "{b:?} vs {want:?}"); + } + let diag = want.min.dist(want.max); + let err = + [b.min.x - want.min.x, b.min.y - want.min.y, b.max.x - want.max.x, b.max.y - want.max.y].into_iter().map(f64::abs).fold(0.0, f64::max); + assert!(err <= 1e-3 * diag, "{n} control points: {b:?} vs {want:?}"); + } +} + +#[test] +fn bounds_keep_their_cap_over_huge_knot_spans() { + // Knots a hostile edit can produce (lengthening by 1e308): a span's count must not + // overflow into a boundless loop or a sum that panics. + let control = vec![Vec2::ZERO, Vec2::new(1.0, 2.0), Vec2::new(3.0, 0.0), Vec2::new(4.0, 1.0)]; + for knots in [ + vec![0.0, 0.0, 0.0, 1e308, 1.5e308, 1.5e308, 1.5e308], + vec![0.0, 0.0, 0.0, f64::MAX, f64::MAX, f64::MAX, f64::MAX], + vec![-1e308, -1e308, -1e308, 0.0, 1e308, 1e308, 1e308], + ] { + let s = Spline { knots, ..Spline::from_control(control.clone(), 2) }; + assert!(s.is_valid()); + let b = s.bounds(); + assert!(b.is_empty() || Bounds2::from_points(control.iter().copied()).expand(1e-9).contains_box(&b), "{b:?}"); + } +} + +fn scaled(s: &Spline, k: f64) -> Spline { + Spline { control: s.control.iter().map(|c| *c * k).collect(), fit: s.fit.iter().map(|c| *c * k).collect(), ..s.clone() } +} + +#[test] +fn bounds_stay_within_a_small_margin_of_the_finely_sampled_box() { + let mut all = samples(); + all.push(wiggly(false)); + all.push(wiggly(true)); + all.push(Spline::from_fit_points_closed(&[Vec2::ZERO, Vec2::new(10.0, 0.0), Vec2::new(10.0, 10.0), Vec2::new(0.0, 10.0)])); + for s in all { + // Whatever the drawing's units: the same relative margin at any scale, including + // splines far smaller than any absolute tolerance floor. + for k in [1e-12, 1e-9, 1e-6, 1.0, 1e6] { + let s = scaled(&s, k); + let (b, want) = (s.bounds(), fine_box(&s)); + let diag = want.min.dist(want.max); + let err = [b.min.x - want.min.x, b.min.y - want.min.y, b.max.x - want.max.x, b.max.y - want.max.y] + .into_iter() + .map(f64::abs) + .fold(0.0, f64::max); + assert!(err <= 1e-3 * diag, "degree {} with {} control points at scale {k}: {b:?} vs {want:?}", s.degree, s.control.len()); + // The curve lies in its control points' convex hull (positive weights), up to rounding. + assert!(Bounds2::from_points(s.control.iter().copied()).expand(1e-9 * diag).contains_box(&b), "degree {} at scale {k}", s.degree); + } + } +} + +/// A negative weight voids the convex hull property: a degree-1 spline is then not its +/// control polygon, so it is sampled like any other. +#[test] +fn a_degree_one_spline_with_a_negative_weight_is_sampled() { + let mut s = Spline::from_control(vec![Vec2::new(1.0, 0.0), Vec2::new(2.0, 0.0)], 1); + s.weights = vec![1.0, -0.5]; + assert!(s.is_valid()); + let far = s.eval(0.75); + assert!((far.x - 4.0).abs() < 1e-9, "{far:?}"); + let b = s.bounds(); + assert!(b.contains(far), "{b:?} misses {far:?}"); + s.weights = vec![1.0, 0.5]; + assert_eq!(s.bounds(), Bounds2::new(Vec2::new(1.0, 0.0), Vec2::new(2.0, 0.0))); +} + +/// Zero weights evaluate to the origin, so a spline whose control points coincide is not its +/// control box either; it is sampled. +#[test] +fn zero_weights_are_sampled_not_boxed() { + let mut s = Spline::from_control(vec![Vec2::new(3.0, 4.0); 2], 1); + s.weights = vec![0.0, 0.0]; + assert!(s.is_valid()); + assert_eq!(s.eval(0.5), Vec2::ZERO); + let b = s.bounds(); + assert!(b.contains(Vec2::ZERO), "{b:?}"); +} + +#[test] +fn degenerate_splines_get_their_control_box() { + let point = Spline::from_control(vec![Vec2::new(3.0, 4.0); 4], 3); + assert_eq!(point.bounds(), Bounds2::new(Vec2::new(3.0, 4.0), Vec2::new(3.0, 4.0))); + let invalid = Spline { knots: vec![0.0; 3], ..Spline::from_control(vec![Vec2::ZERO, Vec2::new(1.0, 2.0), Vec2::new(5.0, -1.0)], 2) }; + assert!(!invalid.is_valid()); + assert_eq!(invalid.bounds(), Bounds2::new(Vec2::new(0.0, -1.0), Vec2::new(5.0, 2.0))); + let hostile = Spline::from_control(vec![Vec2::new(f64::NAN, 0.0), Vec2::new(1.0, f64::INFINITY), Vec2::new(2.0, 2.0)], 2); + assert_eq!(hostile.bounds(), Bounds2::new(Vec2::new(2.0, 2.0), Vec2::new(2.0, 2.0))); + // A non-finite control point among finite ones poisons every sample: the box of the + // finite control points, not the empty box. + for bad in [Vec2::new(f64::NAN, 5.0), Vec2::new(5.0, f64::NAN), Vec2::new(f64::INFINITY, 5.0), Vec2::new(5.0, f64::NEG_INFINITY)] { + let s = Spline::from_control(vec![Vec2::ZERO, bad, Vec2::new(10.0, 10.0), Vec2::new(20.0, 0.0)], 3); + assert!(s.is_valid()); + assert_eq!(s.bounds(), Bounds2::new(Vec2::ZERO, Vec2::new(20.0, 10.0)), "{bad:?}"); + } + // A non-finite weight poisons the samples the same way. + for bad in [f64::NAN, f64::INFINITY] { + let mut s = Spline::from_control(vec![Vec2::ZERO, Vec2::new(5.0, 5.0), Vec2::new(10.0, 10.0), Vec2::new(20.0, 0.0)], 3); + s.weights = vec![1.0, bad, 1.0, 1.0]; + assert!(s.is_valid()); + assert_eq!(s.bounds(), Bounds2::new(Vec2::ZERO, Vec2::new(20.0, 10.0)), "weight {bad:?}"); + } + assert!(Spline::default().bounds().is_empty()); +} + +#[test] +fn a_huge_degree_spline_is_invalid_and_cheap_to_tessellate() { + // A HATCH/DXF spline edge can declare a very high degree with matching knots. Such a + // spline is treated as invalid, so eval/tessellate fall back to the control polygon + // instead of doing O(degree^2) work per sample (CWE-407, rows 18/28). + let degree = 50_000usize; + let control = vec![Vec2::ZERO; degree + 2]; + let knots = clamped_uniform_knots(control.len(), degree); + let s = Spline { degree, knots, control: control.clone(), ..Spline::default() }; + assert!(!s.is_valid(), "a degree-{degree} spline must be rejected as invalid"); + // Returns the control polygon rather than sampling, so this is fast. + assert_eq!(s.tessellate(1e-3).len(), control.len()); +} + +#[test] +fn a_normal_low_degree_spline_is_valid() { + let s = Spline::from_control(vec![Vec2::new(0.0, 0.0), Vec2::new(1.0, 1.0), Vec2::new(2.0, 0.0), Vec2::new(3.0, 1.0)], 3); + assert!(s.is_valid()); + assert!(s.tessellate(1e-3).len() > 4); +} diff --git a/crates/io/examples/export_bench.rs b/crates/io/examples/export_bench.rs new file mode 100644 index 000000000..d5ae0d436 --- /dev/null +++ b/crates/io/examples/export_bench.rs @@ -0,0 +1,79 @@ +//! Export timing on a realistic large drawing: `examples/apartment.dxf` repeated on a grid +//! (a floor plan of many apartments). +//! +//! `cargo run --release -p cadcraft-io --example export_bench -- [FORMAT] [GRID] [RUNS] [OUT_DIR]` +//! +//! FORMAT is `png`, `pdf`, `svg`, `dxf` or `all` (default `png`), GRID the apartments per side +//! (default 10), RUNS the timed runs per format (default 7). Prints the median and minimum wall +//! time of each format's export (from the drawing in memory to the file's bytes). With OUT_DIR, +//! the last run's output is written there as `bench.FORMAT` to compare builds. + +use std::time::{Duration, Instant}; + +use cadcraft_doc::{Drawing, Space}; +use cadcraft_geom::{Mat3, Vec2}; + +fn drawing(grid: usize) -> Drawing { + let src = include_bytes!("../../../examples/apartment.dxf"); + let Ok(mut d) = cadcraft_io::read(src, "apartment.dxf") else { + eprintln!("apartment.dxf did not read"); + std::process::exit(1); + }; + let ents: Vec<_> = d.space(&Space::Model).map(|s| s.iter().map(|e| (**e).clone()).collect()).unwrap_or_default(); + let ext = d.extents(&Space::Model); + let step = Vec2::new(ext.width() * 1.1, ext.height() * 1.1); + for i in 0..grid { + for j in 0..grid { + if (i, j) == (0, 0) { + continue; + } + let m = Mat3::translate(Vec2::new(step.x * j as f64, step.y * i as f64)); + for e in &ents { + let mut e = e.clone(); + e.handle = d.new_handle(); + e.kind.transform(&m); + if let Some(s) = d.space_mut(&Space::Model) { + s.push(e); + } + } + } + } + d +} + +fn main() { + let args: Vec = std::env::args().skip(1).collect(); + let format = args.first().map(String::as_str).unwrap_or("png").to_string(); + let grid: usize = args.get(1).and_then(|a| a.parse().ok()).unwrap_or(10); + let runs: usize = args.get(2).and_then(|a| a.parse().ok()).unwrap_or(7).max(1); + let out_dir = args.get(3); + let d = drawing(grid); + eprintln!("{} entities", d.entity_count()); + let formats: Vec<&str> = if format == "all" { vec!["dxf", "png", "pdf", "svg"] } else { vec![format.as_str()] }; + for f in formats { + let name = format!("bench.{f}"); + let mut times: Vec = Vec::new(); + let mut last = Vec::new(); + for _ in 0..runs { + let t = Instant::now(); + match cadcraft_io::write(&d, &name) { + Ok(bytes) => { + times.push(t.elapsed()); + last = bytes; + } + Err(e) => { + eprintln!("{f}: {e}"); + std::process::exit(1); + } + } + } + times.sort(); + let ms = |t: Duration| t.as_secs_f64() * 1000.0; + println!("{f}: median {:.1} ms, min {:.1} ms, {} runs, {} bytes", ms(times[times.len() / 2]), ms(times[0]), runs, last.len()); + if let Some(dir) = out_dir + && let Err(e) = std::fs::write(std::path::Path::new(dir).join(&name), &last) + { + eprintln!("{name}: {e}"); + } + } +} diff --git a/crates/io/src/dxf_read.rs b/crates/io/src/dxf_read.rs index 4c797ed9e..88d1801c3 100644 --- a/crates/io/src/dxf_read.rs +++ b/crates/io/src/dxf_read.rs @@ -9,966 +9,26 @@ use cadcraft_geom::{PolyVertex, Spline, Vec2, Vec3}; use crate::{IoError, Result}; +mod common; +mod dimassoc; +mod document; +mod entities; +mod hatch; +mod objects; +mod tables; +mod text; + +use document::{ + apply_sortents, count_skipped, outside_sections, parse_entities, place_blocks, place_entities, read_blocks, read_objects, setup_layouts, +}; +#[cfg(test)] +use entities::acad_table; +use entities::{entity, plot_style_names}; +use objects::Objects; +use tables::{header, tables}; + pub(crate) mod mtext_dxf; -/// Value lookup helpers over an entity's tags. -struct T<'a>(&'a [Tag]); - -impl T<'_> { - fn f(&self, code: i32) -> Option { - self.0.iter().find(|t| t.code == code).map(Tag::f64) - } - fn fd(&self, code: i32, d: f64) -> f64 { - self.f(code).unwrap_or(d) - } - fn i(&self, code: i32) -> Option { - self.0.iter().find(|t| t.code == code).map(Tag::i64) - } - fn s(&self, code: i32) -> Option { - self.0.iter().find(|t| t.code == code).map(Tag::str) - } - fn p(&self, code: i32) -> Vec3 { - Vec3::new(self.fd(code, 0.0), self.fd(code + 10, 0.0), self.fd(code + 20, 0.0)) - } - fn all_f(&self, code: i32) -> Vec { - self.0.iter().filter(|t| t.code == code).map(Tag::f64).collect() - } - fn pts(&self, code: i32) -> Vec { - let xs = self.all_f(code); - let ys = self.all_f(code + 10); - xs.into_iter().zip(ys).map(|(x, y)| Vec2::new(x, y)).collect() - } -} - -/// An entity's group 440: `0x01` in the top byte is ByBlock, `0x02` a fixed alpha (mapped to a -/// percentage as for layers), anything else ByLayer. -fn transparency_440(v: i64) -> Transparency { - match (v >> 24) & 0xff { - 1 => Transparency::ByBlock, - 2 => { - let alpha = u32::try_from(v & 0xff).unwrap_or(255); - let opaque = (alpha * 100 + 127) / 255; - Transparency::Percent(u8::try_from(100u32.saturating_sub(opaque).min(90)).unwrap_or(90)) - } - _ => Transparency::ByLayer, - } -} - -fn common(t: &T) -> Common { - let mut c = Common { layer: t.s(8).unwrap_or_else(|| "0".into()), linetype: t.s(6).unwrap_or_else(|| "ByLayer".into()), ..Common::default() }; - if let Some(v) = t.i(62) { - c.color = Color::from_aci(v as i16); - if v < 0 { - c.color = Color::ByLayer; - c.visible = true; - } - } - if let Some(tc) = t.i(420) { - c.color = Color::True(Rgb::from_u32(tc as u32)); - } - if let Some(lw) = t.i(370) { - c.lineweight = Lineweight::from_dxf(lw as i16); - } - if let Some(s) = t.f(48) { - c.ltscale = if s > 0.0 { s } else { 1.0 }; - } - if t.i(60) == Some(1) { - c.visible = false; - } - if let Some(v) = t.i(440) { - c.transparency = transparency_440(v); - } - c.thickness = t.fd(39, 0.0); - c.extrusion = Vec3::new(t.fd(210, 0.0), t.fd(220, 0.0), t.fd(230, 1.0)); - c -} - -/// Apply the OCS → WCS transform for 2D entities with a non-default extrusion. -fn ocs(c: &Common, p: Vec3) -> Vec3 { - ocs_n(c.extrusion, p) -} - -fn ocs_n(n: Vec3, p: Vec3) -> Vec3 { - if (n.z - 1.0).abs() < 1e-12 && n.x.abs() < 1e-12 && n.y.abs() < 1e-12 { p } else { cadcraft_geom::Mat4::ocs(n).apply(p) } -} - -/// The properties an ATTRIB carries itself: those it has groups for. Its layer counts only when -/// it differs from its block reference's (an attribute defined on layer 0 goes on the reference's -/// layer, as it is drawn anyway). -fn attrib_props(t: &T, insert_layer: &str) -> AttribProps { - let c = common(t); - AttribProps { - layer: t.s(8).filter(|l| !l.is_empty() && !l.eq_ignore_ascii_case(insert_layer)), - color: (t.i(62).is_some() || t.i(420).is_some()).then_some(c.color), - linetype: t.s(6).filter(|l| !l.is_empty()), - lineweight: t.i(370).is_some().then_some(c.lineweight), - } -} - -/// An attribute's modes from its own groups: flags (70) 4 verify and 8 preset, and Lock -/// position, the 280 after its tag in its subclass (a 280 before the tag is the subclass version). -fn attrib_modes(own: &[Tag]) -> AttribModes { - let flags = own.iter().find(|t| t.code == 70).map(Tag::i64).unwrap_or(0); - let sub = own.iter().position(|t| t.code == 100 && t.str().trim().starts_with("AcDbAttribute")).unwrap_or(0); - let rest = own.get(sub..).unwrap_or_default(); - let lock_position = rest.iter().position(|t| t.code == 2).is_some_and(|at| rest.iter().skip(at + 1).any(|t| t.code == 280 && t.i64() != 0)); - AttribModes { verify: flags & 4 != 0, preset: flags & 8 != 0, lock_position } -} - -/// An ATTRIB's or ATTDEF's groups split at its embedded multiline text (`101 Embedded Object`): -/// the attribute's own groups and, for a multiline attribute, the MTEXT groups after the marker. -fn split_embedded(tags: &[Tag]) -> (&[Tag], Option<&[Tag]>) { - match tags.iter().position(|t| t.code == 101) { - Some(i) => (tags.get(..i).unwrap_or(tags), tags.get(i + 1..)), - None => (tags, None), - } -} - -/// A multiline attribute's text, from the MTEXT groups embedded in it. -fn multiline_attrib(embedded: Option<&[Tag]>, c: &Common) -> Option { - let mtext = mtext_from(embedded.filter(|e| !e.is_empty())?, c); - let read_as = cadcraft_fonts::plain_mtext(&mtext.contents); - Some(MultilineAttrib { mtext, read_as }) -} - -/// Multiline text from MTEXT groups (an MTEXT entity's, or a multiline attribute's embedded ones). -fn mtext_from(tags: &[Tag], c: &Common) -> MText { - let t = T(tags); - // Contents: code 3 chunks followed by code 1. - let mut contents = String::new(); - for tg in tags.iter().filter(|x| x.code == 3) { - contents.push_str(&tg.str()); - } - contents.push_str(&t.s(1).unwrap_or_default()); - let contents = mtext_dxf::decode_contents(&contents); - let columns = mtext_dxf::read_columns(tags); - // The direction comes from the MTEXT's own groups only: in an R2018+ embedded object - // (group 101), 11 is the insertion point, not the X-axis direction (#667, as in #834). - let own = T(mtext_dxf::before_columns(tags)); - let mut rotation = match (own.f(11), own.f(21)) { - (Some(x), Some(y)) if x != 0.0 || y != 0.0 => Vec2::new(x, y).angle(), - _ => own.fd(50, 0.0).to_radians(), - }; - let mut attach = t.i(71).unwrap_or(1).clamp(1, 9) as u8; - if c.extrusion.z < 0.0 { - // Insertion point and direction are WCS, but the line height runs along extrusion × - // direction: under (0,0,-1) the glyphs are mirrored. Drawn readable (MIRRTEXT = 0) with - // the same footprint, the text turns round and left/right attachment swap. - rotation = cadcraft_geom::norm_angle(rotation + std::f64::consts::PI); - attach = match attach { - 1 | 4 | 7 => attach + 2, - 3 | 6 | 9 => attach - 2, - a => a, - }; - } - MText { - insert: t.p(10), - height: t.fd(40, 0.2).max(1e-9), - width: match &columns { - Some((_, w)) if t.fd(41, 0.0) <= 0.0 => *w, - _ => t.fd(41, 0.0).max(0.0), - }, - attach, - rotation, - style: t.s(7).unwrap_or_else(|| "Standard".into()), - contents, - line_spacing: t.fd(44, 1.0), - line_spacing_exact: t.i(73) == Some(2), - columns: columns.map(|(c, _)| c), - } -} - -fn text_from(t: &T) -> Text { - let h = match t.i(72).unwrap_or(0) { - 1 => HAlign::Center, - 2 => HAlign::Right, - 3 => HAlign::Aligned, - 4 => HAlign::Middle, - 5 => HAlign::Fit, - _ => HAlign::Left, - }; - let v = match t.i(73).or_else(|| t.i(74)).unwrap_or(0) { - 1 => VAlign::Bottom, - 2 => VAlign::Middle, - 3 => VAlign::Top, - _ => VAlign::Baseline, - }; - let align = if t.f(11).is_some() { Some(t.p(11)) } else { None }; - let mut text = Text { - insert: t.p(10), - align_pt: if h == HAlign::Left && v == VAlign::Baseline { None } else { align }, - height: t.fd(40, 0.2).max(1e-9), - value: t.s(1).unwrap_or_default(), - rotation: t.fd(50, 0.0).to_radians(), - width_factor: t.fd(41, 1.0), - oblique: t.fd(51, 0.0).to_radians(), - style: t.s(7).unwrap_or_else(|| "Standard".into()), - halign: h, - valign: v, - }; - let n = Vec3::new(t.fd(210, 0.0), t.fd(220, 0.0), t.fd(230, 1.0)); - if n.z < 0.0 { - mirror_text(&mut text, align.map(|a| ocs_n(n, a)), ocs_n(n, t.p(10))); - } - text -} - -/// TEXT / ATTRIB / ATTDEF with extrusion (0,0,-1): the points are OCS and the text runs from its -/// insertion point towards -x in the WCS, with mirrored glyphs. CADCraft draws text readable -/// (MIRRTEXT = 0), so it keeps the mirrored footprint: the direction turns to -rotation and -/// left/right justification swap ends (start ↔ end point). -fn mirror_text(t: &mut Text, align: Option, insert: Vec3) { - t.rotation = cadcraft_geom::norm_angle(-t.rotation); - let baseline_left = |t: &Text, p: Vec3| if t.valign == VAlign::Baseline { None } else { Some(p) }; - match t.halign { - HAlign::Left => { - // TL/ML/BL are placed by their alignment point, baseline-left text by its start point. - t.halign = HAlign::Right; - t.insert = insert; - t.align_pt = Some(if t.valign == VAlign::Baseline { insert } else { align.unwrap_or(insert) }); - } - HAlign::Right => { - t.halign = HAlign::Left; - t.insert = align.unwrap_or(insert); - t.align_pt = baseline_left(t, t.insert); - } - HAlign::Aligned | HAlign::Fit => { - t.insert = align.unwrap_or(insert); - t.align_pt = Some(insert); - } - HAlign::Center | HAlign::Middle => { - t.insert = insert; - t.align_pt = align; - } - } -} - -/// Parse one entity record. POLYLINE/INSERT followers are handled by the caller. -fn entity(kind: &str, tags: &[Tag]) -> Option<(Common, EntityKind)> { - entity_with_table_budget(kind, tags, &mut 4_000_000) -} - -/// Parse one entity while charging materialized table cells to the caller's shared import budget. -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) { - return None; - } - let t = T(tags); - let c = common(&t); - let k = match kind { - "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) - } - "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), - }) - } - "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 - // is the mirrored point, a negative x scale, the opposite rotation and a negative column - // spacing. Noise of up to 1e-3 in the extrusion's x/y moves the projection by ~1e-7 of the - // size, so it still counts as mirrored. A truly tilted extrusion (block plane not parallel - // to XY) needs the full 3D block transform, which Insert cannot hold; it is left as read. - let n = c.extrusion; - let mirrored = n.z < 0.0 && n.x.hypot(n.y) <= 1e-3 * n.z.abs(); - let mut p = t.p(10); - let mut scale = Vec3::new(t.fd(41, 1.0), t.fd(42, 1.0), t.fd(43, 1.0)); - let mut rotation = t.fd(50, 0.0).to_radians(); - let mut col_spacing = t.fd(44, 0.0); - if mirrored { - p = ocs(&c, p); - scale.x = -scale.x; - rotation = -rotation; - col_spacing = -col_spacing; - } - EntityKind::Insert(Insert { - block: t.s(2).unwrap_or_default(), - insert: p, - scale, - rotation, - attribs: Vec::new(), - cols: t.i(70).unwrap_or(1).clamp(1, 10_000) as u32, - rows: t.i(71).unwrap_or(1).clamp(1, 10_000) as u32, - col_spacing, - row_spacing: t.fd(45, 0.0), - }) - } - "DIMENSION" => { - let ty = t.i(70).unwrap_or(0) & 0x0f; - let kind = match ty { - 0 => DimKind::Linear { rotation: t.fd(50, 0.0).to_radians() }, - 1 => DimKind::Aligned, - 2 => DimKind::Angular, - 3 => DimKind::Diameter, - 4 => DimKind::Radius, - 5 => DimKind::Angular3P, - 6 => DimKind::Ordinate { x_type: t.i(70).unwrap_or(0) & 64 != 0 }, - 8 => DimKind::ArcLength, - _ => DimKind::Aligned, - }; - EntityKind::Dimension(Dimension { - kind, - defpt: t.p(10), - // The text midpoint is the one OCS point of a DIMENSION. - text_mid: if c.extrusion.z < 0.0 { ocs(&c, t.p(11)) } else { t.p(11) }, - p13: t.p(13), - p14: t.p(14), - p15: t.p(15), - p16: t.p(16), - text: t.s(1).unwrap_or_default(), - style: t.s(3).unwrap_or_else(|| "Standard".into()), - measurement: t.fd(42, 0.0), - text_rotation: t.fd(53, 0.0).to_radians(), - user_text_pos: t.i(70).unwrap_or(0) & 128 != 0, - block: t.s(2).filter(|s| !s.is_empty()), - overrides: Default::default(), - assoc: Vec::new(), - marks: Default::default(), - }) - } - "LEADER" => EntityKind::Leader(Leader { - vertices: t.pts(10).into_iter().map(|p| p.to3(0.0)).collect(), - arrow: t.i(71).unwrap_or(1) != 0, - spline: t.i(72).unwrap_or(0) != 0, - style: t.s(3).unwrap_or_else(|| "Standard".into()), - }), - // Names behind handles follow in `entity_extras` and `Objects::apply`. - "MULTILEADER" | "MLEADER" => EntityKind::MLeader(crate::dxf_mleader::read(tags)), - "SOLID" | "TRACE" => { - let corners = [t.p(10), t.p(11), t.p(12), if t.f(13).is_some() { t.p(13) } else { t.p(12) }]; - let corners = corners.map(|p| ocs(&c, p)); - if kind == "SOLID" { EntityKind::Solid(Solid { corners }) } else { EntityKind::Trace(Solid { corners }) } - } - "MLINE" => EntityKind::MLine(crate::dxf_mline::read_entity(tags)), - "3DFACE" => EntityKind::Face3d(Face3d { corners: [t.p(10), t.p(11), t.p(12), t.p(13)], hidden_edges: t.i(70).unwrap_or(0) as u8 }), - "HATCH" => { - let mut k = hatch(tags, &t)?; - if c.extrusion.z < 0.0 - && let EntityKind::Hatch(h) = &mut k - { - // Boundary in the mirrored OCS, like LWPOLYLINE; the pattern direction mirrors too. - for v in h.loops.iter_mut().flat_map(|l| l.vertices.iter_mut()) { - v.p.x = -v.p.x; - v.bulge = -v.bulge; - } - h.angle = cadcraft_geom::norm_angle(std::f64::consts::PI - h.angle); - } - k - } - "ACAD_TABLE" => EntityKind::Table(acad_table(tags, table_cells_left)), - "VIEWPORT" => EntityKind::Viewport(Viewport { - center: t.p(10), - width: t.fd(40, 1.0), - height: t.fd(41, 1.0), - view_center: Vec2::new(t.fd(12, 0.0), t.fd(22, 0.0)), - view_height: t.fd(45, 1.0), - id: t.i(69).unwrap_or(0) as u32, - locked: t.i(90).unwrap_or(0) & 16384 != 0, - frozen_layers: crate::dxf_ext::read_frozen(tags), - layer_colors: crate::dxf_ext::read_layer_colors(tags), - // Status flags: 65536 = non-rectangular clipping (by the 340 entity), 131072 = off. - clip: t.s(340).and_then(|h| Handle::parse_hex(&h)).filter(|h| h.0 != 0 && t.i(90).unwrap_or(0) & 65536 != 0), - on: t.i(90).unwrap_or(0) & 131072 == 0, - }), - "WIPEOUT" => { - let o = t.p(10); - let u = t.p(11); - let v = t.p(12); - // Clip vertices are in pixel space: origin at the image's top-left corner, y pointing down. - let pts: Vec = t.pts(14).into_iter().map(|q| o.xy() + u.xy() * (q.x + 0.5) + v.xy() * (0.5 - q.y)).collect(); - EntityKind::Wipeout(Wipeout { boundary: pts }) - } - // The file path comes from the IMAGEDEF object (`dxf_image::resolve`). - "IMAGE" => EntityKind::Image(crate::dxf_image::read_entity(tags)), - "TOLERANCE" => { - let dir = t.p(11); - let dir = if dir.xy().is_finite() && dir.xy().len() > 1e-12 { dir } else { Vec3::new(1.0, 0.0, 0.0) }; - EntityKind::Tolerance(Tolerance { - insert: t.p(10), - dir, - style: t.s(3).unwrap_or_else(|| "Standard".into()), - text: t.s(1).unwrap_or_default().chars().take(MAX_FRAME_CHARS).collect(), - }) - } - other => EntityKind::Unknown(Unknown { - dxf_type: other.to_string(), - tags: tags.iter().map(|tg| RawTag { code: tg.code, value: tg.str() }).collect(), - }), - }; - Some((c, k)) -} - -/// An ACAD_TABLE entity: sizes, cell texts and merges (DXF Reference), plus CADCraft's -/// title/header flags, text height and style name from xdata when present. -fn acad_table(tags: &[Tag], table_cells_left: &mut usize) -> Table { - const MAX_CELLS: usize = 1_000_000; - let start = tags.iter().position(|x| x.code == 100 && x.str() == "AcDbTable").unwrap_or(0); - let tail = tags.get(start..).unwrap_or(&[]); - let first_cell = tail.iter().position(|x| x.code == 171).unwrap_or(tail.len()); - let head = T(tail.get(..first_cell).unwrap_or(&[])); - let mut rows = usize::try_from(head.i(91).unwrap_or(0)).unwrap_or(0).min(10_000); - let mut cols = usize::try_from(head.i(92).unwrap_or(0)).unwrap_or(0).min(10_000); - // Large declared sizes must be backed by cell records (no allocation from a bare count). - let n_cells = tail.iter().filter(|x| x.code == 171).count(); - let declared = rows.saturating_mul(cols); - if declared > MAX_CELLS || (declared > 10_000 && n_cells < declared) { - rows = 0; - cols = 0; - } - // Charge before any cell vector is materialized, including discarded tables. - let count = rows.saturating_mul(cols); - match table_cells_left.checked_sub(count) { - Some(left) => *table_cells_left = left, - None => return Table { insert: T(tags).p(10), ..Table::default() }, - } - let size = |v: f64| if v.is_finite() && v > 0.0 { v } else { 0.27 }; - let mut row_heights: Vec = head.all_f(141).into_iter().take(rows).map(size).collect(); - row_heights.resize(rows, 0.27); - let mut col_widths: Vec = head.all_f(142).into_iter().take(cols).map(size).collect(); - col_widths.resize(cols, 2.5); - // Cells, row-major, each starting at a 171 group; xdata ends the last one. - let body = tail.get(first_cell..).unwrap_or(&[]); - let body = body.get(..body.iter().position(|x| x.code >= 1000).unwrap_or(body.len())).unwrap_or(&[]); - let mut flat: Vec = Vec::new(); - let mut i = 0; - while let Some(t0) = body.get(i) { - if flat.len() >= rows * cols { - break; - } - i += 1; - if t0.code != 171 { - continue; - } - let end = body.get(i..).and_then(|r| r.iter().position(|x| x.code == 171)).map(|p| i + p).unwrap_or(body.len()); - let cell = body.get(i..end).unwrap_or(&[]); - i = end; - let c = T(cell); - let mut text: String = cell.iter().filter(|x| x.code == 2 || x.code == 3).map(Tag::str).collect(); - text.push_str(&c.s(1).unwrap_or_default()); - let span = |code: i32| c.i(code).and_then(|v| u32::try_from(v).ok()); - // A merge starts at the cell whose span (rows 176, columns 175) is more than one cell. - // Its own merged flag (173) varies by writer: CADCraft sets it, while AutoCAD-style - // writers (acadrust 0.6.4) set it only on the cells the merge covers, whose span is 0. - let merged = match (span(176), span(175)) { - (Some(rs), Some(cs)) if rs >= 1 && cs >= 1 && (rs, cs) != (1, 1) => Some((rs, cs)), - _ => None, - }; - // The cell's own text height: 140 outside the value block (301 … 304), where it is a number. - let mut in_value = false; - let mut text_height = None; - for x in cell { - match x.code { - 301 => in_value = true, - 304 => in_value = false, - 140 if !in_value => text_height = Some(x.f64()).filter(|h| h.is_finite() && *h > 0.0 && *h < 1e9), - _ => {} - } - } - flat.push(TableCell { text, merged, text_height, ..TableCell::default() }); - } - flat.resize(rows * cols, TableCell::default()); - let cells: Vec> = if cols == 0 { vec![Vec::new(); rows] } else { flat.chunks(cols).map(<[TableCell]>::to_vec).collect() }; - let x = crate::dxf_ext::xdata(tags, crate::dxf_ext::APP); - let ours = x.iter().position(|t| t.code == 1000 && t.str() == "TABLE").map(|p| x.get(p + 1..).unwrap_or(&[])); - let (title, header, text_height, style) = match ours { - Some(o) => { - let flags: Vec = o.iter().filter(|t| t.code == 1070).map(Tag::i64).collect(); - let h = o.iter().find(|t| t.code == 1040).map(Tag::f64).filter(|h| h.is_finite() && *h > 0.0).unwrap_or(0.18); - let style = o.iter().find(|t| t.code == 1000).map(Tag::str).unwrap_or_else(|| "Standard".into()); - (flags.first().is_some_and(|v| *v != 0), flags.get(1).is_some_and(|v| *v != 0), h, style) - } - // Other writers: a first row merged across every column reads as a title. - // Their text height is resolved to the table style's once the styles are read (`apply`). - None => (cols > 1 && cells.first().and_then(|r| r.first()).and_then(|c| c.merged) == Some((1, cols as u32)), false, 0.0, "Standard".into()), - }; - let mut t = Table { insert: T(tags).p(10), col_widths, row_heights, cells, style, text_height, title, header, ..Table::default() }; - crate::dxf_table::read_table_xdata(tags, &mut t); - t -} - -fn hatch(tags: &[Tag], t: &T) -> Option { - let (origin, background) = hatch_xdata(tags); - let pattern = t.s(2).unwrap_or_else(|| "SOLID".into()); - let solid = t.i(70).unwrap_or(0) == 1; - let n_loops = t.i(91).unwrap_or(0).clamp(0, 100_000) as usize; - let mut loops = Vec::new(); - // Walk the boundary data after the 91 tag. - let start = tags.iter().position(|x| x.code == 91).map(|i| i + 1)?; - let mut i = start; - let get = |i: usize| tags.get(i); - for _ in 0..n_loops { - // 92: loop type flags. - while get(i).is_some_and(|x| x.code != 92) { - i += 1; - } - let flags = get(i)?.i64(); - i += 1; - let outer = flags & 1 != 0; - let mut verts: Vec = Vec::new(); - if flags & 2 != 0 { - // Polyline boundary: 72 has-bulge, 73 closed, 93 count, then 10/20/42. - let has_bulge = get(i).filter(|x| x.code == 72).map(Tag::i64).unwrap_or(0) != 0; - while get(i).is_some_and(|x| x.code != 93) { - i += 1; - } - let n = get(i)?.i64().clamp(0, 1_000_000) as usize; - i += 1; - for _ in 0..n { - let x = get(i).filter(|t| t.code == 10)?.f64(); - let y = get(i + 1).filter(|t| t.code == 20)?.f64(); - i += 2; - let mut b = 0.0; - if has_bulge && get(i).is_some_and(|t| t.code == 42) { - b = get(i)?.f64(); - i += 1; - } - verts.push(PolyVertex::with_bulge(Vec2::new(x, y), b)); - } - } else { - // Edge boundary: 93 edge count, each 72 edge type. - while get(i).is_some_and(|x| x.code != 93) { - i += 1; - } - let n = get(i)?.i64().clamp(0, 1_000_000) as usize; - i += 1; - for _ in 0..n { - while get(i).is_some_and(|x| x.code != 72) { - i += 1; - } - let ty = get(i)?.i64(); - i += 1; - let f = |i: usize| get(i).map(Tag::f64).unwrap_or(0.0); - match ty { - 1 => { - let a = Vec2::new(f(i), f(i + 1)); - let b = Vec2::new(f(i + 2), f(i + 3)); - i += 4; - if verts.last().is_none_or(|l| !l.p.near(a, 1e-9)) { - verts.push(PolyVertex::new(a)); - } - verts.push(PolyVertex::new(b)); - } - 2 => { - let c = Vec2::new(f(i), f(i + 1)); - let r = f(i + 2); - let sa = f(i + 3).to_radians(); - let ea = f(i + 4).to_radians(); - let ccw = get(i + 5).map(Tag::i64).unwrap_or(1) != 0; - i += 6; - let (s, e) = if ccw { (sa, ea) } else { (-sa, -ea) }; - let p0 = Vec2::polar(c, r, s); - let p1 = Vec2::polar(c, r, e); - let sweep = if ccw { cadcraft_geom::ccw_sweep(s, e) } else { -cadcraft_geom::ccw_sweep(e, s) }; - if verts.last().is_none_or(|l| !l.p.near(p0, 1e-9)) { - verts.push(PolyVertex::new(p0)); - } - // A full circle (0 to 360) starts and ends on the same point, which one - // bulge can't span: it goes in as two half circles. - let full = (sweep.abs() - std::f64::consts::TAU).abs() < 1e-9; - let bulge = cadcraft_geom::arc_to_bulge(if full { sweep / 2.0 } else { sweep }); - if let Some(l) = verts.last_mut() { - l.bulge = bulge; - } - if full { - verts.push(PolyVertex::with_bulge(Vec2::polar(c, r, s + sweep / 2.0), bulge)); - } - verts.push(PolyVertex::new(p1)); - } - 3 => { - // Elliptic arc: approximate with points. - let c = Vec2::new(f(i), f(i + 1)); - let m = Vec2::new(f(i + 2), f(i + 3)); - let ratio = f(i + 4); - let sa = f(i + 5).to_radians(); - let ea = f(i + 6).to_radians(); - let ccw = get(i + 7).filter(|t| t.code == 73).map(Tag::i64).unwrap_or(1) != 0; - i += 8; - // A clockwise edge stores its parameters mirrored, as an arc edge does: it - // runs from -start clockwise to -end, the ccw sweep from -end to -start - // walked backwards. - let (s, e) = if ccw { (sa, ea) } else { (-ea, -sa) }; - let e = cadcraft_geom::Ellipse { center: c, major: m, ratio, start: s, end: e }; - let mut pts = Vec::new(); - e.tessellate(m.len() * 1e-3, &mut pts); - if !ccw { - pts.reverse(); - } - verts.extend(pts.into_iter().map(PolyVertex::new)); - } - 4 => { - // Spline edge: 94 degree, 73 rational, 74 periodic, 95 knot count, 96 - // control point count, then the 40 knots, the 10/20 control points (each - // with a 42 weight when rational), and 97 fit point count with 11/21 fit - // points and 12/22, 13/23 end tangents (R2010+). - let (mut degree, mut nk, mut nc) = (3, 0, 0); - 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.clamp(1, 10), // same cap as the SPLINE reader - 95 => nk = n, - 96 => nc = n, - _ => {} - } - i += 1; - } - let mut knots = Vec::new(); - while knots.len() < nk && get(i).is_some_and(|x| x.code == 40) { - knots.push(f(i)); - i += 1; - } - let (mut control, mut weights) = (Vec::new(), Vec::new()); - while control.len() < nc && get(i).is_some_and(|x| x.code == 10) { - control.push(Vec2::new(f(i), f(i + 1))); - i += 2; - if get(i).is_some_and(|t| t.code == 42) { - weights.push(f(i)); - i += 1; - } - } - let mut fit = Vec::new(); - if get(i).is_some_and(|x| x.code == 97) { - let n = get(i).map(Tag::i64).unwrap_or(0).clamp(0, 100_000) as usize; - i += 1; - while fit.len() < n && get(i).is_some_and(|x| x.code == 11) { - fit.push(Vec2::new(f(i), f(i + 1))); - i += 2; - } - } - if weights.len() != control.len() { - weights.clear(); - } - let sp = Spline { degree, knots, control, weights, ..Spline::default() }; - let sp = if sp.is_valid() { - sp - } else if sp.control.is_empty() && fit.len() > 1 { - Spline::from_fit_points(&fit) - } else { - Spline::from_control(sp.control, degree) - }; - let pts = sp.tessellate(1e-3); - let skip = usize::from(verts.last().zip(pts.first()).is_some_and(|(l, p)| l.p.near(*p, 1e-9))); - verts.extend(pts.into_iter().skip(skip).map(PolyVertex::new)); - } - _ => {} - } - } - } - if verts.len() > 1 && verts.first().map(|f| f.p).zip(verts.last().map(|l| l.p)).is_some_and(|(a, b)| a.near(b, 1e-9)) { - verts.pop(); - } - // 97: number of source boundary objects, then their handles (330). A spline edge's fit - // data count is a 97 too, followed by its fit points instead. - let mut sources = Vec::new(); - for _ in 0..2 { - while get(i).is_some_and(|x| !matches!(x.code, 97 | 92 | 75)) { - i += 1; - } - if get(i).is_none_or(|x| x.code != 97) { - break; - } - let n = get(i).map(Tag::i64).unwrap_or(0).clamp(0, MAX_HATCH_SOURCES as i64) as usize; - i += 1; - if get(i).is_some_and(|x| !matches!(x.code, 330 | 92 | 75)) { - continue; - } - while sources.len() < n - && let Some(x) = get(i).filter(|x| x.code == 330) - { - sources.extend(Handle::parse_hex(&x.str()).filter(|h| h.0 != 0)); - i += 1; - } - break; - } - loops.push(HatchLoop { vertices: verts, outer, sources }); - } - let solid = solid || pattern.eq_ignore_ascii_case("SOLID"); - let (scale, angle) = (t.fd(41, 1.0), t.fd(52, 0.0).to_radians()); - // Keep the file's definition lines even for a name the library knows: the library's versions - // of some patterns (AR-CONC) only approximate AutoCAD's, and drawing them at the file's scale - // could be hundreds of times denser than the hatch the author saw. - let pattern_lines = if solid { Vec::new() } else { pattern_definition(tags, scale, angle, origin) }; - Some(EntityKind::Hatch(Hatch { - pattern: pattern.to_ascii_uppercase(), - solid, - loops, - scale, - angle, - associative: t.i(71).unwrap_or(0) != 0, - style: t.i(75).unwrap_or(0) as u8, - // The first 30 is the elevation point's z (boundary points are 2D). - elevation: t.f(30).filter(|z| z.is_finite()).unwrap_or(0.0), - gradient: gradient(tags), - origin, - background, - pattern_lines, - })) -} - -/// Upper bound on the source boundary objects of one HATCH loop. -const MAX_HATCH_SOURCES: usize = 4096; - -/// Upper bounds on a HATCH's pattern definition lines and on the dashes of one line. -const MAX_PATTERN_LINES: usize = 1024; -const MAX_PATTERN_DASHES: usize = 256; - -/// The pattern definition lines of a HATCH (groups 78, then per line 53 angle, 43/44 base -/// point, 45/46 offset, 79 dash count, 49 dash lengths). A file holds them as drawn: scaled, -/// rotated with the hatch and the offset as a world vector. They are kept the library's way -/// (unit scale, no hatch rotation, base point relative to the hatch origin, offset as shift -/// along the line and spacing), which the writer and the renderer turn back. Lines with -/// non-finite values are skipped. -fn pattern_definition(tags: &[Tag], scale: f64, angle: f64, origin: Vec2) -> Vec { - let Some(at) = tags.iter().position(|x| x.code == 78) else { return Vec::new() }; - let scale = if scale.is_finite() && scale > 1e-9 { scale } else { 1.0 }; - let angle = if angle.is_finite() { angle } else { 0.0 }; - // Raw lines: angle (degrees), base, offset, dashes. - let mut raw: Vec<(f64, Vec2, Vec2, Vec)> = Vec::new(); - for x in tags.iter().skip(at + 1) { - let cur = raw.last_mut(); - match (x.code, cur) { - (53, _) => { - if raw.len() >= MAX_PATTERN_LINES { - break; - } - raw.push((x.f64(), Vec2::ZERO, Vec2::ZERO, Vec::new())); - } - (43, Some(l)) => l.1.x = x.f64(), - (44, Some(l)) => l.1.y = x.f64(), - (45, Some(l)) => l.2.x = x.f64(), - (46, Some(l)) => l.2.y = x.f64(), - (79, Some(_)) => {} - (49, Some(l)) => { - if l.3.len() < MAX_PATTERN_DASHES { - l.3.push(x.f64()); - } - } - _ => break, - } - } - raw.into_iter() - .filter(|(a, b, o, d)| a.is_finite() && b.is_finite() && o.is_finite() && d.iter().all(|v| v.is_finite())) - .map(|(a, base, offset, dashes)| { - let local = (base - origin).rotate(-angle) * (1.0 / scale); - let delta = offset.rotate(-a.to_radians()) * (1.0 / scale); - cadcraft_doc::library::PatternLine { - angle: a - angle.to_degrees(), - origin: (local.x, local.y), - delta: (delta.x, delta.y), - dashes: dashes.into_iter().map(|d| d / scale).collect(), - } - }) - .collect() -} - -/// Hatch origin and background colour from `CADCRAFT` xdata: `1000 HATCH`, `1011` origin, -/// `1000` background colour name (empty for none). Defaults when absent. -fn hatch_xdata(tags: &[Tag]) -> (Vec2, Option) { - let x = crate::dxf_ext::xdata(tags, crate::dxf_ext::APP); - let Some(at) = x.windows(2).position(|w| matches!(w, [m, o] if m.code == 1000 && m.str() == "HATCH" && o.code == 1011)) else { - return (Vec2::ZERO, None); - }; - let val = |k: usize, code: i32| x.get(at + k).filter(|t| t.code == code); - let coord = |k: usize, code: i32| val(k, code).map(Tag::f64).filter(|v| v.is_finite()).unwrap_or(0.0); - let origin = Vec2::new(coord(1, 1011), coord(2, 1021)); - (origin, val(4, 1000).and_then(|t| Color::parse(&t.str()))) -} - -/// Gradient values missing from a file fall back to the GRADIENT command's defaults. -fn default_gradient() -> Gradient { - Gradient { name: "LINEAR".into(), color1: Color::Index(5), color2: Color::Index(7), angle: 0.0, centered: true } -} - -/// A hatch's gradient fill: the native groups when present (another program may have edited -/// them), otherwise CADCraft's `GRADIENT` xdata (kept by R2000 files and DWG conversions). -fn gradient(tags: &[Tag]) -> Option { - if tags.iter().any(|x| x.code == 450) { native_gradient(tags) } else { xdata_gradient(tags) } -} - -/// `CADCRAFT` xdata: `1000 GRADIENT`, then name, 1040 angle, 1070 centered, colour 1 and 2 names. -fn xdata_gradient(tags: &[Tag]) -> Option { - let x = crate::dxf_ext::xdata(tags, crate::dxf_ext::APP); - let at = x.iter().position(|t| t.code == 1000 && t.str() == "GRADIENT")?; - let o = x.get(at + 1..).unwrap_or(&[]); - let d = default_gradient(); - let strs: Vec = o.iter().filter(|t| t.code == 1000).take(3).map(Tag::str).collect(); - let color = |i: usize, d: Color| strs.get(i).and_then(|s| Color::parse(s)).unwrap_or(d); - Some(Gradient { - name: strs.first().cloned().unwrap_or(d.name), - color1: color(1, d.color1), - color2: color(2, d.color2), - angle: o.iter().find(|t| t.code == 1040).map(Tag::f64).filter(|a| a.is_finite()).unwrap_or(d.angle), - centered: o.iter().find(|t| t.code == 1070).map_or(d.centered, |t| t.i64() != 0), - }) -} - -/// DXF Reference HATCH groups 450–470, when 450 says gradient. Each 463 starts a colour record -/// whose 63 (ACI) and 421 (true colour) groups follow. -fn native_gradient(tags: &[Tag]) -> Option { - let start = tags.iter().position(|x| x.code == 450)?; - if tags.get(start)?.i64() != 1 { - return None; - } - let mut g = default_gradient(); - // Per colour record: (ACI, true colour); a true colour wins over its ACI fallback. - let mut colors: [(Option, Option); 2] = [(None, None); 2]; - let mut slot: Option = None; - for x in tags.iter().skip(start + 1).take_while(|x| x.code != 1001) { - let rec = slot.and_then(|s| colors.get_mut(s)); - match x.code { - 460 => g.angle = Some(x.f64()).filter(|a| a.is_finite()).unwrap_or(0.0), - 461 => g.centered = x.f64().abs() < 1e-9, - 463 => slot = Some(slot.map_or(0, |s| s.saturating_add(1))), - 63 => { - if let Some(r) = rec { - r.0 = Some(Color::from_aci(i16::try_from(x.i64()).unwrap_or(256))); - } - } - 421 => { - if let Some(r) = rec { - r.1 = Some(Rgb::from_u32(u32::try_from(x.i64() & 0xff_ffff).unwrap_or(0))); - } - } - 470 => g.name = x.str(), - _ => {} - } - } - let [c1, c2] = colors; - let pick = |(aci, rgb): (Option, Option), d: Color| rgb.map(Color::True).or(aci).unwrap_or(d); - g.color1 = pick(c1, g.color1); - g.color2 = pick(c2, g.color2); - Some(g) -} - /// Handle maps and references gathered while reading. #[derive(Default)] struct Rx { @@ -998,696 +58,18 @@ struct Rx { layers: HashMap, } -/// The plot style names of a file: the entries of the `ACAD_PLOTSTYLENAME` dictionary of the -/// named object dictionary (the first object), by placeholder handle. -fn plot_style_names(secs: &[cadcraft_dxf::Section]) -> HashMap { - let mut out = HashMap::new(); - let Some(objects) = secs.iter().find(|s| s.name == "OBJECTS") else { return out }; - let recs = records(&objects.tags); - let entries = |tags: &[Tag]| { - let mut v: Vec<(String, String)> = Vec::new(); - let mut name: Option = None; - for t in tags { - match t.code { - 3 => name = Some(t.str()), - 350 | 360 => { - if let Some(n) = name.take() { - v.push((n, t.str().trim().to_ascii_uppercase())); - } - } - _ => {} - } - } - v - }; - let Some((_, root)) = recs.iter().find(|(k, _)| k == "DICTIONARY") else { return out }; - let Some((_, dict)) = entries(root).into_iter().find(|(n, _)| n.eq_ignore_ascii_case(crate::dxf_ext::PLOT_STYLE_NAMES_KEY)) else { - return out; - }; - let is_dict = |k: &str| k == "DICTIONARY" || k == "ACDBDICTIONARYWDFLT"; - let Some((_, tags)) = recs.iter().find(|(k, tg)| is_dict(k) && T(tg).s(5).is_some_and(|h| h.trim().eq_ignore_ascii_case(&dict))) else { - return out; - }; - for (n, h) in entries(tags).into_iter().take(crate::dxf_ext::MAX_PLOT_STYLE_NAMES) { - if !n.trim().is_empty() { - out.insert(h, n.chars().take(255).collect()); - } - } - out -} - -/// The named plot style an entity record points at (group 390 among its AcDbEntity groups). -fn entity_plot_style(tags: &[Tag], rx: &Rx) -> Option { - let mut in_entity = false; - for t in tags { - if t.code == 100 { - if in_entity { - return None; - } - in_entity = t.str() == "AcDbEntity"; - } else if t.code == 390 && in_entity { - return rx.pstyles.get(&t.str().trim().to_ascii_uppercase()).cloned(); - } - } - None -} - -/// Dimension overrides and associativity, table references: data of an entity record that -/// needs the file's handle maps. -fn entity_extras(kind: &str, tags: &[Tag], h: Handle, k: &mut EntityKind, rx: &mut Rx) { - match (kind, k) { - ("DIMENSION", EntityKind::Dimension(dm)) => { - dm.overrides = crate::dxf_ext::read_dstyle(tags, &rx.styles, &rx.brs, &rx.ltypes); - dm.assoc = crate::dxf_ext::read_assoc(tags); - if let Some(center) = crate::dxf_ext::read_arclen(tags) { - dm.kind = DimKind::ArcLength; - dm.p15 = center; - } - dm.marks = crate::dxf_ext::read_marks(tags); - // Group 52: the extension lines' angle relative to the dimension line (DIMEDIT Oblique). - let base = match dm.kind { - DimKind::Linear { rotation } => Some(rotation), - DimKind::Aligned => Some((dm.p14.xy() - dm.p13.xy()).angle()), - _ => None, - }; - if let (Some(base), Some(o)) = (base, T(tags).f(52).filter(|o| o.is_finite() && o.rem_euclid(180.0) != 0.0)) { - dm.marks.oblique = Some(cadcraft_geom::norm_angle(base + o.to_radians())); - } - } - ("ACAD_TABLE", EntityKind::Table(_)) => { - let t = T(tags); - if let Some(b) = t.s(2) { - rx.table_blocks.insert(b.to_ascii_uppercase()); - } - let ours = crate::dxf_ext::xdata(tags, crate::dxf_ext::APP).iter().any(|t| t.code == 1000 && t.str() == "TABLE"); - if !ours { - rx.table_style_fix.push((h, t.s(342).map(|sh| sh.trim().to_ascii_uppercase()).unwrap_or_default())); - } - } - ("INSERT", EntityKind::Insert(ins)) => { - rx.inserted.insert(ins.block.to_ascii_uppercase()); - } - ("MULTILEADER" | "MLEADER", EntityKind::MLeader(m)) => { - let pending = crate::dxf_mleader::resolve(tags, m, &rx.styles, &rx.brs); - if let Some(b) = &m.block { - rx.inserted.insert(b.block.to_ascii_uppercase()); - } - rx.mleader_fix.push((h, pending)); - } - // CADCraft's VPFROZEN xdata wins; otherwise AutoCAD's group 341 layer handles. - ("VIEWPORT", EntityKind::Viewport(v)) if v.frozen_layers.is_empty() => { - v.frozen_layers = tags - .iter() - .take_while(|t| t.code < 1000) - .filter(|t| t.code == 341) - .filter_map(|t| rx.layers.get(&t.str().trim().to_ascii_uppercase()).cloned()) - .collect(); - } - _ => {} - } -} - -/// 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`. -fn parse_entities( - recs: &[(String, Vec)], - d: &mut Drawing, - rx: &mut Rx, - skipped: &mut usize, - table_cells_left: &mut usize, -) -> Vec<(Option, bool, Entity)> { - let mut out = Vec::new(); - let mut i = 0; - while let Some((kind, tags)) = recs.get(i) { - let t = T(tags); - let owner = crate::dxf_ext::owner(tags); - let paper = t.i(67) == Some(1); - let handle = t.s(5).and_then(|h| Handle::parse_hex(&h)); - let mut kindent = entity_with_table_budget(kind, tags, table_cells_left); - if let Some((c, _)) = kindent.as_mut() - && let Some(n) = entity_plot_style(tags, rx) - { - c.plot_style = n; - } - // More entities from this record (the faces of a polyface or polygon mesh). - let mut extra: Vec = Vec::new(); - 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, - }) - }; - 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; - } - } - if let Some((_, EntityKind::Insert(ins))) = kindent.as_mut() { - ins.attribs = attribs; - } - } - } - // A VERTEX or ATTRIB with no POLYLINE or INSERT before it; a stray SEQEND holds nothing. - "VERTEX" | "ATTRIB" => { - *skipped = skipped.saturating_add(1); - continue; - } - "SEQEND" => continue, - _ => {} - } - if let Some((c, mut k)) = kindent { - let h = match handle { - Some(h) => { - d.bump_handseed(h); - h - } - None => d.new_handle(), - }; - entity_extras(kind, tags, h, &mut k, rx); - let more: Vec<_> = - extra.into_iter().map(|f| (owner.clone(), paper, Entity { handle: d.new_handle(), common: c.clone(), kind: f })).collect(); - out.push((owner, paper, Entity { handle: h, common: c, kind: k })); - out.extend(more); - } else if kind == "HATCH" { - // Without boundary data. The other records `entity` returns nothing for are read - // elsewhere (the writer's paper viewport, the loops of a region). - *skipped = skipped.saturating_add(1); - } - } - // Assign fresh handles to duplicates (hostile files). - let mut seen = std::collections::HashSet::new(); - for (_, _, e) in &mut out { - if !seen.insert(e.handle) { - e.handle = d.new_handle(); - seen.insert(e.handle); - } - } - out -} - -/// Faces of a polyface mesh: each face record names three or four vertices by 1-based index; -/// a negative index hides the edge that starts at that vertex. Faces with fewer vertices or -/// an index outside the vertex list are skipped. -fn polyface_faces(verts: &[Vec3], faces: &[[i64; 4]]) -> Vec { - let mut out = Vec::new(); - for f in faces { - let mut corners: Vec = Vec::with_capacity(4); - let mut hidden = 0u8; - let mut ok = true; - for &ix in f.iter().filter(|&&ix| ix != 0) { - let at = usize::try_from(ix.unsigned_abs()).ok().and_then(|n| n.checked_sub(1)); - let Some(p) = at.and_then(|n| verts.get(n)) else { - ok = false; - break; - }; - if ix < 0 { - hidden |= 1 << corners.len(); - } - corners.push(*p); - } - match (ok, corners.as_slice()) { - // A triangle's closing edge is the 3DFACE's fourth (the third is degenerate). - (true, [a, b, c]) => out.push(Face3d { corners: [*a, *b, *c, *c], hidden_edges: hidden | if hidden & 4 != 0 { 8 } else { 0 } }), - (true, [a, b, c, d]) => out.push(Face3d { corners: [*a, *b, *c, *d], hidden_edges: hidden }), - _ => {} - } - } - out -} - -/// Quads of an M×N polygon mesh (71/72, or the smoothed 73/74 when the vertex count matches -/// those), closed in M by bit 1 and in N by bit 32. Vertices run row by row. -fn mesh_faces(verts: &[Vec3], t: &T, flags: i64) -> Vec { - let dims = |a: i32, b: i32| Some((usize::try_from(t.i(a)?).ok()?, usize::try_from(t.i(b)?).ok()?)); - let fits = |(m, n): (usize, usize)| m >= 2 && n >= 2 && m.checked_mul(n).is_some_and(|mn| mn <= verts.len()); - let exact = |(m, n): (usize, usize)| fits((m, n)) && m * n == verts.len(); - let Some((m, n)) = [dims(71, 72), dims(73, 74)].into_iter().flatten().find(|d| exact(*d)).or_else(|| dims(71, 72).filter(|d| fits(*d))) else { - return Vec::new(); - }; - let at = |i: usize, j: usize| verts.get((i % m) * n + j % n).copied(); - let rows = if flags & 1 != 0 { m } else { m - 1 }; - let cols = if flags & 32 != 0 { n } else { n - 1 }; - let mut out = Vec::new(); - for i in 0..rows { - for j in 0..cols { - if let (Some(a), Some(b), Some(c), Some(d)) = (at(i, j), at(i, j + 1), at(i + 1, j + 1), at(i + 1, j)) { - out.push(Face3d { corners: [a, b, c, d], hidden_edges: 0 }); - } - } - } - out -} - -fn header(tags: &[Tag], d: &mut Drawing) { - let mut i = 0; - while let Some(t) = tags.get(i) { - if t.code == 9 { - let name = t.str().trim_start_matches('$').to_ascii_uppercase(); - i += 1; - let mut vals: Vec<&Tag> = Vec::new(); - while let Some(v) = tags.get(i) { - if v.code == 9 { - break; - } - vals.push(v); - i += 1; - } - // Legacy files keep spacing in HEADER; modern *ACTIVE VPORT entries override it. - if matches!(name.as_str(), "SNAPUNIT" | "GRIDUNIT") { - let (Some(x), Some(y)) = (vals.iter().find(|t| t.code == 10), vals.iter().find(|t| t.code == 20)) else { continue }; - let p = Vec2::new(x.f64(), y.f64()); - if p.is_finite() && if name == "GRIDUNIT" { p.x >= 0.0 && p.y >= 0.0 } else { p.x > 0.0 && p.y > 0.0 } { - d.header.set(&name, HVal::Point(p.to3(0.0))); - } - continue; - } - let hv = match vals.as_slice() { - [x, y, z] if x.code == 10 => HVal::Point(Vec3::new(x.f64(), y.f64(), z.f64())), - [x, y] if x.code == 10 => HVal::Point(Vec3::new(x.f64(), y.f64(), 0.0)), - [v] => match &v.value { - cadcraft_dxf::Value::Real(r) => HVal::Real(*r), - cadcraft_dxf::Value::Int(n) => HVal::Int(*n), - cadcraft_dxf::Value::Bool(b) => HVal::Int(i64::from(*b)), - other => HVal::Str(other.as_str()), - }, - _ => continue, - }; - // $DIMDSEP holds a character code; the dimension style holds the character. - let hv = match hv { - HVal::Int(n) if name == "DIMDSEP" => match u32::try_from(n).ok().and_then(char::from_u32).filter(|c| !c.is_control()) { - Some(c) => HVal::Str(c.to_string()), - None => continue, - }, - // $DIMJOGANG is in radians; the variable (like the style) holds degrees. - HVal::Real(r) if name == "DIMJOGANG" => HVal::Real((r.to_degrees() * 1e9).round() / 1e9), - // $ANGBASE is in degrees (a drawing measured from north has 90.0); the variable - // holds radians. - HVal::Real(r) if name == "ANGBASE" => HVal::Real(r.to_radians()), - hv => hv, - }; - d.header.set(&name, hv); - } else { - i += 1; - } - } -} - -/// True when a style record carries `AcadAnnotative` xdata with the annotative flag set. -fn annotative(tags: &[Tag]) -> bool { - let list = crate::dxf_ext::xdata_list(crate::dxf_ext::xdata(tags, "AcadAnnotative"), "AnnotativeData"); - list.iter().filter(|t| t.code == 1070).nth(1).is_some_and(|t| t.i64() != 0) -} - -/// A text style's font: CADCraft's own font name from `CADCRAFT` xdata (`1000 FONT`, name) -/// while the font file is still the `txt` written for other readers; the TrueType family of the -/// style's `ACAD` xdata (`1000` typeface) when it has no font file (how AutoCAD stores TrueType -/// styles); otherwise the file. -fn style_font(tags: &[Tag], file: String) -> String { - if file.trim().is_empty() - && let Some(face) = - crate::dxf_ext::xdata(tags, "ACAD").iter().find(|t| t.code == 1000).map(|t| t.str().trim().to_string()).filter(|f| !f.is_empty()) - { - return face; - } - let x = crate::dxf_ext::xdata(tags, crate::dxf_ext::APP); - let own = x.windows(2).find_map(|w| match w { - [m, n] if m.code == 1000 && m.str() == "FONT" && n.code == 1000 => Some(n.str()), - _ => None, - }); - let stem = file.trim().to_ascii_lowercase(); - match own { - Some(name) if !name.trim().is_empty() && (stem == "txt" || stem == "txt.shx") => name, - _ => file, - } -} - -/// Load symbol tables and resolve handle references, indexing layer names for case-insensitive updates. -fn tables(tags: &[Tag], d: &mut Drawing, rx: &mut Rx) { - let recs = records(tags); - // STYLE handle → name, or the shape file of an unnamed shape style (linetype elements). - let ltype_styles: HashMap = recs - .iter() - .filter(|(k, _)| k == "STYLE") - .filter_map(|(_, tg)| { - let t = T(tg); - let name = t.s(2).filter(|n| !n.trim().is_empty()); - Some((t.s(5)?.trim().to_ascii_uppercase(), name.or_else(|| t.s(3))?)) - }) - .collect(); - // Handle maps first: DIMSTYLE records refer to text styles and arrow blocks by handle. - for (kind, tg) in &recs { - let t = T(tg); - let (Some(h), Some(n)) = (t.s(5), t.s(2)) else { continue }; - match kind.as_str() { - "STYLE" => { - rx.styles.insert(h.trim().to_ascii_uppercase(), n); - } - "BLOCK_RECORD" => { - let units = t.i(70).and_then(|u| u8::try_from(u).ok()).unwrap_or(0); - rx.block_props.insert(n.to_ascii_uppercase(), (units, t.i(280).unwrap_or(1) != 0)); - if let Some(a) = crate::dxf_ext::read_array(tg) { - rx.block_arrays.insert(n.to_ascii_uppercase(), a); - } - rx.brs.insert(h.trim().to_ascii_uppercase(), n); - } - "LTYPE" => { - rx.ltypes.insert(h.trim().to_ascii_uppercase(), n); - } - "LAYER" => { - rx.layers.insert(h.trim().to_ascii_uppercase(), n); - } - _ => {} - } - } - let mut active_vport = false; - // Index of layers already appended, by lower-cased name, so each LAYER record updates or - // appends in O(1) instead of scanning the growing layer list — otherwise many distinct layer - // names make import quadratic (CWE-407). - let mut layer_index: HashMap = d.layers.iter().enumerate().map(|(i, l)| (l.name.to_ascii_lowercase(), i)).collect(); - for (kind, tg) in recs { - let t = T(&tg); - match kind.as_str() { - "LAYER" => { - let name = t.s(2).unwrap_or_default(); - if name.is_empty() || d.layers.len() >= MAX_OBJECTS { - continue; - } - let aci = t.i(62).unwrap_or(7); - let flags = t.i(70).unwrap_or(0); - let mut l = Layer { - name: name.clone(), - color: Color::Index(aci.unsigned_abs().clamp(1, 255) as u8), - linetype: t.s(6).unwrap_or_else(|| "Continuous".into()), - lineweight: t.i(370).map(|v| Lineweight::from_dxf(v as i16)).unwrap_or(Lineweight::Default), - on: aci >= 0, - frozen: flags & 1 != 0, - locked: flags & 4 != 0, - vp_freeze_new: flags & 2 != 0, - plot: t.i(290).unwrap_or(1) != 0, - ..Layer::default() - }; - if let Some(tc) = t.i(420) { - l.color = Color::True(Rgb::from_u32(tc as u32)); - } - if let Some(n) = t.s(390).and_then(|h| rx.pstyles.get(&h.trim().to_ascii_uppercase())) { - l.plot_style = n.clone(); - } - let alpha = crate::dxf_ext::xdata(&tg, crate::dxf_ext::LAYER_TRANSPARENCY_APP).iter().find(|x| x.code == 1071); - if let Some(pct) = alpha.and_then(|x| crate::dxf_ext::transparency_from_dxf(x.i64())) { - l.transparency = pct; - } - if let Some(desc) = crate::dxf_ext::xdata(&tg, crate::dxf_ext::LAYER_DESCRIPTION_APP).iter().filter(|x| x.code == 1000).nth(1) { - l.description = desc.str(); - } - match layer_index.get(&name.to_ascii_lowercase()) { - Some(&i) => { - if let Some(x) = d.layers.get_mut(i) { - *x = l; - } - } - None => { - layer_index.insert(name.to_ascii_lowercase(), d.layers.len()); - d.layers.push(l); - } - } - } - "LTYPE" => { - let name = t.s(2).unwrap_or_default(); - if name.is_empty() || d.linetype(&name).is_some() || d.linetypes.len() >= MAX_OBJECTS { - continue; - } - let pattern = ltype_pattern(&tg, <ype_styles); - d.linetypes.push(Linetype { name, description: t.s(3).unwrap_or_default(), pattern }); - } - "STYLE" => { - let name = t.s(2).unwrap_or_default(); - if name.is_empty() || d.text_styles.len() >= MAX_OBJECTS { - continue; - } - // 70 bit 4: vertical; 71 bits 2/4: backwards / upside down. - let gen_flags = t.i(71).unwrap_or(0); - let st = TextStyle { - name: name.clone(), - font: style_font(&tg, t.s(3).unwrap_or_default()), - big_font: t.s(4).unwrap_or_default(), - height: t.fd(40, 0.0), - width_factor: t.fd(41, 1.0), - oblique: t.fd(50, 0.0).to_radians(), - backwards: gen_flags & 2 != 0, - upside_down: gen_flags & 4 != 0, - vertical: t.i(70).unwrap_or(0) & 4 != 0, - annotative: annotative(&tg), - }; - match d.text_styles.iter_mut().find(|s| s.name.eq_ignore_ascii_case(&name)) { - Some(x) => *x = st, - None => d.text_styles.push(st), - } - } - // The first *ACTIVE viewport holds the current snap/grid settings. - "VPORT" if !active_vport && t.s(2).is_some_and(|n| n.trim().eq_ignore_ascii_case("*ACTIVE")) => { - active_vport = true; - vport_record(d, &t); - for (name, code) in [("SNAPUNIT", 14), ("GRIDUNIT", 15)] { - if let (Some(x), Some(y)) = (t.f(code), t.f(code + 10)) { - let p = Vec2::new(x, y); - if p.is_finite() && if name == "GRIDUNIT" { p.x >= 0.0 && p.y >= 0.0 } else { p.x > 0.0 && p.y > 0.0 } { - d.header.set(name, HVal::Point(p.to3(0.0))); - } - } - } - if let (Some(x), Some(y)) = (t.f(13), t.f(23)) - && x.is_finite() - && y.is_finite() - { - d.header.set("SNAPBASE", HVal::Point(Vec3::new(x, y, 0.0))); - } - if let Some(a) = t.f(50).filter(|a| a.is_finite()) { - d.header.set_f64("SNAPANG", a); - } - } - "VIEW" => { - let name = t.s(2).unwrap_or_default(); - let (h, w) = (t.fd(40, 0.0), t.fd(41, 0.0)); - if name.is_empty() || !(h.is_finite() && w.is_finite() && h > 0.0 && w > 0.0) || d.views.len() >= MAX_OBJECTS { - continue; - } - let center = t.p(10).xy(); - if !(center.x.is_finite() && center.y.is_finite()) { - continue; - } - let x = crate::dxf_ext::xdata(&tg, crate::dxf_ext::APP); - let field = |key: &str| { - x.iter().position(|t| t.code == 1000 && t.str() == key).and_then(|i| x.get(i + 1)).filter(|t| t.code == 1000).map(Tag::str) - }; - // 70 bit 1: a paper space view; its layout is CADCraft xdata. - let layout = (t.i(70).unwrap_or(0) & 1 != 0).then(|| field("LAYOUT").unwrap_or_default()); - let twist = t.f(50).filter(|a| a.is_finite()).map_or(0.0, |a| a.rem_euclid(360.0).to_radians()); - let v = NamedView { name: name.clone(), center, height: h, width: w, layer_state: field("LAYERSTATE"), twist, layout }; - match d.views.iter_mut().find(|x| x.name.eq_ignore_ascii_case(&name)) { - Some(x) => *x = v, - None => d.views.push(v), - } - } - "UCS" => { - let name = t.s(2).unwrap_or_default(); - let (origin, x_axis, y_axis) = (t.p(10), t.p(11), t.p(12)); - let finite = [origin, x_axis, y_axis].iter().all(|p| p.x.is_finite() && p.y.is_finite() && p.z.is_finite()); - if name.is_empty() || !finite || d.ucss.len() >= MAX_OBJECTS { - continue; - } - let u = Ucs { name: name.clone(), origin, x_axis, y_axis }; - match d.ucss.iter_mut().find(|x| x.name.eq_ignore_ascii_case(&name)) { - Some(x) => *x = u, - None => d.ucss.push(u), - } - } - "DIMSTYLE" => { - let name = t.s(2).unwrap_or_default(); - if name.is_empty() || d.dim_styles.len() >= MAX_OBJECTS { - continue; - } - // 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(); - // 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); - } - } - fields.extend(crate::dxf_ext::read_newer_dimvars(&tg, &rx.styles, &rx.brs, &rx.ltypes)); - let mut st = DimStyle { name: name.clone(), annotative: annotative(&tg), ..DimStyle::default() }; - st.apply_fields(&fields); - match d.dim_styles.iter_mut().find(|s| s.name.eq_ignore_ascii_case(&name)) { - Some(x) => *x = st, - None => d.dim_styles.push(st), - } - } - "VPORT" => vport_record(d, &t), - _ => {} - } - } - // One *Active viewport is no tiling: the view is the editor's (it zooms to the extents). - d.vports.retain(|c| c.tiles.len() > usize::from(c.is_active())); -} - -/// Most tiles a viewport configuration keeps. -const MAX_VPORT_TILES: usize = 64; - -/// A VPORT record: one tile of the configuration it names (`*ACTIVE` the one shown). Corners -/// are fractions of the drawing area; unusable tiles are skipped. -fn vport_record(d: &mut Drawing, t: &T) { - let name = t.s(2).map(|n| n.trim().to_string()).unwrap_or_default(); - let (min, max, center) = (t.p(10).xy(), t.p(11).xy(), t.p(12).xy()); - let height = t.fd(40, 0.0); - let unit = |v: f64| v.is_finite() && (-1e-9..=1.0 + 1e-9).contains(&v); - let ok = [min.x, min.y, max.x, max.y].into_iter().all(unit) - && max.x > min.x + 1e-6 - && max.y > min.y + 1e-6 - && center.is_finite() - && height.is_finite() - && height > 0.0; - if name.is_empty() || !ok { - return; - } - let aspect = t.f(41).filter(|a| a.is_finite() && *a > 0.0).unwrap_or(0.0); - let clamp = |p: Vec2| Vec2::new(p.x.clamp(0.0, 1.0), p.y.clamp(0.0, 1.0)); - let tile = VportTile { min: clamp(min), max: clamp(max), center, height, aspect }; - let name = if name.eq_ignore_ascii_case(VportConfig::ACTIVE) { VportConfig::ACTIVE.to_string() } else { name }; - let room = d.vports.len() < MAX_OBJECTS; - match d.vports.iter_mut().find(|c| c.name.eq_ignore_ascii_case(&name)) { - Some(c) if c.tiles.len() < MAX_VPORT_TILES => c.tiles.push(tile), - Some(_) => {} - None if room => d.vports.push(VportConfig { name, tiles: vec![tile] }), - None => {} - } -} - -/// The dash elements of an LTYPE record with their embedded text and shapes (DXF Reference, -/// LTYPE): each `49` length starts an element; `74` type flags (1 absolute rotation, 2 text, -/// 4 shape), `75` shape number, `340` STYLE, `46` scale, `50` rotation (radians), `44`/`45` -/// offset and `9` text follow it. -fn ltype_pattern(tags: &[Tag], styles: &HashMap) -> Vec { - let mut out: Vec = Vec::new(); - for t in tags.iter().take_while(|t| t.code < 1000) { - if t.code == 49 { - out.push(DashElement::dash(t.f64())); - continue; - } - let Some(el) = out.last_mut() else { continue }; - let complex = el.text.is_some() || el.shape.is_some(); - let v = t.f64(); - match t.code { - 74 => { - let flags = t.i64(); - el.absolute = flags & 1 != 0; - if flags & 2 != 0 { - el.text = Some(String::new()); - } else if flags & 4 != 0 { - el.shape = Some(0); - } - } - 75 if el.shape.is_some() => el.shape = Some(u16::try_from(t.i64()).unwrap_or(0)), - 340 if complex => el.style = styles.get(&t.str().trim().to_ascii_uppercase()).filter(|n| !n.trim().is_empty()).cloned(), - 9 if el.text.is_some() => el.text = Some(t.str()), - 46 if complex && v.is_finite() => el.scale = v, - 50 if complex && v.is_finite() => el.rotation = v, - 44 if complex && v.is_finite() => el.offset.x = v, - 45 if complex && v.is_finite() => el.offset.y = v, - _ => {} - } - } - out -} - /// Read a DXF file into a drawing. 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()))?; @@ -1700,8 +82,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: @@ -1721,156 +103,23 @@ 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 mut paper_block_for: HashMap = HashMap::new(); - layout_objs.sort_by_key(|l| l.1); - for (name, order, br, page) in &layout_objs { - if name.eq_ignore_ascii_case("Model") { - continue; - } - d.layouts.push(Layout { page: page.clone(), ..Layout::new(name, *order) }); - if let Some(bname) = rx.brs.get(br) { - paper_block_for.insert(bname.to_ascii_uppercase(), name.clone()); - } - } - if d.layouts.is_empty() { - d.layouts.push(Layout::new("Layout1", 1)); - paper_block_for.insert("*PAPER_SPACE".into(), "Layout1".into()); - } - // The layout in `*Paper_Space` is the active one, kept when it isn't the first tab (the first - // is active by default); paper-space entities without an owner go there. - let active = paper_block_for.get("*PAPER_SPACE").cloned(); - 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. - for (name, base, description, ents, xref) in blocks { - let up = name.to_ascii_uppercase(); - if up == "*MODEL_SPACE" { - for (_, _, e) in ents { - d.ensure_layer(&e.common.layer); - d.model.push(e); - } - continue; - } - if up.starts_with("*PAPER_SPACE") { - let lname = paper_block_for.get(&up).cloned().unwrap_or_else(|| first_layout.clone()); - if let Some(l) = d.layouts.iter_mut().find(|l| l.name == lname) { - for (_, _, e) in ents { - l.entities.push(e); - } - } - continue; - } - // Generated geometry we rebuild on save: table blocks, and standard arrowhead blocks - // nothing inserts whose content is the one CADCraft writes. An arrowhead block with other - // content (AutoCAD's `_Dot`, a user's redefinition) is kept, so dimensions keep it. - let generated_arrow = || { - let own: Vec<&Entity> = ents.iter().map(|(_, _, e)| e).collect(); - crate::dxf_ext::is_standard_arrow(&name) && !rx.inserted.contains(&up) && crate::dxf_write::is_generated_arrow(&name, &own) - }; - if rx.table_blocks.contains(&up) || generated_arrow() { - continue; - } - let mut b = Block::new(&name); - b.base = base; - b.description = description; - if let Some(&(units, explodable)) = rx.block_props.get(&up) { - (b.units, b.explodable) = (units, explodable); - } - if xref & 4 != 0 { - b.xref_path = Some(String::new()); - b.xref_overlay = xref & 8 != 0; - } else { - for (_, _, e) in ents { - b.entities.push(e); - } - } - // Array parameters, with the block names the items use (other programs may rename - // anonymous blocks); dropped when the items no longer match them. - b.array = rx.block_arrays.get(&up).cloned().and_then(|mut a| a.sync_names(b.entities.iter().map(|e| e.as_ref())).then_some(a)); - if let Some(path) = rx.xref_paths.remove(&up) { - b.xref_path = Some(path); - } - d.blocks.insert(name, std::sync::Arc::new(b)); - } - // ENTITIES section: model space, or paper space when flagged (67=1). - 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()); - if let Some(l) = d.layouts.iter_mut().find(|l| l.name == lname) { - l.entities.push(e); - } - } else { - d.model.push(e); - } - } + 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); @@ -1888,545 +137,6 @@ fn read_budgeted(bytes: &[u8], mut table_cells_left: usize) -> Result { Ok(d) } -fn count_skipped(skipped: &mut std::collections::BTreeMap, section: &str, n: usize) { - if n > 0 { - let c = skipped.entry(section.to_string()).or_insert(0); - *c = c.saturating_add(n); - } -} - -/// Records between sections, which [`sections`] passes over: everything after an ENDSEC up to the -/// next SECTION. Stray ENDSECs and the EOF hold nothing and don't count. -fn outside_sections(tags: &[Tag]) -> usize { - let mut inside = false; - let mut n = 0usize; - for t in tags.iter().filter(|t| t.code == 0) { - match t.str().as_str() { - "SECTION" => inside = true, - "ENDSEC" => inside = false, - "EOF" => {} - _ if !inside => n = n.saturating_add(1), - _ => {} - } - } - n -} - -/// A SORTENTSTABLE (DXF Reference, OBJECTS section): after its AcDbSortentsTable marker, the -/// owning block record (330), then pairs of an entity handle (331) and the sort handle (5) it is -/// drawn by. -fn sortents_table(tags: &[Tag]) -> Option<(String, HashMap)> { - let start = tags.iter().position(|t| t.code == 100 && t.str() == "AcDbSortentsTable")?; - let tail = tags.get(start + 1..)?; - let owner = tail.iter().find(|t| t.code == 330)?.str().trim().to_ascii_uppercase(); - let mut map = HashMap::new(); - let mut ent: Option = None; - for t in tail { - match t.code { - 331 => ent = Handle::parse_hex(&t.str()), - 5 => { - if let (Some(e), Some(k)) = (ent.take(), Handle::parse_hex(&t.str())) - && map.len() < MAX_OBJECTS - { - map.insert(e, k.0); - } - } - _ => {} - } - } - (!map.is_empty()).then_some((owner, map)) -} - -/// Draw each space with a SORTENTSTABLE in sort-handle order: an entity listed in the table by -/// its sort handle, any other by its own handle. -fn apply_sortents( - d: &mut Drawing, - tables: &[(String, HashMap)], - brs: &HashMap, - paper_for: &HashMap, - first_layout: &str, -) { - for (owner, map) in tables { - let Some(name) = brs.get(owner) else { continue }; - let up = name.to_ascii_uppercase(); - let key = |e: &Entity| map.get(&e.handle).copied().unwrap_or(e.handle.0); - if up == "*MODEL_SPACE" { - d.model.sort_by_key(key); - } else if up.starts_with("*PAPER_SPACE") { - let lname = paper_for.get(&up).map(String::as_str).unwrap_or(first_layout); - if let Some(l) = d.layouts.iter_mut().find(|l| l.name == lname) { - l.entities.sort_by_key(key); - } - } else if let Some(b) = d.blocks.get_mut(name.as_str()) { - std::sync::Arc::make_mut(b).entities.sort_by_key(key); - } - } -} - -/// Non-graphical objects applied once every entity is placed. -#[derive(Default)] -struct Objects { - /// Dictionary entry handle (upper case) → entry name. - names: HashMap, - /// SCALE objects (the annotation scale list), in file order. - scales: Vec, - /// XRECORD handle (upper case) → its groups. - xrecords: HashMap>, - /// TABLESTYLE objects: (handle, groups). - table_styles: Vec<(String, Vec)>, - /// DIMASSOC objects. - dimassocs: Vec>, - /// MLEADERSTYLE objects: (handle, groups). - mleader_styles: Vec<(String, Vec)>, - /// GROUP objects: (handle, groups). - groups: Vec<(String, Vec)>, - /// Saved paper-space views of layouts: (layout name, view). - 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; - -impl Objects { - fn add(&mut self, kind: &str, tags: &[Tag]) { - let h = T(tags).s(5).map(|h| h.trim().to_ascii_uppercase()).unwrap_or_default(); - match kind { - "SCALE" if self.scales.len() < cadcraft_doc::MAX_SCALES => { - if let Some(s) = crate::dxf_ext::read_scale(tags) - && !self.scales.iter().any(|x| x.name.eq_ignore_ascii_case(&s.name)) - { - self.scales.push(s); - } - } - "DICTIONARY" => { - let mut name: Option = None; - for t in tags { - match t.code { - 3 => name = Some(t.str()), - 350 | 360 => { - if let Some(n) = name.take() - && self.names.len() < MAX_OBJECTS - { - self.names.insert(t.str().trim().to_ascii_uppercase(), n); - } - } - _ => {} - } - } - } - "XRECORD" if self.xrecords.len() < MAX_OBJECTS => { - self.xrecords.insert(h, tags.to_vec()); - } - "TABLESTYLE" if self.table_styles.len() < MAX_OBJECTS => self.table_styles.push((h, tags.to_vec())), - "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); - } - } - "LAYOUT" if self.layout_views.len() < MAX_OBJECTS => { - let start = tags.iter().position(|x| x.code == 100 && x.str() == "AcDbLayout").unwrap_or(0); - if let (Some(name), Some(view)) = (T(tags.get(start..).unwrap_or(&[])).s(1), layout_view(tags)) { - self.layout_views.push((name, view)); - } - } - _ => {} - } - } - - /// The text of the CADCraft XRECORD stored under `key` in a dictionary (its 1/3 strings). - fn xrecord_text(&self, key: &str) -> Option { - let h = self.names.iter().find(|(_, n)| n.as_str() == key).map(|(h, _)| h)?; - let tags = self.xrecords.get(h)?; - let mut text = String::new(); - for t in tags.iter().filter(|t| t.code == 1 || t.code == 3) { - if text.len() > crate::dxf_ext::MAX_PAYLOAD { - break; - } - text.push_str(&t.str()); - } - Some(text) - } - - 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); - } - // 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 }; - let st = crate::dxf_table::read_table_style(name, tags); - match d.table_styles.iter_mut().find(|s| s.name.eq_ignore_ascii_case(name)) { - Some(x) => *x = st, - None => d.table_styles.push(st), - } - } - 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 - // style's, so every cell style height applies as it is. - let height = { - let style = name.as_deref().unwrap_or("Standard"); - d.table_styles - .iter() - .find(|s| s.name.eq_ignore_ascii_case(style)) - .map_or(cadcraft_doc::TableStyle::default().text_height, |s| s.text_height) - }; - let _ = d.modify_entity(*h, |e| { - if let EntityKind::Table(t) = &mut e.kind { - if let Some(name) = name { - t.style = name; - } - t.text_height = height; - } - }); - } - // 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; - d.parametric = p; - } - // The annotation scale list (a metric drawing without one gets the metric defaults), - // then CADCraft's annotative scaling record. - if !self.scales.is_empty() { - d.scales = self.scales.clone(); - } else if d.header.i64("MEASUREMENT", 0) == 1 { - d.scales = cadcraft_doc::metric_scales(); - } - if let Some(p) = self.xrecord_text(crate::dxf_ext::ANNOTATION_KEY).and_then(|t| crate::dxf_ext::parse_anno(&t)) { - crate::dxf_ext::apply_anno(d, p); - } - // Saved layer states. - if let Some(states) = self.xrecord_text(crate::dxf_ext::LAYER_STATES_KEY).and_then(|t| crate::dxf_ext::parse_layer_states(&t)) { - d.layer_states = states; - } - // The drawing's plot style tables. - 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), - } - } - 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), - } - } - // Standard associativity, for dimensions without CADCraft's exact links. - for tags in &self.dimassocs { - dimassoc(d, tags); - } - } -} - -/// Associativity from a DIMASSOC object (files from other writers). The dimension kind decides -/// which definition points the references name, in the order AutoCAD writes them: `p13`/`p14` -/// for linear, aligned and arc-length dimensions, both ends of both lines (`p13`..`p16`) for -/// angular dimensions, the three points of an angular-3-point dimension, `p13` for ordinate -/// dimensions, and for radial or diametric dimensions the single circle reference -/// ([`radial_links`]). CADCraft's own xdata links, when present, win. -fn dimassoc(d: &mut Drawing, tags: &[Tag]) { - let start = tags.iter().position(|x| x.code == 100 && x.str() == "AcDbDimAssoc").unwrap_or(0); - let tail = tags.get(start..).unwrap_or(&[]); - let Some(dim_h) = tail.iter().find(|x| x.code == 330).and_then(|x| Handle::parse_hex(&x.str())) else { return }; - let Some(EntityKind::Dimension(dm)) = d.entity(dim_h).map(|e| &e.kind) else { return }; - if !dm.assoc.is_empty() { - return; - } - // A radial or diametric dimension names its circle once, by its centre. - if matches!(dm.kind, DimKind::Radius | DimKind::Diameter) { - let links = radial_links(d, dm, tail); - if !links.is_empty() { - let _ = d.modify_entity(dim_h, |e| { - if let EntityKind::Dimension(dm) = &mut e.kind { - dm.assoc = links; - } - }); - } - return; - } - let slots: &[&str] = match dm.kind { - DimKind::Linear { .. } | DimKind::Aligned => &["p13", "p14"], - DimKind::Angular => &["p13", "p14", "p15", "p16"], - DimKind::Angular3P => &["p13", "p14", "p15"], - DimKind::ArcLength => &["p13", "p14"], - DimKind::Ordinate { .. } => &["p13"], - DimKind::Radius | DimKind::Diameter => return, - }; - let flags = T(tail).i(90).unwrap_or(0); - let used: Vec<&str> = slots.iter().enumerate().filter(|(i, _)| flags & (1 << i) != 0).map(|(_, s)| *s).collect(); - // Point references start at each `1 AcDbOsnapPointRef`. - let starts: Vec = tail.iter().enumerate().filter(|(_, x)| x.code == 1).map(|(i, _)| i).take(8).collect(); - let mut links = Vec::new(); - for (k, s) in starts.iter().enumerate() { - let Some(point) = used.get(k) else { break }; - let end = starts.get(k + 1).copied().unwrap_or(tail.len()); - let r = T(tail.get(s + 1..end).unwrap_or(&[])); - let Some(main) = r.s(331).and_then(|h| Handle::parse_hex(&h)) else { continue }; - let other = r.s(332).and_then(|h| Handle::parse_hex(&h)); - let at = r.p(10).xy(); - // A polyline's segment (subentity type 73 = 2, edge; 91 its 1-based index). - let segment = r.i(91).filter(|k| *k >= 1 && r.i(73) == Some(2)); - if let Some(snap) = infer_snap(d, r.i(72).unwrap_or(0), main, other, at, segment) { - links.push(DimAssoc { point: (*point).to_string(), handle: main, snap }); - } - } - if !links.is_empty() { - let _ = d.modify_entity(dim_h, |e| { - if let EntityKind::Dimension(dm) = &mut e.kind { - dm.assoc = links; - } - }); - } -} - -/// Associations of a radial or diametric dimension from its one standard reference: the circle -/// or arc it measures, by its centre (osnap 3). The definition points are then on that circle, -/// so the dimension follows its radius or diameter. -fn radial_links(d: &Drawing, dm: &Dimension, tail: &[Tag]) -> Vec { - let starts: Vec = tail.iter().enumerate().filter(|(_, x)| x.code == 1 && x.str() == "AcDbOsnapPointRef").map(|(i, _)| i).take(8).collect(); - for (k, s) in starts.iter().enumerate() { - let end = starts.get(k + 1).copied().unwrap_or(tail.len()); - let r = T(tail.get(s + 1..end).unwrap_or(&[])); - if !matches!(r.i(72), Some(3 | 4)) { - continue; - } - let Some(handle) = r.s(331).and_then(|h| Handle::parse_hex(&h)) else { continue }; - let Some(centre) = circle_centre(d, handle) else { continue }; - let on = |point: &str, p: Vec3| { - let angle = centre.angle_to(p.xy()); - angle.is_finite().then(|| DimAssoc { point: point.into(), handle, snap: AssocSnap::OnCircle { angle } }) - }; - let links = match dm.kind { - DimKind::Radius => on("p15", dm.p15).map(|p15| vec![DimAssoc { point: "defpt".into(), handle, snap: AssocSnap::Center }, p15]), - DimKind::Diameter => [on("defpt", dm.defpt), on("p15", dm.p15)].into_iter().collect::>>(), - _ => None, - }; - if let Some(links) = links { - return links; - } - } - Vec::new() -} - -/// The planar centre of a circle or arc, or `None` for any other entity: the point a radial or -/// diametric dimension's single DIMASSOC reference names. -fn circle_centre(d: &Drawing, h: Handle) -> Option { - match &d.entity(h)?.kind { - EntityKind::Circle(c) => Some(c.center.xy()), - EntityKind::Arc(a) => Some(a.center.xy()), - _ => None, - } -} - -/// A CADCraft snap from a DIMASSOC object snap type and point, and for a polyline the segment -/// the point is an end of: AutoCAD writes no usable point there (10/20 are 0, #1049), the segment -/// and the osnap (1 its end, 13 its start) name the vertex. For a line or arc the osnap alone -/// says which end: 13 is the start and 1 the end, whatever the stored point (the point is -/// 0,0,0 for curves, and choosing by distance gave the wrong end for arcs). A point entity has -/// only one position, so both codes mean it. -fn infer_snap(d: &Drawing, osnap: i64, main: Handle, other: Option, at: Vec2, segment: Option) -> Option { - let e = d.entity(main)?; - if let (EntityKind::LwPolyline(p), Some(k), 1 | 13) = (&e.kind, segment, osnap) { - let n = p.vertices.len(); - let segments = if p.closed { n } else { n.saturating_sub(1) }; - let k = usize::try_from(k).ok().filter(|k| *k <= segments)?; - let index = if osnap == 13 { k - 1 } else { k % n }; - return Some(AssocSnap::Vertex { index }); - } - Some(match osnap { - 1 | 13 => match &e.kind { - // AutoCAD names a curve's start with 13 and its end with 1 (the stored point is 0,0,0). - // Other writers use 1 for either end of a line with the point stored: take the nearer. - EntityKind::Line(l) => { - if osnap == 13 || (at != Vec2::ZERO && l.a.xy().dist(at) <= l.b.xy().dist(at)) { - AssocSnap::Start - } else { - AssocSnap::End - } - } - EntityKind::Arc(_) => { - if osnap == 13 { - AssocSnap::Start - } else { - AssocSnap::End - } - } - EntityKind::LwPolyline(p) => { - let index = p.vertices.iter().enumerate().min_by(|x, y| x.1.p.dist(at).total_cmp(&y.1.p.dist(at))).map(|(i, _)| i)?; - AssocSnap::Vertex { index } - } - EntityKind::Point(_) => AssocSnap::Start, - _ => return None, - }, - 2 => AssocSnap::Mid, - 3 | 4 => AssocSnap::Center, - 5 | 10 => match &e.kind { - EntityKind::Circle(Circle { center, .. }) | EntityKind::Arc(Arc { center, .. }) => { - let v = at - center.xy(); - AssocSnap::OnCircle { angle: v.y.atan2(v.x) } - } - _ => return None, - }, - 6 => AssocSnap::Intersection { other: other.filter(|o| d.entity(*o).is_some())? }, - _ => return None, - }) -} - -/// Page setup from a LAYOUT object's AcDbPlotSettings fields (sizes and margins in mm). -fn page_setup(t: &T) -> PageSetup { - let mut p = PageSetup::default(); - let mm = |code: i32, d: f64| { - let v = t.fd(code, d); - if v.is_finite() && (0.0..=100_000.0).contains(&v) { v } else { d } - }; - let (w, h) = (mm(44, 0.0), mm(45, 0.0)); - if w > 0.0 && h > 0.0 { - p.width_mm = w.min(h); - p.height_mm = w.max(h); - } - p.margins_mm = [mm(40, p.margins_mm[0]), mm(41, p.margins_mm[1]), mm(42, p.margins_mm[2]), mm(43, p.margins_mm[3])]; - // Plot paper units 1: millimetres. 0 (inches) is left to MEASUREMENT: earlier CADCraft - // versions wrote 0 for every layout. - p.paper_mm = t.i(72) == Some(1); - // Plot rotation 1 or 3 turns the media a quarter turn; media defined wider than tall is - // already landscape. - let rotated = matches!(t.i(73), Some(1 | 3)); - if w > 0.0 && h > 0.0 { - p.landscape = rotated != (w > h); - } else if t.i(73).is_some() { - p.landscape = rotated; - } - if let Some(name) = t.s(4).filter(|n| !n.is_empty()) { - p.paper = name.replace('_', " "); - } - if let Some(dev) = t.s(2).map(|s| s.trim().to_string()) { - p.device = if dev.is_empty() || dev.eq_ignore_ascii_case("none_device") { "None".into() } else { dev }; - } - // Plot layout flags: 2 display plot styles, 4 centred, 16 standard scale (75 = 0: scaled - // to fit), 128 lineweights. - if let Some(flags) = t.i(70) { - p.show_plot_styles = flags & 2 != 0; - p.center = flags & 4 != 0; - p.lineweights = flags & 128 != 0; - p.scale_to_fit = flags & 16 != 0 && t.i(75) == Some(0); - } - // Custom scale: 142 paper units per 143 drawing units; 147 is the scale factor. - let pos = |v: Option| v.filter(|v| v.is_finite() && *v > 0.0); - let ratio = pos(t.f(142)).zip(pos(t.f(143))).map(|(n, d)| n / d); - if let Some(s) = pos(ratio).or_else(|| pos(t.f(147))) { - p.scale = s; - } - p.plot_area = match t.i(74) { - Some(0) => "display", - Some(1) => "extents", - Some(2) => "limits", - Some(4) => "window", - _ => "layout", - } - .into(); - p.plot_window = [t.fd(48, 0.0), t.fd(49, 0.0), t.fd(140, 0.0), t.fd(141, 0.0)].map(|v| if v.is_finite() { v } else { 0.0 }); - p.plot_style_table = t.s(7).unwrap_or_default(); - p -} - -/// A layout's saved paper-space view (centre, height) from CADCraft xdata. -fn layout_view(tags: &[Tag]) -> Option<(Vec2, f64)> { - let list = crate::dxf_ext::xdata_list(crate::dxf_ext::xdata(tags, crate::dxf_ext::APP), "PSVIEW"); - let g = |c: i32| list.iter().find(|t| t.code == c).map(Tag::f64).filter(|v| v.is_finite()); - Some((Vec2::new(g(1010)?, g(1020)?), g(1040)?)) -} - -/// A HELIX (DXF Reference): the axis base point, start point, axis, radius, turns, turn height, -/// handedness and constraint after the AcDbHelix marker; the top radius is how far the end of its -/// spline (AcDbSpline: the last control point, else fit point) lies from the axis. -fn helix(tags: &[Tag]) -> Helix { - let hs = tags.iter().position(|t| t.code == 100 && t.str() == "AcDbHelix").unwrap_or(tags.len()); - let ss = tags.iter().position(|t| t.code == 100 && t.str() == "AcDbSpline").unwrap_or(0).min(hs); - let spline = tags.get(ss..hs).unwrap_or(&[]); - let t = T(tags.get(hs..).unwrap_or(&[])); - // The last point of the spline (3D) with group `code`. - let last = |code: i32| { - let i = spline.iter().rposition(|g| g.code == code)?; - let f = |c: i32| spline.get(i..).and_then(|r| r.iter().find(|g| g.code == c)).map(Tag::f64).unwrap_or(0.0); - Some(Vec3::new(f(code), f(code + 10), f(code + 20))) - }; - let center = t.p(10); - let start = t.p(11); - let axis = t.p(12); - let axis = if axis.is_finite() && axis.len() > 1e-12 { axis.normalized() } else { Vec3::Z }; - // Distance from the axis. - let radius = |p: Vec3| { - let v = Vec3::new(p.x - center.x, p.y - center.y, p.z - center.z); - let along = v.dot(axis); - Vec3::new(v.x - axis.x * along, v.y - axis.y * along, v.z - axis.z * along).len() - }; - let base_radius = t.f(40).filter(|r| r.is_finite() && *r >= 0.0).unwrap_or_else(|| radius(start)); - let turns = t.fd(41, 1.0); - let end = last(10).or_else(|| last(11)); - let height = match (t.f(42), end) { - (Some(th), _) if th.is_finite() => th * turns, - (_, Some(e)) => e.z - center.z, - _ => 0.0, - }; - Helix { - center, - base_radius, - top_radius: end.map(radius).unwrap_or(base_radius), - height, - turns, - start_angle: center.xy().angle_to(start.xy()), - ccw: t.i(290).unwrap_or(1) != 0, - constrain: HelixConstrain::from_dxf(t.i(280).unwrap_or(2)), - } - .sanitized() -} - /// An entity from the groups of a DXF record of type `kind` (without its `0` group), as the /// reader makes it. AutoLISP's `entmake` and `entmod` are built on it. pub fn entity_from_groups(kind: &str, tags: &[Tag]) -> Option<(Common, EntityKind)> { diff --git a/crates/io/src/dxf_read/common.rs b/crates/io/src/dxf_read/common.rs new file mode 100644 index 000000000..18e8edd8e --- /dev/null +++ b/crates/io/src/dxf_read/common.rs @@ -0,0 +1,82 @@ +use super::*; + +/// Value lookup helpers over an entity's tags. +pub(super) struct T<'a>(pub(super) &'a [Tag]); + +impl T<'_> { + pub(super) fn f(&self, code: i32) -> Option { + self.0.iter().find(|t| t.code == code).map(Tag::f64) + } + pub(super) fn fd(&self, code: i32, d: f64) -> f64 { + self.f(code).unwrap_or(d) + } + pub(super) fn i(&self, code: i32) -> Option { + self.0.iter().find(|t| t.code == code).map(Tag::i64) + } + pub(super) fn s(&self, code: i32) -> Option { + self.0.iter().find(|t| t.code == code).map(Tag::str) + } + pub(super) fn p(&self, code: i32) -> Vec3 { + Vec3::new(self.fd(code, 0.0), self.fd(code + 10, 0.0), self.fd(code + 20, 0.0)) + } + pub(super) fn all_f(&self, code: i32) -> Vec { + self.0.iter().filter(|t| t.code == code).map(Tag::f64).collect() + } + pub(super) fn pts(&self, code: i32) -> Vec { + let xs = self.all_f(code); + let ys = self.all_f(code + 10); + xs.into_iter().zip(ys).map(|(x, y)| Vec2::new(x, y)).collect() + } +} + +/// An entity's group 440: `0x01` in the top byte is ByBlock, `0x02` a fixed alpha (mapped to a +/// percentage as for layers), anything else ByLayer. +fn transparency_440(v: i64) -> Transparency { + match (v >> 24) & 0xff { + 1 => Transparency::ByBlock, + 2 => { + let alpha = u32::try_from(v & 0xff).unwrap_or(255); + let opaque = (alpha * 100 + 127) / 255; + Transparency::Percent(u8::try_from(100u32.saturating_sub(opaque).min(90)).unwrap_or(90)) + } + _ => Transparency::ByLayer, + } +} + +pub(super) fn common(t: &T) -> Common { + let mut c = Common { layer: t.s(8).unwrap_or_else(|| "0".into()), linetype: t.s(6).unwrap_or_else(|| "ByLayer".into()), ..Common::default() }; + if let Some(v) = t.i(62) { + c.color = Color::from_aci(v as i16); + if v < 0 { + c.color = Color::ByLayer; + c.visible = true; + } + } + if let Some(tc) = t.i(420) { + c.color = Color::True(Rgb::from_u32(tc as u32)); + } + if let Some(lw) = t.i(370) { + c.lineweight = Lineweight::from_dxf(lw as i16); + } + if let Some(s) = t.f(48) { + c.ltscale = if s > 0.0 { s } else { 1.0 }; + } + if t.i(60) == Some(1) { + c.visible = false; + } + if let Some(v) = t.i(440) { + c.transparency = transparency_440(v); + } + c.thickness = t.fd(39, 0.0); + c.extrusion = Vec3::new(t.fd(210, 0.0), t.fd(220, 0.0), t.fd(230, 1.0)); + c +} + +/// Apply the OCS → WCS transform for 2D entities with a non-default extrusion. +pub(super) fn ocs(c: &Common, p: Vec3) -> Vec3 { + ocs_n(c.extrusion, p) +} + +pub(super) fn ocs_n(n: Vec3, p: Vec3) -> Vec3 { + if (n.z - 1.0).abs() < 1e-12 && n.x.abs() < 1e-12 && n.y.abs() < 1e-12 { p } else { cadcraft_geom::Mat4::ocs(n).apply(p) } +} diff --git a/crates/io/src/dxf_read/dimassoc.rs b/crates/io/src/dxf_read/dimassoc.rs new file mode 100644 index 000000000..a6219c1d4 --- /dev/null +++ b/crates/io/src/dxf_read/dimassoc.rs @@ -0,0 +1,156 @@ +use super::common::T; +use super::*; + +/// Associativity from a DIMASSOC object (files from other writers). The dimension kind decides +/// which definition points the references name, in the order AutoCAD writes them: `p13`/`p14` +/// for linear, aligned and arc-length dimensions, both ends of both lines (`p13`..`p16`) for +/// angular dimensions, the three points of an angular-3-point dimension, `p13` for ordinate +/// dimensions, and for radial or diametric dimensions the single circle reference +/// ([`radial_links`]). CADCraft's own xdata links, when present, win. +pub(super) fn dimassoc(d: &mut Drawing, tags: &[Tag]) { + let start = tags.iter().position(|x| x.code == 100 && x.str() == "AcDbDimAssoc").unwrap_or(0); + let tail = tags.get(start..).unwrap_or(&[]); + let Some(dim_h) = tail.iter().find(|x| x.code == 330).and_then(|x| Handle::parse_hex(&x.str())) else { return }; + let Some(EntityKind::Dimension(dm)) = d.entity(dim_h).map(|e| &e.kind) else { return }; + if !dm.assoc.is_empty() { + return; + } + // A radial or diametric dimension names its circle once, by its centre. + if matches!(dm.kind, DimKind::Radius | DimKind::Diameter) { + let links = radial_links(d, dm, tail); + if !links.is_empty() { + let _ = d.modify_entity(dim_h, |e| { + if let EntityKind::Dimension(dm) = &mut e.kind { + dm.assoc = links; + } + }); + } + return; + } + let slots: &[&str] = match dm.kind { + DimKind::Linear { .. } | DimKind::Aligned => &["p13", "p14"], + DimKind::Angular => &["p13", "p14", "p15", "p16"], + DimKind::Angular3P => &["p13", "p14", "p15"], + DimKind::ArcLength => &["p13", "p14"], + DimKind::Ordinate { .. } => &["p13"], + DimKind::Radius | DimKind::Diameter => return, + }; + let flags = T(tail).i(90).unwrap_or(0); + let used: Vec<&str> = slots.iter().enumerate().filter(|(i, _)| flags & (1 << i) != 0).map(|(_, s)| *s).collect(); + // Point references start at each `1 AcDbOsnapPointRef`. + let starts: Vec = tail.iter().enumerate().filter(|(_, x)| x.code == 1).map(|(i, _)| i).take(8).collect(); + let mut links = Vec::new(); + for (k, s) in starts.iter().enumerate() { + let Some(point) = used.get(k) else { break }; + let end = starts.get(k + 1).copied().unwrap_or(tail.len()); + let r = T(tail.get(s + 1..end).unwrap_or(&[])); + let Some(main) = r.s(331).and_then(|h| Handle::parse_hex(&h)) else { continue }; + let other = r.s(332).and_then(|h| Handle::parse_hex(&h)); + let at = r.p(10).xy(); + // A polyline's segment (subentity type 73 = 2, edge; 91 its 1-based index). + let segment = r.i(91).filter(|k| *k >= 1 && r.i(73) == Some(2)); + if let Some(snap) = infer_snap(d, r.i(72).unwrap_or(0), main, other, at, segment) { + links.push(DimAssoc { point: (*point).to_string(), handle: main, snap }); + } + } + if !links.is_empty() { + let _ = d.modify_entity(dim_h, |e| { + if let EntityKind::Dimension(dm) = &mut e.kind { + dm.assoc = links; + } + }); + } +} + +/// Associations of a radial or diametric dimension from its one standard reference: the circle +/// or arc it measures, by its centre (osnap 3). The definition points are then on that circle, +/// so the dimension follows its radius or diameter. +fn radial_links(d: &Drawing, dm: &Dimension, tail: &[Tag]) -> Vec { + let starts: Vec = tail.iter().enumerate().filter(|(_, x)| x.code == 1 && x.str() == "AcDbOsnapPointRef").map(|(i, _)| i).take(8).collect(); + for (k, s) in starts.iter().enumerate() { + let end = starts.get(k + 1).copied().unwrap_or(tail.len()); + let r = T(tail.get(s + 1..end).unwrap_or(&[])); + if !matches!(r.i(72), Some(3 | 4)) { + continue; + } + let Some(handle) = r.s(331).and_then(|h| Handle::parse_hex(&h)) else { continue }; + let Some(centre) = circle_centre(d, handle) else { continue }; + let on = |point: &str, p: Vec3| { + let angle = centre.angle_to(p.xy()); + angle.is_finite().then(|| DimAssoc { point: point.into(), handle, snap: AssocSnap::OnCircle { angle } }) + }; + let links = match dm.kind { + DimKind::Radius => on("p15", dm.p15).map(|p15| vec![DimAssoc { point: "defpt".into(), handle, snap: AssocSnap::Center }, p15]), + DimKind::Diameter => [on("defpt", dm.defpt), on("p15", dm.p15)].into_iter().collect::>>(), + _ => None, + }; + if let Some(links) = links { + return links; + } + } + Vec::new() +} + +/// The planar centre of a circle or arc, or `None` for any other entity: the point a radial or +/// diametric dimension's single DIMASSOC reference names. +fn circle_centre(d: &Drawing, h: Handle) -> Option { + match &d.entity(h)?.kind { + EntityKind::Circle(c) => Some(c.center.xy()), + EntityKind::Arc(a) => Some(a.center.xy()), + _ => None, + } +} + +/// A CADCraft snap from a DIMASSOC object snap type and point, and for a polyline the segment +/// the point is an end of: AutoCAD writes no usable point there (10/20 are 0, #1049), the segment +/// and the osnap (1 its end, 13 its start) name the vertex. For a line or arc the osnap alone +/// says which end: 13 is the start and 1 the end, whatever the stored point (the point is +/// 0,0,0 for curves, and choosing by distance gave the wrong end for arcs). A point entity has +/// only one position, so both codes mean it. +fn infer_snap(d: &Drawing, osnap: i64, main: Handle, other: Option, at: Vec2, segment: Option) -> Option { + let e = d.entity(main)?; + if let (EntityKind::LwPolyline(p), Some(k), 1 | 13) = (&e.kind, segment, osnap) { + let n = p.vertices.len(); + let segments = if p.closed { n } else { n.saturating_sub(1) }; + let k = usize::try_from(k).ok().filter(|k| *k <= segments)?; + let index = if osnap == 13 { k - 1 } else { k % n }; + return Some(AssocSnap::Vertex { index }); + } + Some(match osnap { + 1 | 13 => match &e.kind { + // AutoCAD names a curve's start with 13 and its end with 1 (the stored point is 0,0,0). + // Other writers use 1 for either end of a line with the point stored: take the nearer. + EntityKind::Line(l) => { + if osnap == 13 || (at != Vec2::ZERO && l.a.xy().dist(at) <= l.b.xy().dist(at)) { + AssocSnap::Start + } else { + AssocSnap::End + } + } + EntityKind::Arc(_) => { + if osnap == 13 { + AssocSnap::Start + } else { + AssocSnap::End + } + } + EntityKind::LwPolyline(p) => { + let index = p.vertices.iter().enumerate().min_by(|x, y| x.1.p.dist(at).total_cmp(&y.1.p.dist(at))).map(|(i, _)| i)?; + AssocSnap::Vertex { index } + } + EntityKind::Point(_) => AssocSnap::Start, + _ => return None, + }, + 2 => AssocSnap::Mid, + 3 | 4 => AssocSnap::Center, + 5 | 10 => match &e.kind { + EntityKind::Circle(Circle { center, .. }) | EntityKind::Arc(Arc { center, .. }) => { + let v = at - center.xy(); + AssocSnap::OnCircle { angle: v.y.atan2(v.x) } + } + _ => return None, + }, + 6 => AssocSnap::Intersection { other: other.filter(|o| d.entity(*o).is_some())? }, + _ => return None, + }) +} diff --git a/crates/io/src/dxf_read/document.rs b/crates/io/src/dxf_read/document.rs new file mode 100644 index 000000000..245bda331 --- /dev/null +++ b/crates/io/src/dxf_read/document.rs @@ -0,0 +1,326 @@ +use super::common::T; +use super::entities::{entity_extras, entity_plot_style, entity_with_table_budget, insert_attribs, polyline_entity}; +use super::objects::{MAX_OBJECTS, Objects, page_setup}; +use super::*; + +pub(super) fn parse_entities( + recs: &[(String, Vec)], + d: &mut Drawing, + rx: &mut Rx, + skipped: &mut usize, + table_cells_left: &mut usize, +) -> Vec<(Option, bool, Entity)> { + let mut out = Vec::new(); + let mut i = 0; + while let Some((kind, tags)) = recs.get(i) { + let t = T(tags); + let owner = crate::dxf_ext::owner(tags); + let paper = t.i(67) == Some(1); + let handle = t.s(5).and_then(|h| Handle::parse_hex(&h)); + let mut kindent = entity_with_table_budget(kind, tags, table_cells_left); + if let Some((c, _)) = kindent.as_mut() + && let Some(n) = entity_plot_style(tags, rx) + { + c.plot_style = n; + } + // More entities from this record (the faces of a polyface or polygon mesh). + let mut extra: Vec = Vec::new(); + i += 1; + match kind.as_str() { + "POLYLINE" => { + 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 ins_layer = kindent.as_ref().map(|(c, _)| c.layer.clone()).unwrap_or_default(); + let attribs = insert_attribs(recs, &mut i, &ins_layer); + if let Some((_, EntityKind::Insert(ins))) = kindent.as_mut() { + ins.attribs = attribs; + } + } + } + // A VERTEX or ATTRIB with no POLYLINE or INSERT before it; a stray SEQEND holds nothing. + "VERTEX" | "ATTRIB" => { + *skipped = skipped.saturating_add(1); + continue; + } + "SEQEND" => continue, + _ => {} + } + if let Some((c, mut k)) = kindent { + let h = match handle { + Some(h) => { + d.bump_handseed(h); + h + } + None => d.new_handle(), + }; + entity_extras(kind, tags, h, &mut k, rx); + let more: Vec<_> = + extra.into_iter().map(|f| (owner.clone(), paper, Entity { handle: d.new_handle(), common: c.clone(), kind: f })).collect(); + out.push((owner, paper, Entity { handle: h, common: c, kind: k })); + out.extend(more); + } else if kind == "HATCH" { + // Without boundary data. The other records `entity` returns nothing for are read + // elsewhere (the writer's paper viewport, the loops of a region). + *skipped = skipped.saturating_add(1); + } + } + // Assign fresh handles to duplicates (hostile files). + let mut seen = std::collections::HashSet::new(); + for (_, _, e) in &mut out { + if !seen.insert(e.handle) { + e.handle = d.new_handle(); + seen.insert(e.handle); + } + } + out +} + +/// Read the BLOCKS section: the xref paths of external blocks, then each block's entities. +pub(super) 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`. +pub(super) 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. +pub(super) 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.iter() { + if name.eq_ignore_ascii_case("Model") { + continue; + } + d.layouts.push(Layout { page: page.clone(), ..Layout::new(name, *order) }); + if let Some(bname) = rx.brs.get(br) { + paper_block_for.insert(bname.to_ascii_uppercase(), name.clone()); + } + } + if d.layouts.is_empty() { + d.layouts.push(Layout::new("Layout1", 1)); + paper_block_for.insert("*PAPER_SPACE".into(), "Layout1".into()); + } + // The layout in `*Paper_Space` is the active one, kept when it isn't the first tab (the first + // is active by default); paper-space entities without an owner go there. + let active = paper_block_for.get("*PAPER_SPACE").cloned(); + 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()); + (paper_block_for, first_layout) +} + +/// Place the BLOCKS entities: model and paper space blocks go to their space, the rest become +/// block definitions. +pub(super) 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" { + for (_, _, e) in ents { + d.ensure_layer(&e.common.layer); + d.model.push(e); + } + continue; + } + if up.starts_with("*PAPER_SPACE") { + 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); + } + } + continue; + } + // Generated geometry we rebuild on save: table blocks, and standard arrowhead blocks + // nothing inserts whose content is the one CADCraft writes. An arrowhead block with other + // content (AutoCAD's `_Dot`, a user's redefinition) is kept, so dimensions keep it. + let generated_arrow = || { + let own: Vec<&Entity> = ents.iter().map(|(_, _, e)| e).collect(); + crate::dxf_ext::is_standard_arrow(&name) && !rx.inserted.contains(&up) && crate::dxf_write::is_generated_arrow(&name, &own) + }; + if rx.table_blocks.contains(&up) || generated_arrow() { + continue; + } + let mut b = Block::new(&name); + b.base = base; + b.description = description; + if let Some(&(units, explodable)) = rx.block_props.get(&up) { + (b.units, b.explodable) = (units, explodable); + } + if xref & 4 != 0 { + b.xref_path = Some(String::new()); + b.xref_overlay = xref & 8 != 0; + } else { + for (_, _, e) in ents { + b.entities.push(e); + } + } + // Array parameters, with the block names the items use (other programs may rename + // anonymous blocks); dropped when the items no longer match them. + b.array = rx.block_arrays.get(&up).cloned().and_then(|mut a| a.sync_names(b.entities.iter().map(|e| e.as_ref())).then_some(a)); + if let Some(path) = rx.xref_paths.remove(&up) { + b.xref_path = Some(path); + } + d.blocks.insert(name, std::sync::Arc::new(b)); + } +} + +/// Place the ENTITIES section: model space, or paper space when flagged (67=1). +pub(super) 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.to_string()); + if let Some(l) = d.layouts.iter_mut().find(|l| l.name == lname) { + l.entities.push(e); + } + } else { + d.model.push(e); + } + } +} + +pub(super) fn count_skipped(skipped: &mut std::collections::BTreeMap, section: &str, n: usize) { + if n > 0 { + let c = skipped.entry(section.to_string()).or_insert(0); + *c = c.saturating_add(n); + } +} + +/// Records between sections, which [`sections`] passes over: everything after an ENDSEC up to the +/// next SECTION. Stray ENDSECs and the EOF hold nothing and don't count. +pub(super) fn outside_sections(tags: &[Tag]) -> usize { + let mut inside = false; + let mut n = 0usize; + for t in tags.iter().filter(|t| t.code == 0) { + match t.str().as_str() { + "SECTION" => inside = true, + "ENDSEC" => inside = false, + "EOF" => {} + _ if !inside => n = n.saturating_add(1), + _ => {} + } + } + n +} + +/// A SORTENTSTABLE (DXF Reference, OBJECTS section): after its AcDbSortentsTable marker, the +/// owning block record (330), then pairs of an entity handle (331) and the sort handle (5) it is +/// drawn by. +pub(super) fn sortents_table(tags: &[Tag]) -> Option<(String, HashMap)> { + let start = tags.iter().position(|t| t.code == 100 && t.str() == "AcDbSortentsTable")?; + let tail = tags.get(start + 1..)?; + let owner = tail.iter().find(|t| t.code == 330)?.str().trim().to_ascii_uppercase(); + let mut map = HashMap::new(); + let mut ent: Option = None; + for t in tail { + match t.code { + 331 => ent = Handle::parse_hex(&t.str()), + 5 => { + if let (Some(e), Some(k)) = (ent.take(), Handle::parse_hex(&t.str())) + && map.len() < MAX_OBJECTS + { + map.insert(e, k.0); + } + } + _ => {} + } + } + (!map.is_empty()).then_some((owner, map)) +} + +/// Draw each space with a SORTENTSTABLE in sort-handle order: an entity listed in the table by +/// its sort handle, any other by its own handle. +pub(super) fn apply_sortents( + d: &mut Drawing, + tables: &[(String, HashMap)], + brs: &HashMap, + paper_for: &HashMap, + first_layout: &str, +) { + for (owner, map) in tables { + let Some(name) = brs.get(owner) else { continue }; + let up = name.to_ascii_uppercase(); + let key = |e: &Entity| map.get(&e.handle).copied().unwrap_or(e.handle.0); + if up == "*MODEL_SPACE" { + d.model.sort_by_key(key); + } else if up.starts_with("*PAPER_SPACE") { + let lname = paper_for.get(&up).map(String::as_str).unwrap_or(first_layout); + if let Some(l) = d.layouts.iter_mut().find(|l| l.name == lname) { + l.entities.sort_by_key(key); + } + } else if let Some(b) = d.blocks.get_mut(name.as_str()) { + std::sync::Arc::make_mut(b).entities.sort_by_key(key); + } + } +} diff --git a/crates/io/src/dxf_read/entities.rs b/crates/io/src/dxf_read/entities.rs new file mode 100644 index 000000000..9f0816d2a --- /dev/null +++ b/crates/io/src/dxf_read/entities.rs @@ -0,0 +1,651 @@ +use super::common::{T, common, ocs}; +use super::hatch::hatch; +use super::text::{attdef, attrib_modes, attrib_props, mtext_from, multiline_attrib, split_embedded, text_from}; +use super::*; + +/// Parse one entity record. POLYLINE/INSERT followers are handled by the caller. +pub(super) fn entity(kind: &str, tags: &[Tag]) -> Option<(Common, EntityKind)> { + entity_with_table_budget(kind, tags, &mut 4_000_000) +} + +/// 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 +} + +pub(super) 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) { + return None; + } + let t = T(tags); + let c = common(&t); + let k = match kind { + "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" => 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" => 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 + // is the mirrored point, a negative x scale, the opposite rotation and a negative column + // spacing. Noise of up to 1e-3 in the extrusion's x/y moves the projection by ~1e-7 of the + // size, so it still counts as mirrored. A truly tilted extrusion (block plane not parallel + // to XY) needs the full 3D block transform, which Insert cannot hold; it is left as read. + let n = c.extrusion; + let mirrored = n.z < 0.0 && n.x.hypot(n.y) <= 1e-3 * n.z.abs(); + let mut p = t.p(10); + let mut scale = Vec3::new(t.fd(41, 1.0), t.fd(42, 1.0), t.fd(43, 1.0)); + let mut rotation = t.fd(50, 0.0).to_radians(); + let mut col_spacing = t.fd(44, 0.0); + if mirrored { + p = ocs(&c, p); + scale.x = -scale.x; + rotation = -rotation; + col_spacing = -col_spacing; + } + EntityKind::Insert(Insert { + block: t.s(2).unwrap_or_default(), + insert: p, + scale, + rotation, + attribs: Vec::new(), + cols: t.i(70).unwrap_or(1).clamp(1, 10_000) as u32, + rows: t.i(71).unwrap_or(1).clamp(1, 10_000) as u32, + col_spacing, + row_spacing: t.fd(45, 0.0), + }) + } + "DIMENSION" => { + let ty = t.i(70).unwrap_or(0) & 0x0f; + let kind = match ty { + 0 => DimKind::Linear { rotation: t.fd(50, 0.0).to_radians() }, + 1 => DimKind::Aligned, + 2 => DimKind::Angular, + 3 => DimKind::Diameter, + 4 => DimKind::Radius, + 5 => DimKind::Angular3P, + 6 => DimKind::Ordinate { x_type: t.i(70).unwrap_or(0) & 64 != 0 }, + 8 => DimKind::ArcLength, + _ => DimKind::Aligned, + }; + EntityKind::Dimension(Dimension { + kind, + defpt: t.p(10), + // The text midpoint is the one OCS point of a DIMENSION. + text_mid: if c.extrusion.z < 0.0 { ocs(&c, t.p(11)) } else { t.p(11) }, + p13: t.p(13), + p14: t.p(14), + p15: t.p(15), + p16: t.p(16), + text: t.s(1).unwrap_or_default(), + style: t.s(3).unwrap_or_else(|| "Standard".into()), + measurement: t.fd(42, 0.0), + text_rotation: t.fd(53, 0.0).to_radians(), + user_text_pos: t.i(70).unwrap_or(0) & 128 != 0, + block: t.s(2).filter(|s| !s.is_empty()), + overrides: Default::default(), + assoc: Vec::new(), + marks: Default::default(), + }) + } + "LEADER" => EntityKind::Leader(Leader { + vertices: t.pts(10).into_iter().map(|p| p.to3(0.0)).collect(), + arrow: t.i(71).unwrap_or(1) != 0, + spline: t.i(72).unwrap_or(0) != 0, + style: t.s(3).unwrap_or_else(|| "Standard".into()), + }), + // Names behind handles follow in `entity_extras` and `Objects::apply`. + "MULTILEADER" | "MLEADER" => EntityKind::MLeader(crate::dxf_mleader::read(tags)), + "SOLID" | "TRACE" => { + let corners = [t.p(10), t.p(11), t.p(12), if t.f(13).is_some() { t.p(13) } else { t.p(12) }]; + let corners = corners.map(|p| ocs(&c, p)); + if kind == "SOLID" { EntityKind::Solid(Solid { corners }) } else { EntityKind::Trace(Solid { corners }) } + } + "MLINE" => EntityKind::MLine(crate::dxf_mline::read_entity(tags)), + "3DFACE" => EntityKind::Face3d(Face3d { corners: [t.p(10), t.p(11), t.p(12), t.p(13)], hidden_edges: t.i(70).unwrap_or(0) as u8 }), + "HATCH" => { + let mut k = hatch(tags, &t)?; + if c.extrusion.z < 0.0 + && let EntityKind::Hatch(h) = &mut k + { + // Boundary in the mirrored OCS, like LWPOLYLINE; the pattern direction mirrors too. + for v in h.loops.iter_mut().flat_map(|l| l.vertices.iter_mut()) { + v.p.x = -v.p.x; + v.bulge = -v.bulge; + } + h.angle = cadcraft_geom::norm_angle(std::f64::consts::PI - h.angle); + } + k + } + "ACAD_TABLE" => EntityKind::Table(acad_table(tags, table_cells_left)), + "VIEWPORT" => EntityKind::Viewport(Viewport { + center: t.p(10), + width: t.fd(40, 1.0), + height: t.fd(41, 1.0), + view_center: Vec2::new(t.fd(12, 0.0), t.fd(22, 0.0)), + view_height: t.fd(45, 1.0), + id: t.i(69).unwrap_or(0) as u32, + locked: t.i(90).unwrap_or(0) & 16384 != 0, + frozen_layers: crate::dxf_ext::read_frozen(tags), + layer_colors: crate::dxf_ext::read_layer_colors(tags), + // Status flags: 65536 = non-rectangular clipping (by the 340 entity), 131072 = off. + clip: t.s(340).and_then(|h| Handle::parse_hex(&h)).filter(|h| h.0 != 0 && t.i(90).unwrap_or(0) & 65536 != 0), + on: t.i(90).unwrap_or(0) & 131072 == 0, + }), + "WIPEOUT" => { + let o = t.p(10); + let u = t.p(11); + let v = t.p(12); + // Clip vertices are in pixel space: origin at the image's top-left corner, y pointing down. + let pts: Vec = t.pts(14).into_iter().map(|q| o.xy() + u.xy() * (q.x + 0.5) + v.xy() * (0.5 - q.y)).collect(); + EntityKind::Wipeout(Wipeout { boundary: pts }) + } + // The file path comes from the IMAGEDEF object (`dxf_image::resolve`). + "IMAGE" => EntityKind::Image(crate::dxf_image::read_entity(tags)), + "TOLERANCE" => { + let dir = t.p(11); + let dir = if dir.xy().is_finite() && dir.xy().len() > 1e-12 { dir } else { Vec3::new(1.0, 0.0, 0.0) }; + EntityKind::Tolerance(Tolerance { + insert: t.p(10), + dir, + style: t.s(3).unwrap_or_else(|| "Standard".into()), + text: t.s(1).unwrap_or_default().chars().take(MAX_FRAME_CHARS).collect(), + }) + } + other => EntityKind::Unknown(Unknown { + dxf_type: other.to_string(), + tags: tags.iter().map(|tg| RawTag { code: tg.code, value: tg.str() }).collect(), + }), + }; + Some((c, k)) +} + +/// An ACAD_TABLE entity: sizes, cell texts and merges (DXF Reference), plus CADCraft's +/// title/header flags, text height and style name from xdata when present. +pub(super) fn acad_table(tags: &[Tag], table_cells_left: &mut usize) -> Table { + const MAX_CELLS: usize = 1_000_000; + let start = tags.iter().position(|x| x.code == 100 && x.str() == "AcDbTable").unwrap_or(0); + let tail = tags.get(start..).unwrap_or(&[]); + let first_cell = tail.iter().position(|x| x.code == 171).unwrap_or(tail.len()); + let head = T(tail.get(..first_cell).unwrap_or(&[])); + let mut rows = usize::try_from(head.i(91).unwrap_or(0)).unwrap_or(0).min(10_000); + let mut cols = usize::try_from(head.i(92).unwrap_or(0)).unwrap_or(0).min(10_000); + // Large declared sizes must be backed by cell records (no allocation from a bare count). + let n_cells = tail.iter().filter(|x| x.code == 171).count(); + let declared = rows.saturating_mul(cols); + if declared > MAX_CELLS || (declared > 10_000 && n_cells < declared) { + rows = 0; + cols = 0; + } + // Charge before any cell vector is materialized, including discarded tables. + let count = rows.saturating_mul(cols); + match table_cells_left.checked_sub(count) { + Some(left) => *table_cells_left = left, + None => return Table { insert: T(tags).p(10), ..Table::default() }, + } + let size = |v: f64| if v.is_finite() && v > 0.0 { v } else { 0.27 }; + let mut row_heights: Vec = head.all_f(141).into_iter().take(rows).map(size).collect(); + row_heights.resize(rows, 0.27); + let mut col_widths: Vec = head.all_f(142).into_iter().take(cols).map(size).collect(); + col_widths.resize(cols, 2.5); + // Cells, row-major, each starting at a 171 group; xdata ends the last one. + let body = tail.get(first_cell..).unwrap_or(&[]); + let body = body.get(..body.iter().position(|x| x.code >= 1000).unwrap_or(body.len())).unwrap_or(&[]); + let mut flat: Vec = Vec::new(); + let mut i = 0; + while let Some(t0) = body.get(i) { + if flat.len() >= rows * cols { + break; + } + i += 1; + if t0.code != 171 { + continue; + } + let end = body.get(i..).and_then(|r| r.iter().position(|x| x.code == 171)).map(|p| i + p).unwrap_or(body.len()); + let cell = body.get(i..end).unwrap_or(&[]); + i = end; + let c = T(cell); + let mut text: String = cell.iter().filter(|x| x.code == 2 || x.code == 3).map(Tag::str).collect(); + text.push_str(&c.s(1).unwrap_or_default()); + let span = |code: i32| c.i(code).and_then(|v| u32::try_from(v).ok()); + // A merge starts at the cell whose span (rows 176, columns 175) is more than one cell. + // Its own merged flag (173) varies by writer: CADCraft sets it, while AutoCAD-style + // writers (acadrust 0.6.4) set it only on the cells the merge covers, whose span is 0. + let merged = match (span(176), span(175)) { + (Some(rs), Some(cs)) if rs >= 1 && cs >= 1 && (rs, cs) != (1, 1) => Some((rs, cs)), + _ => None, + }; + // The cell's own text height: 140 outside the value block (301 … 304), where it is a number. + let mut in_value = false; + let mut text_height = None; + for x in cell { + match x.code { + 301 => in_value = true, + 304 => in_value = false, + 140 if !in_value => text_height = Some(x.f64()).filter(|h| h.is_finite() && *h > 0.0 && *h < 1e9), + _ => {} + } + } + flat.push(TableCell { text, merged, text_height, ..TableCell::default() }); + } + flat.resize(rows * cols, TableCell::default()); + let cells: Vec> = if cols == 0 { vec![Vec::new(); rows] } else { flat.chunks(cols).map(<[TableCell]>::to_vec).collect() }; + let x = crate::dxf_ext::xdata(tags, crate::dxf_ext::APP); + let ours = x.iter().position(|t| t.code == 1000 && t.str() == "TABLE").map(|p| x.get(p + 1..).unwrap_or(&[])); + let (title, header, text_height, style) = match ours { + Some(o) => { + let flags: Vec = o.iter().filter(|t| t.code == 1070).map(Tag::i64).collect(); + let h = o.iter().find(|t| t.code == 1040).map(Tag::f64).filter(|h| h.is_finite() && *h > 0.0).unwrap_or(0.18); + let style = o.iter().find(|t| t.code == 1000).map(Tag::str).unwrap_or_else(|| "Standard".into()); + (flags.first().is_some_and(|v| *v != 0), flags.get(1).is_some_and(|v| *v != 0), h, style) + } + // Other writers: a first row merged across every column reads as a title. + // Their text height is resolved to the table style's once the styles are read (`apply`). + None => (cols > 1 && cells.first().and_then(|r| r.first()).and_then(|c| c.merged) == Some((1, cols as u32)), false, 0.0, "Standard".into()), + }; + let mut t = Table { insert: T(tags).p(10), col_widths, row_heights, cells, style, text_height, title, header, ..Table::default() }; + crate::dxf_table::read_table_xdata(tags, &mut t); + t +} + +/// The plot style names of a file: the entries of the `ACAD_PLOTSTYLENAME` dictionary of the +/// named object dictionary (the first object), by placeholder handle. +pub(super) fn plot_style_names(secs: &[cadcraft_dxf::Section]) -> HashMap { + let mut out = HashMap::new(); + let Some(objects) = secs.iter().find(|s| s.name == "OBJECTS") else { return out }; + let recs = records(&objects.tags); + let entries = |tags: &[Tag]| { + let mut v: Vec<(String, String)> = Vec::new(); + let mut name: Option = None; + for t in tags { + match t.code { + 3 => name = Some(t.str()), + 350 | 360 => { + if let Some(n) = name.take() { + v.push((n, t.str().trim().to_ascii_uppercase())); + } + } + _ => {} + } + } + v + }; + let Some((_, root)) = recs.iter().find(|(k, _)| k == "DICTIONARY") else { return out }; + let Some((_, dict)) = entries(root).into_iter().find(|(n, _)| n.eq_ignore_ascii_case(crate::dxf_ext::PLOT_STYLE_NAMES_KEY)) else { + return out; + }; + let is_dict = |k: &str| k == "DICTIONARY" || k == "ACDBDICTIONARYWDFLT"; + let Some((_, tags)) = recs.iter().find(|(k, tg)| is_dict(k) && T(tg).s(5).is_some_and(|h| h.trim().eq_ignore_ascii_case(&dict))) else { + return out; + }; + for (n, h) in entries(tags).into_iter().take(crate::dxf_ext::MAX_PLOT_STYLE_NAMES) { + if !n.trim().is_empty() { + out.insert(h, n.chars().take(255).collect()); + } + } + out +} + +/// The named plot style an entity record points at (group 390 among its AcDbEntity groups). +pub(super) fn entity_plot_style(tags: &[Tag], rx: &Rx) -> Option { + let mut in_entity = false; + for t in tags { + if t.code == 100 { + if in_entity { + return None; + } + in_entity = t.str() == "AcDbEntity"; + } else if t.code == 390 && in_entity { + return rx.pstyles.get(&t.str().trim().to_ascii_uppercase()).cloned(); + } + } + None +} + +/// Dimension overrides and associativity, table references: data of an entity record that +/// needs the file's handle maps. +pub(super) fn entity_extras(kind: &str, tags: &[Tag], h: Handle, k: &mut EntityKind, rx: &mut Rx) { + match (kind, k) { + ("DIMENSION", EntityKind::Dimension(dm)) => { + dm.overrides = crate::dxf_ext::read_dstyle(tags, &rx.styles, &rx.brs, &rx.ltypes); + dm.assoc = crate::dxf_ext::read_assoc(tags); + if let Some(center) = crate::dxf_ext::read_arclen(tags) { + dm.kind = DimKind::ArcLength; + dm.p15 = center; + } + dm.marks = crate::dxf_ext::read_marks(tags); + // Group 52: the extension lines' angle relative to the dimension line (DIMEDIT Oblique). + let base = match dm.kind { + DimKind::Linear { rotation } => Some(rotation), + DimKind::Aligned => Some((dm.p14.xy() - dm.p13.xy()).angle()), + _ => None, + }; + if let (Some(base), Some(o)) = (base, T(tags).f(52).filter(|o| o.is_finite() && o.rem_euclid(180.0) != 0.0)) { + dm.marks.oblique = Some(cadcraft_geom::norm_angle(base + o.to_radians())); + } + } + ("ACAD_TABLE", EntityKind::Table(_)) => { + let t = T(tags); + if let Some(b) = t.s(2) { + rx.table_blocks.insert(b.to_ascii_uppercase()); + } + let ours = crate::dxf_ext::xdata(tags, crate::dxf_ext::APP).iter().any(|t| t.code == 1000 && t.str() == "TABLE"); + if !ours { + rx.table_style_fix.push((h, t.s(342).map(|sh| sh.trim().to_ascii_uppercase()).unwrap_or_default())); + } + } + ("INSERT", EntityKind::Insert(ins)) => { + rx.inserted.insert(ins.block.to_ascii_uppercase()); + } + ("MULTILEADER" | "MLEADER", EntityKind::MLeader(m)) => { + let pending = crate::dxf_mleader::resolve(tags, m, &rx.styles, &rx.brs); + if let Some(b) = &m.block { + rx.inserted.insert(b.block.to_ascii_uppercase()); + } + rx.mleader_fix.push((h, pending)); + } + // CADCraft's VPFROZEN xdata wins; otherwise AutoCAD's group 341 layer handles. + ("VIEWPORT", EntityKind::Viewport(v)) if v.frozen_layers.is_empty() => { + v.frozen_layers = tags + .iter() + .take_while(|t| t.code < 1000) + .filter(|t| t.code == 341) + .filter_map(|t| rx.layers.get(&t.str().trim().to_ascii_uppercase()).cloned()) + .collect(); + } + _ => {} + } +} + +/// 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. +pub(super) 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. +pub(super) 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 +} + +/// Faces of a polyface mesh: each face record names three or four vertices by 1-based index; +/// a negative index hides the edge that starts at that vertex. Faces with fewer vertices or +/// an index outside the vertex list are skipped. +fn polyface_faces(verts: &[Vec3], faces: &[[i64; 4]]) -> Vec { + let mut out = Vec::new(); + for f in faces { + let mut corners: Vec = Vec::with_capacity(4); + let mut hidden = 0u8; + let mut ok = true; + for &ix in f.iter().filter(|&&ix| ix != 0) { + let at = usize::try_from(ix.unsigned_abs()).ok().and_then(|n| n.checked_sub(1)); + let Some(p) = at.and_then(|n| verts.get(n)) else { + ok = false; + break; + }; + if ix < 0 { + hidden |= 1 << corners.len(); + } + corners.push(*p); + } + match (ok, corners.as_slice()) { + // A triangle's closing edge is the 3DFACE's fourth (the third is degenerate). + (true, [a, b, c]) => out.push(Face3d { corners: [*a, *b, *c, *c], hidden_edges: hidden | if hidden & 4 != 0 { 8 } else { 0 } }), + (true, [a, b, c, d]) => out.push(Face3d { corners: [*a, *b, *c, *d], hidden_edges: hidden }), + _ => {} + } + } + out +} + +/// Quads of an M×N polygon mesh (71/72, or the smoothed 73/74 when the vertex count matches +/// those), closed in M by bit 1 and in N by bit 32. Vertices run row by row. +fn mesh_faces(verts: &[Vec3], t: &T, flags: i64) -> Vec { + let dims = |a: i32, b: i32| Some((usize::try_from(t.i(a)?).ok()?, usize::try_from(t.i(b)?).ok()?)); + let fits = |(m, n): (usize, usize)| m >= 2 && n >= 2 && m.checked_mul(n).is_some_and(|mn| mn <= verts.len()); + let exact = |(m, n): (usize, usize)| fits((m, n)) && m * n == verts.len(); + let Some((m, n)) = [dims(71, 72), dims(73, 74)].into_iter().flatten().find(|d| exact(*d)).or_else(|| dims(71, 72).filter(|d| fits(*d))) else { + return Vec::new(); + }; + let at = |i: usize, j: usize| verts.get((i % m) * n + j % n).copied(); + let rows = if flags & 1 != 0 { m } else { m - 1 }; + let cols = if flags & 32 != 0 { n } else { n - 1 }; + let mut out = Vec::new(); + for i in 0..rows { + for j in 0..cols { + if let (Some(a), Some(b), Some(c), Some(d)) = (at(i, j), at(i, j + 1), at(i + 1, j + 1), at(i + 1, j)) { + out.push(Face3d { corners: [a, b, c, d], hidden_edges: 0 }); + } + } + } + out +} + +/// A HELIX (DXF Reference): the axis base point, start point, axis, radius, turns, turn height, +/// handedness and constraint after the AcDbHelix marker; the top radius is how far the end of its +/// spline (AcDbSpline: the last control point, else fit point) lies from the axis. +fn helix(tags: &[Tag]) -> Helix { + let hs = tags.iter().position(|t| t.code == 100 && t.str() == "AcDbHelix").unwrap_or(tags.len()); + let ss = tags.iter().position(|t| t.code == 100 && t.str() == "AcDbSpline").unwrap_or(0).min(hs); + let spline = tags.get(ss..hs).unwrap_or(&[]); + let t = T(tags.get(hs..).unwrap_or(&[])); + // The last point of the spline (3D) with group `code`. + let last = |code: i32| { + let i = spline.iter().rposition(|g| g.code == code)?; + let f = |c: i32| spline.get(i..).and_then(|r| r.iter().find(|g| g.code == c)).map(Tag::f64).unwrap_or(0.0); + Some(Vec3::new(f(code), f(code + 10), f(code + 20))) + }; + let center = t.p(10); + let start = t.p(11); + let axis = t.p(12); + let axis = if axis.is_finite() && axis.len() > 1e-12 { axis.normalized() } else { Vec3::Z }; + // Distance from the axis. + let radius = |p: Vec3| { + let v = Vec3::new(p.x - center.x, p.y - center.y, p.z - center.z); + let along = v.dot(axis); + Vec3::new(v.x - axis.x * along, v.y - axis.y * along, v.z - axis.z * along).len() + }; + let base_radius = t.f(40).filter(|r| r.is_finite() && *r >= 0.0).unwrap_or_else(|| radius(start)); + let turns = t.fd(41, 1.0); + let end = last(10).or_else(|| last(11)); + let height = match (t.f(42), end) { + (Some(th), _) if th.is_finite() => th * turns, + (_, Some(e)) => e.z - center.z, + _ => 0.0, + }; + Helix { + center, + base_radius, + top_radius: end.map(radius).unwrap_or(base_radius), + height, + turns, + start_angle: center.xy().angle_to(start.xy()), + ccw: t.i(290).unwrap_or(1) != 0, + constrain: HelixConstrain::from_dxf(t.i(280).unwrap_or(2)), + } + .sanitized() +} diff --git a/crates/io/src/dxf_read/hatch.rs b/crates/io/src/dxf_read/hatch.rs new file mode 100644 index 000000000..18fe81531 --- /dev/null +++ b/crates/io/src/dxf_read/hatch.rs @@ -0,0 +1,356 @@ +use super::common::T; +use super::*; + +pub(super) fn hatch(tags: &[Tag], t: &T) -> Option { + let (origin, background) = hatch_xdata(tags); + let pattern = t.s(2).unwrap_or_else(|| "SOLID".into()); + let solid = t.i(70).unwrap_or(0) == 1; + let n_loops = t.i(91).unwrap_or(0).clamp(0, 100_000) as usize; + let mut loops = Vec::new(); + // Walk the boundary data after the 91 tag. + let start = tags.iter().position(|x| x.code == 91).map(|i| i + 1)?; + let mut i = start; + let get = |i: usize| tags.get(i); + for _ in 0..n_loops { + // 92: loop type flags. + while get(i).is_some_and(|x| x.code != 92) { + i += 1; + } + let flags = get(i)?.i64(); + i += 1; + let outer = flags & 1 != 0; + let mut verts: Vec = Vec::new(); + if flags & 2 != 0 { + // Polyline boundary: 72 has-bulge, 73 closed, 93 count, then 10/20/42. + let has_bulge = get(i).filter(|x| x.code == 72).map(Tag::i64).unwrap_or(0) != 0; + while get(i).is_some_and(|x| x.code != 93) { + i += 1; + } + let n = get(i)?.i64().clamp(0, 1_000_000) as usize; + i += 1; + for _ in 0..n { + let x = get(i).filter(|t| t.code == 10)?.f64(); + let y = get(i + 1).filter(|t| t.code == 20)?.f64(); + i += 2; + let mut b = 0.0; + if has_bulge && get(i).is_some_and(|t| t.code == 42) { + b = get(i)?.f64(); + i += 1; + } + verts.push(PolyVertex::with_bulge(Vec2::new(x, y), b)); + } + } else { + // Edge boundary: 93 edge count, each 72 edge type. + while get(i).is_some_and(|x| x.code != 93) { + i += 1; + } + let n = get(i)?.i64().clamp(0, 1_000_000) as usize; + i += 1; + for _ in 0..n { + while get(i).is_some_and(|x| x.code != 72) { + i += 1; + } + let ty = get(i)?.i64(); + i += 1; + let f = |i: usize| get(i).map(Tag::f64).unwrap_or(0.0); + match ty { + 1 => { + let a = Vec2::new(f(i), f(i + 1)); + let b = Vec2::new(f(i + 2), f(i + 3)); + i += 4; + if verts.last().is_none_or(|l| !l.p.near(a, 1e-9)) { + verts.push(PolyVertex::new(a)); + } + verts.push(PolyVertex::new(b)); + } + 2 => { + let c = Vec2::new(f(i), f(i + 1)); + let r = f(i + 2); + let sa = f(i + 3).to_radians(); + let ea = f(i + 4).to_radians(); + let ccw = get(i + 5).map(Tag::i64).unwrap_or(1) != 0; + i += 6; + let (s, e) = if ccw { (sa, ea) } else { (-sa, -ea) }; + let p0 = Vec2::polar(c, r, s); + let p1 = Vec2::polar(c, r, e); + let sweep = if ccw { cadcraft_geom::ccw_sweep(s, e) } else { -cadcraft_geom::ccw_sweep(e, s) }; + if verts.last().is_none_or(|l| !l.p.near(p0, 1e-9)) { + verts.push(PolyVertex::new(p0)); + } + // A full circle (0 to 360) starts and ends on the same point, which one + // bulge can't span: it goes in as two half circles. + let full = (sweep.abs() - std::f64::consts::TAU).abs() < 1e-9; + let bulge = cadcraft_geom::arc_to_bulge(if full { sweep / 2.0 } else { sweep }); + if let Some(l) = verts.last_mut() { + l.bulge = bulge; + } + if full { + verts.push(PolyVertex::with_bulge(Vec2::polar(c, r, s + sweep / 2.0), bulge)); + } + verts.push(PolyVertex::new(p1)); + } + 3 => { + // Elliptic arc: approximate with points. + let c = Vec2::new(f(i), f(i + 1)); + let m = Vec2::new(f(i + 2), f(i + 3)); + let ratio = f(i + 4); + let sa = f(i + 5).to_radians(); + let ea = f(i + 6).to_radians(); + let ccw = get(i + 7).filter(|t| t.code == 73).map(Tag::i64).unwrap_or(1) != 0; + i += 8; + // A clockwise edge stores its parameters mirrored, as an arc edge does: it + // runs from -start clockwise to -end, the ccw sweep from -end to -start + // walked backwards. + let (s, e) = if ccw { (sa, ea) } else { (-ea, -sa) }; + let e = cadcraft_geom::Ellipse { center: c, major: m, ratio, start: s, end: e }; + let mut pts = Vec::new(); + e.tessellate(m.len() * 1e-3, &mut pts); + if !ccw { + pts.reverse(); + } + verts.extend(pts.into_iter().map(PolyVertex::new)); + } + 4 => { + // Spline edge: 94 degree, 73 rational, 74 periodic, 95 knot count, 96 + // control point count, then the 40 knots, the 10/20 control points (each + // with a 42 weight when rational), and 97 fit point count with 11/21 fit + // points and 12/22, 13/23 end tangents (R2010+). + let (mut degree, mut nk, mut nc) = (3, 0, 0); + 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.clamp(1, 10), // same cap as the SPLINE reader + 95 => nk = n, + 96 => nc = n, + _ => {} + } + i += 1; + } + let mut knots = Vec::new(); + while knots.len() < nk && get(i).is_some_and(|x| x.code == 40) { + knots.push(f(i)); + i += 1; + } + let (mut control, mut weights) = (Vec::new(), Vec::new()); + while control.len() < nc && get(i).is_some_and(|x| x.code == 10) { + control.push(Vec2::new(f(i), f(i + 1))); + i += 2; + if get(i).is_some_and(|t| t.code == 42) { + weights.push(f(i)); + i += 1; + } + } + let mut fit = Vec::new(); + if get(i).is_some_and(|x| x.code == 97) { + let n = get(i).map(Tag::i64).unwrap_or(0).clamp(0, 100_000) as usize; + i += 1; + while fit.len() < n && get(i).is_some_and(|x| x.code == 11) { + fit.push(Vec2::new(f(i), f(i + 1))); + i += 2; + } + } + if weights.len() != control.len() { + weights.clear(); + } + let sp = Spline { degree, knots, control, weights, ..Spline::default() }; + let sp = if sp.is_valid() { + sp + } else if sp.control.is_empty() && fit.len() > 1 { + Spline::from_fit_points(&fit) + } else { + Spline::from_control(sp.control, degree) + }; + let pts = sp.tessellate(1e-3); + let skip = usize::from(verts.last().zip(pts.first()).is_some_and(|(l, p)| l.p.near(*p, 1e-9))); + verts.extend(pts.into_iter().skip(skip).map(PolyVertex::new)); + } + _ => {} + } + } + } + if verts.len() > 1 && verts.first().map(|f| f.p).zip(verts.last().map(|l| l.p)).is_some_and(|(a, b)| a.near(b, 1e-9)) { + verts.pop(); + } + // 97: number of source boundary objects, then their handles (330). A spline edge's fit + // data count is a 97 too, followed by its fit points instead. + let mut sources = Vec::new(); + for _ in 0..2 { + while get(i).is_some_and(|x| !matches!(x.code, 97 | 92 | 75)) { + i += 1; + } + if get(i).is_none_or(|x| x.code != 97) { + break; + } + let n = get(i).map(Tag::i64).unwrap_or(0).clamp(0, MAX_HATCH_SOURCES as i64) as usize; + i += 1; + if get(i).is_some_and(|x| !matches!(x.code, 330 | 92 | 75)) { + continue; + } + while sources.len() < n + && let Some(x) = get(i).filter(|x| x.code == 330) + { + sources.extend(Handle::parse_hex(&x.str()).filter(|h| h.0 != 0)); + i += 1; + } + break; + } + loops.push(HatchLoop { vertices: verts, outer, sources }); + } + let solid = solid || pattern.eq_ignore_ascii_case("SOLID"); + let (scale, angle) = (t.fd(41, 1.0), t.fd(52, 0.0).to_radians()); + // Keep the file's definition lines even for a name the library knows: the library's versions + // of some patterns (AR-CONC) only approximate AutoCAD's, and drawing them at the file's scale + // could be hundreds of times denser than the hatch the author saw. + let pattern_lines = if solid { Vec::new() } else { pattern_definition(tags, scale, angle, origin) }; + Some(EntityKind::Hatch(Hatch { + pattern: pattern.to_ascii_uppercase(), + solid, + loops, + scale, + angle, + associative: t.i(71).unwrap_or(0) != 0, + style: t.i(75).unwrap_or(0) as u8, + // The first 30 is the elevation point's z (boundary points are 2D). + elevation: t.f(30).filter(|z| z.is_finite()).unwrap_or(0.0), + gradient: gradient(tags), + origin, + background, + pattern_lines, + })) +} + +/// Upper bound on the source boundary objects of one HATCH loop. +const MAX_HATCH_SOURCES: usize = 4096; + +/// Upper bounds on a HATCH's pattern definition lines and on the dashes of one line. +const MAX_PATTERN_LINES: usize = 1024; + +const MAX_PATTERN_DASHES: usize = 256; + +/// The pattern definition lines of a HATCH (groups 78, then per line 53 angle, 43/44 base +/// point, 45/46 offset, 79 dash count, 49 dash lengths). A file holds them as drawn: scaled, +/// rotated with the hatch and the offset as a world vector. They are kept the library's way +/// (unit scale, no hatch rotation, base point relative to the hatch origin, offset as shift +/// along the line and spacing), which the writer and the renderer turn back. Lines with +/// non-finite values are skipped. +fn pattern_definition(tags: &[Tag], scale: f64, angle: f64, origin: Vec2) -> Vec { + let Some(at) = tags.iter().position(|x| x.code == 78) else { return Vec::new() }; + let scale = if scale.is_finite() && scale > 1e-9 { scale } else { 1.0 }; + let angle = if angle.is_finite() { angle } else { 0.0 }; + // Raw lines: angle (degrees), base, offset, dashes. + let mut raw: Vec<(f64, Vec2, Vec2, Vec)> = Vec::new(); + for x in tags.iter().skip(at + 1) { + let cur = raw.last_mut(); + match (x.code, cur) { + (53, _) => { + if raw.len() >= MAX_PATTERN_LINES { + break; + } + raw.push((x.f64(), Vec2::ZERO, Vec2::ZERO, Vec::new())); + } + (43, Some(l)) => l.1.x = x.f64(), + (44, Some(l)) => l.1.y = x.f64(), + (45, Some(l)) => l.2.x = x.f64(), + (46, Some(l)) => l.2.y = x.f64(), + (79, Some(_)) => {} + (49, Some(l)) => { + if l.3.len() < MAX_PATTERN_DASHES { + l.3.push(x.f64()); + } + } + _ => break, + } + } + raw.into_iter() + .filter(|(a, b, o, d)| a.is_finite() && b.is_finite() && o.is_finite() && d.iter().all(|v| v.is_finite())) + .map(|(a, base, offset, dashes)| { + let local = (base - origin).rotate(-angle) * (1.0 / scale); + let delta = offset.rotate(-a.to_radians()) * (1.0 / scale); + cadcraft_doc::library::PatternLine { + angle: a - angle.to_degrees(), + origin: (local.x, local.y), + delta: (delta.x, delta.y), + dashes: dashes.into_iter().map(|d| d / scale).collect(), + } + }) + .collect() +} + +/// Hatch origin and background colour from `CADCRAFT` xdata: `1000 HATCH`, `1011` origin, +/// `1000` background colour name (empty for none). Defaults when absent. +fn hatch_xdata(tags: &[Tag]) -> (Vec2, Option) { + let x = crate::dxf_ext::xdata(tags, crate::dxf_ext::APP); + let Some(at) = x.windows(2).position(|w| matches!(w, [m, o] if m.code == 1000 && m.str() == "HATCH" && o.code == 1011)) else { + return (Vec2::ZERO, None); + }; + let val = |k: usize, code: i32| x.get(at + k).filter(|t| t.code == code); + let coord = |k: usize, code: i32| val(k, code).map(Tag::f64).filter(|v| v.is_finite()).unwrap_or(0.0); + let origin = Vec2::new(coord(1, 1011), coord(2, 1021)); + (origin, val(4, 1000).and_then(|t| Color::parse(&t.str()))) +} + +/// Gradient values missing from a file fall back to the GRADIENT command's defaults. +fn default_gradient() -> Gradient { + Gradient { name: "LINEAR".into(), color1: Color::Index(5), color2: Color::Index(7), angle: 0.0, centered: true } +} + +/// A hatch's gradient fill: the native groups when present (another program may have edited +/// them), otherwise CADCraft's `GRADIENT` xdata (kept by R2000 files and DWG conversions). +fn gradient(tags: &[Tag]) -> Option { + if tags.iter().any(|x| x.code == 450) { native_gradient(tags) } else { xdata_gradient(tags) } +} + +/// `CADCRAFT` xdata: `1000 GRADIENT`, then name, 1040 angle, 1070 centered, colour 1 and 2 names. +fn xdata_gradient(tags: &[Tag]) -> Option { + let x = crate::dxf_ext::xdata(tags, crate::dxf_ext::APP); + let at = x.iter().position(|t| t.code == 1000 && t.str() == "GRADIENT")?; + let o = x.get(at + 1..).unwrap_or(&[]); + let d = default_gradient(); + let strs: Vec = o.iter().filter(|t| t.code == 1000).take(3).map(Tag::str).collect(); + let color = |i: usize, d: Color| strs.get(i).and_then(|s| Color::parse(s)).unwrap_or(d); + Some(Gradient { + name: strs.first().cloned().unwrap_or(d.name), + color1: color(1, d.color1), + color2: color(2, d.color2), + angle: o.iter().find(|t| t.code == 1040).map(Tag::f64).filter(|a| a.is_finite()).unwrap_or(d.angle), + centered: o.iter().find(|t| t.code == 1070).map_or(d.centered, |t| t.i64() != 0), + }) +} + +/// DXF Reference HATCH groups 450–470, when 450 says gradient. Each 463 starts a colour record +/// whose 63 (ACI) and 421 (true colour) groups follow. +fn native_gradient(tags: &[Tag]) -> Option { + let start = tags.iter().position(|x| x.code == 450)?; + if tags.get(start)?.i64() != 1 { + return None; + } + let mut g = default_gradient(); + // Per colour record: (ACI, true colour); a true colour wins over its ACI fallback. + let mut colors: [(Option, Option); 2] = [(None, None); 2]; + let mut slot: Option = None; + for x in tags.iter().skip(start + 1).take_while(|x| x.code != 1001) { + let rec = slot.and_then(|s| colors.get_mut(s)); + match x.code { + 460 => g.angle = Some(x.f64()).filter(|a| a.is_finite()).unwrap_or(0.0), + 461 => g.centered = x.f64().abs() < 1e-9, + 463 => slot = Some(slot.map_or(0, |s| s.saturating_add(1))), + 63 => { + if let Some(r) = rec { + r.0 = Some(Color::from_aci(i16::try_from(x.i64()).unwrap_or(256))); + } + } + 421 => { + if let Some(r) = rec { + r.1 = Some(Rgb::from_u32(u32::try_from(x.i64() & 0xff_ffff).unwrap_or(0))); + } + } + 470 => g.name = x.str(), + _ => {} + } + } + let [c1, c2] = colors; + let pick = |(aci, rgb): (Option, Option), d: Color| rgb.map(Color::True).or(aci).unwrap_or(d); + g.color1 = pick(c1, g.color1); + g.color2 = pick(c2, g.color2); + Some(g) +} diff --git a/crates/io/src/dxf_read/objects.rs b/crates/io/src/dxf_read/objects.rs new file mode 100644 index 000000000..97b30c786 --- /dev/null +++ b/crates/io/src/dxf_read/objects.rs @@ -0,0 +1,285 @@ +use super::common::T; +use super::dimassoc::dimassoc; +use super::document::sortents_table; +use super::*; + +/// Non-graphical objects applied once every entity is placed. +#[derive(Default)] +pub(super) struct Objects { + /// Dictionary entry handle (upper case) → entry name. + names: HashMap, + /// SCALE objects (the annotation scale list), in file order. + scales: Vec, + /// XRECORD handle (upper case) → its groups. + xrecords: HashMap>, + /// TABLESTYLE objects: (handle, groups). + table_styles: Vec<(String, Vec)>, + /// DIMASSOC objects. + dimassocs: Vec>, + /// MLEADERSTYLE objects: (handle, groups). + mleader_styles: Vec<(String, Vec)>, + /// GROUP objects: (handle, groups). + groups: Vec<(String, Vec)>, + /// Saved paper-space views of layouts: (layout name, view). + layout_views: Vec<(String, (Vec2, f64))>, + /// SORTENTSTABLE objects: (owning block record handle, entity handle → sort handle). + pub(super) sortents: Vec<(String, HashMap)>, + /// WIPEOUTVARIABLES: the drawing's WIPEOUTFRAME (0 = frames hidden, 1 = shown). + wipeout_frame: Option, +} + +pub(super) const MAX_OBJECTS: usize = 1_000_000; + +impl Objects { + pub(super) fn add(&mut self, kind: &str, tags: &[Tag]) { + let h = T(tags).s(5).map(|h| h.trim().to_ascii_uppercase()).unwrap_or_default(); + match kind { + "SCALE" if self.scales.len() < cadcraft_doc::MAX_SCALES => { + if let Some(s) = crate::dxf_ext::read_scale(tags) + && !self.scales.iter().any(|x| x.name.eq_ignore_ascii_case(&s.name)) + { + self.scales.push(s); + } + } + "DICTIONARY" => { + let mut name: Option = None; + for t in tags { + match t.code { + 3 => name = Some(t.str()), + 350 | 360 => { + if let Some(n) = name.take() + && self.names.len() < MAX_OBJECTS + { + self.names.insert(t.str().trim().to_ascii_uppercase(), n); + } + } + _ => {} + } + } + } + "XRECORD" if self.xrecords.len() < MAX_OBJECTS => { + self.xrecords.insert(h, tags.to_vec()); + } + "TABLESTYLE" if self.table_styles.len() < MAX_OBJECTS => self.table_styles.push((h, tags.to_vec())), + "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); + } + } + "LAYOUT" if self.layout_views.len() < MAX_OBJECTS => { + let start = tags.iter().position(|x| x.code == 100 && x.str() == "AcDbLayout").unwrap_or(0); + if let (Some(name), Some(view)) = (T(tags.get(start..).unwrap_or(&[])).s(1), layout_view(tags)) { + self.layout_views.push((name, view)); + } + } + _ => {} + } + } + + /// The text of the CADCraft XRECORD stored under `key` in a dictionary (its 1/3 strings). + pub(super) fn xrecord_text(&self, key: &str) -> Option { + let h = self.names.iter().find(|(_, n)| n.as_str() == key).map(|(h, _)| h)?; + let tags = self.xrecords.get(h)?; + let mut text = String::new(); + for t in tags.iter().filter(|t| t.code == 1 || t.code == 3) { + if text.len() > crate::dxf_ext::MAX_PAYLOAD { + break; + } + text.push_str(&t.str()); + } + Some(text) + } + + 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 }; + let st = crate::dxf_table::read_table_style(name, tags); + match d.table_styles.iter_mut().find(|s| s.name.eq_ignore_ascii_case(name)) { + Some(x) => *x = st, + 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 + // style's, so every cell style height applies as it is. + let height = { + let style = name.as_deref().unwrap_or("Standard"); + d.table_styles + .iter() + .find(|s| s.name.eq_ignore_ascii_case(style)) + .map_or(cadcraft_doc::TableStyle::default().text_height, |s| s.text_height) + }; + let _ = d.modify_entity(*h, |e| { + if let EntityKind::Table(t) = &mut e.kind { + if let Some(name) = name { + t.style = name; + } + t.text_height = height; + } + }); + } + } + + 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), + } + } + } + + pub(super) 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; + d.parametric = p; + } + // The annotation scale list (a metric drawing without one gets the metric defaults), + // then CADCraft's annotative scaling record. + if !self.scales.is_empty() { + d.scales = self.scales.clone(); + } else if d.header.i64("MEASUREMENT", 0) == 1 { + d.scales = cadcraft_doc::metric_scales(); + } + if let Some(p) = self.xrecord_text(crate::dxf_ext::ANNOTATION_KEY).and_then(|t| crate::dxf_ext::parse_anno(&t)) { + crate::dxf_ext::apply_anno(d, p); + } + // Saved layer states. + if let Some(states) = self.xrecord_text(crate::dxf_ext::LAYER_STATES_KEY).and_then(|t| crate::dxf_ext::parse_layer_states(&t)) { + d.layer_states = states; + } + // The drawing's plot style tables. + 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; + } + 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); + } + } + self.apply_groups(d); + // Standard associativity, for dimensions without CADCraft's exact links. + for tags in &self.dimassocs { + dimassoc(d, tags); + } + } +} + +/// Page setup from a LAYOUT object's AcDbPlotSettings fields (sizes and margins in mm). +pub(super) fn page_setup(t: &T) -> PageSetup { + let mut p = PageSetup::default(); + let mm = |code: i32, d: f64| { + let v = t.fd(code, d); + if v.is_finite() && (0.0..=100_000.0).contains(&v) { v } else { d } + }; + let (w, h) = (mm(44, 0.0), mm(45, 0.0)); + if w > 0.0 && h > 0.0 { + p.width_mm = w.min(h); + p.height_mm = w.max(h); + } + p.margins_mm = [mm(40, p.margins_mm[0]), mm(41, p.margins_mm[1]), mm(42, p.margins_mm[2]), mm(43, p.margins_mm[3])]; + // Plot paper units 1: millimetres. 0 (inches) is left to MEASUREMENT: earlier CADCraft + // versions wrote 0 for every layout. + p.paper_mm = t.i(72) == Some(1); + // Plot rotation 1 or 3 turns the media a quarter turn; media defined wider than tall is + // already landscape. + let rotated = matches!(t.i(73), Some(1 | 3)); + if w > 0.0 && h > 0.0 { + p.landscape = rotated != (w > h); + } else if t.i(73).is_some() { + p.landscape = rotated; + } + if let Some(name) = t.s(4).filter(|n| !n.is_empty()) { + p.paper = name.replace('_', " "); + } + if let Some(dev) = t.s(2).map(|s| s.trim().to_string()) { + p.device = if dev.is_empty() || dev.eq_ignore_ascii_case("none_device") { "None".into() } else { dev }; + } + // Plot layout flags: 2 display plot styles, 4 centred, 16 standard scale (75 = 0: scaled + // to fit), 128 lineweights. + if let Some(flags) = t.i(70) { + p.show_plot_styles = flags & 2 != 0; + p.center = flags & 4 != 0; + p.lineweights = flags & 128 != 0; + p.scale_to_fit = flags & 16 != 0 && t.i(75) == Some(0); + } + // Custom scale: 142 paper units per 143 drawing units; 147 is the scale factor. + let pos = |v: Option| v.filter(|v| v.is_finite() && *v > 0.0); + let ratio = pos(t.f(142)).zip(pos(t.f(143))).map(|(n, d)| n / d); + if let Some(s) = pos(ratio).or_else(|| pos(t.f(147))) { + p.scale = s; + } + p.plot_area = match t.i(74) { + Some(0) => "display", + Some(1) => "extents", + Some(2) => "limits", + Some(4) => "window", + _ => "layout", + } + .into(); + p.plot_window = [t.fd(48, 0.0), t.fd(49, 0.0), t.fd(140, 0.0), t.fd(141, 0.0)].map(|v| if v.is_finite() { v } else { 0.0 }); + p.plot_style_table = t.s(7).unwrap_or_default(); + p +} + +/// A layout's saved paper-space view (centre, height) from CADCraft xdata. +fn layout_view(tags: &[Tag]) -> Option<(Vec2, f64)> { + let list = crate::dxf_ext::xdata_list(crate::dxf_ext::xdata(tags, crate::dxf_ext::APP), "PSVIEW"); + let g = |c: i32| list.iter().find(|t| t.code == c).map(Tag::f64).filter(|v| v.is_finite()); + Some((Vec2::new(g(1010)?, g(1020)?), g(1040)?)) +} diff --git a/crates/io/src/dxf_read/tables.rs b/crates/io/src/dxf_read/tables.rs new file mode 100644 index 000000000..9419a081e --- /dev/null +++ b/crates/io/src/dxf_read/tables.rs @@ -0,0 +1,370 @@ +use super::common::T; +use super::objects::MAX_OBJECTS; +use super::*; + +pub(super) fn header(tags: &[Tag], d: &mut Drawing) { + let mut i = 0; + while let Some(t) = tags.get(i) { + if t.code == 9 { + let name = t.str().trim_start_matches('$').to_ascii_uppercase(); + i += 1; + let mut vals: Vec<&Tag> = Vec::new(); + while let Some(v) = tags.get(i) { + if v.code == 9 { + break; + } + vals.push(v); + i += 1; + } + // Legacy files keep spacing in HEADER; modern *ACTIVE VPORT entries override it. + if matches!(name.as_str(), "SNAPUNIT" | "GRIDUNIT") { + let (Some(x), Some(y)) = (vals.iter().find(|t| t.code == 10), vals.iter().find(|t| t.code == 20)) else { continue }; + let p = Vec2::new(x.f64(), y.f64()); + if p.is_finite() && if name == "GRIDUNIT" { p.x >= 0.0 && p.y >= 0.0 } else { p.x > 0.0 && p.y > 0.0 } { + d.header.set(&name, HVal::Point(p.to3(0.0))); + } + continue; + } + let hv = match vals.as_slice() { + [x, y, z] if x.code == 10 => HVal::Point(Vec3::new(x.f64(), y.f64(), z.f64())), + [x, y] if x.code == 10 => HVal::Point(Vec3::new(x.f64(), y.f64(), 0.0)), + [v] => match &v.value { + cadcraft_dxf::Value::Real(r) => HVal::Real(*r), + cadcraft_dxf::Value::Int(n) => HVal::Int(*n), + cadcraft_dxf::Value::Bool(b) => HVal::Int(i64::from(*b)), + other => HVal::Str(other.as_str()), + }, + _ => continue, + }; + // $DIMDSEP holds a character code; the dimension style holds the character. + let hv = match hv { + HVal::Int(n) if name == "DIMDSEP" => match u32::try_from(n).ok().and_then(char::from_u32).filter(|c| !c.is_control()) { + Some(c) => HVal::Str(c.to_string()), + None => continue, + }, + // $DIMJOGANG is in radians; the variable (like the style) holds degrees. + HVal::Real(r) if name == "DIMJOGANG" => HVal::Real((r.to_degrees() * 1e9).round() / 1e9), + // $ANGBASE is in degrees (a drawing measured from north has 90.0); the variable + // holds radians. + HVal::Real(r) if name == "ANGBASE" => HVal::Real(r.to_radians()), + hv => hv, + }; + d.header.set(&name, hv); + } else { + i += 1; + } + } +} + +/// True when a style record carries `AcadAnnotative` xdata with the annotative flag set. +fn annotative(tags: &[Tag]) -> bool { + let list = crate::dxf_ext::xdata_list(crate::dxf_ext::xdata(tags, "AcadAnnotative"), "AnnotativeData"); + list.iter().filter(|t| t.code == 1070).nth(1).is_some_and(|t| t.i64() != 0) +} + +/// A text style's font: CADCraft's own font name from `CADCRAFT` xdata (`1000 FONT`, name) +/// while the font file is still the `txt` written for other readers; the TrueType family of the +/// style's `ACAD` xdata (`1000` typeface) when it has no font file (how AutoCAD stores TrueType +/// styles); otherwise the file. +fn style_font(tags: &[Tag], file: String) -> String { + if file.trim().is_empty() + && let Some(face) = + crate::dxf_ext::xdata(tags, "ACAD").iter().find(|t| t.code == 1000).map(|t| t.str().trim().to_string()).filter(|f| !f.is_empty()) + { + return face; + } + let x = crate::dxf_ext::xdata(tags, crate::dxf_ext::APP); + let own = x.windows(2).find_map(|w| match w { + [m, n] if m.code == 1000 && m.str() == "FONT" && n.code == 1000 => Some(n.str()), + _ => None, + }); + let stem = file.trim().to_ascii_lowercase(); + match own { + Some(name) if !name.trim().is_empty() && (stem == "txt" || stem == "txt.shx") => name, + _ => file, + } +} + +/// Load symbol tables and resolve handle references, indexing layer names for case-insensitive updates. +pub(super) fn tables(tags: &[Tag], d: &mut Drawing, rx: &mut Rx) { + let recs = records(tags); + // STYLE handle → name, or the shape file of an unnamed shape style (linetype elements). + let ltype_styles: HashMap = recs + .iter() + .filter(|(k, _)| k == "STYLE") + .filter_map(|(_, tg)| { + let t = T(tg); + let name = t.s(2).filter(|n| !n.trim().is_empty()); + Some((t.s(5)?.trim().to_ascii_uppercase(), name.or_else(|| t.s(3))?)) + }) + .collect(); + // Handle maps first: DIMSTYLE records refer to text styles and arrow blocks by handle. + for (kind, tg) in &recs { + let t = T(tg); + let (Some(h), Some(n)) = (t.s(5), t.s(2)) else { continue }; + match kind.as_str() { + "STYLE" => { + rx.styles.insert(h.trim().to_ascii_uppercase(), n); + } + "BLOCK_RECORD" => { + let units = t.i(70).and_then(|u| u8::try_from(u).ok()).unwrap_or(0); + rx.block_props.insert(n.to_ascii_uppercase(), (units, t.i(280).unwrap_or(1) != 0)); + if let Some(a) = crate::dxf_ext::read_array(tg) { + rx.block_arrays.insert(n.to_ascii_uppercase(), a); + } + rx.brs.insert(h.trim().to_ascii_uppercase(), n); + } + "LTYPE" => { + rx.ltypes.insert(h.trim().to_ascii_uppercase(), n); + } + "LAYER" => { + rx.layers.insert(h.trim().to_ascii_uppercase(), n); + } + _ => {} + } + } + let mut active_vport = false; + // Index of layers already appended, by lower-cased name, so each LAYER record updates or + // appends in O(1) instead of scanning the growing layer list — otherwise many distinct layer + // names make import quadratic (CWE-407). + let mut layer_index: HashMap = d.layers.iter().enumerate().map(|(i, l)| (l.name.to_ascii_lowercase(), i)).collect(); + for (kind, tg) in recs { + let t = T(&tg); + match kind.as_str() { + "LAYER" => { + let name = t.s(2).unwrap_or_default(); + if name.is_empty() || d.layers.len() >= MAX_OBJECTS { + continue; + } + let aci = t.i(62).unwrap_or(7); + let flags = t.i(70).unwrap_or(0); + let mut l = Layer { + name: name.clone(), + color: Color::Index(aci.unsigned_abs().clamp(1, 255) as u8), + linetype: t.s(6).unwrap_or_else(|| "Continuous".into()), + lineweight: t.i(370).map(|v| Lineweight::from_dxf(v as i16)).unwrap_or(Lineweight::Default), + on: aci >= 0, + frozen: flags & 1 != 0, + locked: flags & 4 != 0, + vp_freeze_new: flags & 2 != 0, + plot: t.i(290).unwrap_or(1) != 0, + ..Layer::default() + }; + if let Some(tc) = t.i(420) { + l.color = Color::True(Rgb::from_u32(tc as u32)); + } + if let Some(n) = t.s(390).and_then(|h| rx.pstyles.get(&h.trim().to_ascii_uppercase())) { + l.plot_style = n.clone(); + } + let alpha = crate::dxf_ext::xdata(&tg, crate::dxf_ext::LAYER_TRANSPARENCY_APP).iter().find(|x| x.code == 1071); + if let Some(pct) = alpha.and_then(|x| crate::dxf_ext::transparency_from_dxf(x.i64())) { + l.transparency = pct; + } + if let Some(desc) = crate::dxf_ext::xdata(&tg, crate::dxf_ext::LAYER_DESCRIPTION_APP).iter().filter(|x| x.code == 1000).nth(1) { + l.description = desc.str(); + } + match layer_index.get(&name.to_ascii_lowercase()) { + Some(&i) => { + if let Some(x) = d.layers.get_mut(i) { + *x = l; + } + } + None => { + layer_index.insert(name.to_ascii_lowercase(), d.layers.len()); + d.layers.push(l); + } + } + } + "LTYPE" => { + let name = t.s(2).unwrap_or_default(); + if name.is_empty() || d.linetype(&name).is_some() || d.linetypes.len() >= MAX_OBJECTS { + continue; + } + let pattern = ltype_pattern(&tg, <ype_styles); + d.linetypes.push(Linetype { name, description: t.s(3).unwrap_or_default(), pattern }); + } + "STYLE" => { + let name = t.s(2).unwrap_or_default(); + if name.is_empty() || d.text_styles.len() >= MAX_OBJECTS { + continue; + } + // 70 bit 4: vertical; 71 bits 2/4: backwards / upside down. + let gen_flags = t.i(71).unwrap_or(0); + let st = TextStyle { + name: name.clone(), + font: style_font(&tg, t.s(3).unwrap_or_default()), + big_font: t.s(4).unwrap_or_default(), + height: t.fd(40, 0.0), + width_factor: t.fd(41, 1.0), + oblique: t.fd(50, 0.0).to_radians(), + backwards: gen_flags & 2 != 0, + upside_down: gen_flags & 4 != 0, + vertical: t.i(70).unwrap_or(0) & 4 != 0, + annotative: annotative(&tg), + }; + match d.text_styles.iter_mut().find(|s| s.name.eq_ignore_ascii_case(&name)) { + Some(x) => *x = st, + None => d.text_styles.push(st), + } + } + // The first *ACTIVE viewport holds the current snap/grid settings. + "VPORT" if !active_vport && t.s(2).is_some_and(|n| n.trim().eq_ignore_ascii_case("*ACTIVE")) => { + active_vport = true; + vport_record(d, &t); + for (name, code) in [("SNAPUNIT", 14), ("GRIDUNIT", 15)] { + if let (Some(x), Some(y)) = (t.f(code), t.f(code + 10)) { + let p = Vec2::new(x, y); + if p.is_finite() && if name == "GRIDUNIT" { p.x >= 0.0 && p.y >= 0.0 } else { p.x > 0.0 && p.y > 0.0 } { + d.header.set(name, HVal::Point(p.to3(0.0))); + } + } + } + if let (Some(x), Some(y)) = (t.f(13), t.f(23)) + && x.is_finite() + && y.is_finite() + { + d.header.set("SNAPBASE", HVal::Point(Vec3::new(x, y, 0.0))); + } + if let Some(a) = t.f(50).filter(|a| a.is_finite()) { + d.header.set_f64("SNAPANG", a); + } + } + "VIEW" => { + let name = t.s(2).unwrap_or_default(); + let (h, w) = (t.fd(40, 0.0), t.fd(41, 0.0)); + if name.is_empty() || !(h.is_finite() && w.is_finite() && h > 0.0 && w > 0.0) || d.views.len() >= MAX_OBJECTS { + continue; + } + let center = t.p(10).xy(); + if !(center.x.is_finite() && center.y.is_finite()) { + continue; + } + let x = crate::dxf_ext::xdata(&tg, crate::dxf_ext::APP); + let field = |key: &str| { + x.iter().position(|t| t.code == 1000 && t.str() == key).and_then(|i| x.get(i + 1)).filter(|t| t.code == 1000).map(Tag::str) + }; + // 70 bit 1: a paper space view; its layout is CADCraft xdata. + let layout = (t.i(70).unwrap_or(0) & 1 != 0).then(|| field("LAYOUT").unwrap_or_default()); + let twist = t.f(50).filter(|a| a.is_finite()).map_or(0.0, |a| a.rem_euclid(360.0).to_radians()); + let v = NamedView { name: name.clone(), center, height: h, width: w, layer_state: field("LAYERSTATE"), twist, layout }; + match d.views.iter_mut().find(|x| x.name.eq_ignore_ascii_case(&name)) { + Some(x) => *x = v, + None => d.views.push(v), + } + } + "UCS" => { + let name = t.s(2).unwrap_or_default(); + let (origin, x_axis, y_axis) = (t.p(10), t.p(11), t.p(12)); + let finite = [origin, x_axis, y_axis].iter().all(|p| p.x.is_finite() && p.y.is_finite() && p.z.is_finite()); + if name.is_empty() || !finite || d.ucss.len() >= MAX_OBJECTS { + continue; + } + let u = Ucs { name: name.clone(), origin, x_axis, y_axis }; + match d.ucss.iter_mut().find(|x| x.name.eq_ignore_ascii_case(&name)) { + Some(x) => *x = u, + None => d.ucss.push(u), + } + } + "DIMSTYLE" => { + let name = t.s(2).unwrap_or_default(); + if name.is_empty() || d.dim_styles.len() >= MAX_OBJECTS { + continue; + } + // 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(); + // 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); + } + } + fields.extend(crate::dxf_ext::read_newer_dimvars(&tg, &rx.styles, &rx.brs, &rx.ltypes)); + let mut st = DimStyle { name: name.clone(), annotative: annotative(&tg), ..DimStyle::default() }; + st.apply_fields(&fields); + match d.dim_styles.iter_mut().find(|s| s.name.eq_ignore_ascii_case(&name)) { + Some(x) => *x = st, + None => d.dim_styles.push(st), + } + } + "VPORT" => vport_record(d, &t), + _ => {} + } + } + // One *Active viewport is no tiling: the view is the editor's (it zooms to the extents). + d.vports.retain(|c| c.tiles.len() > usize::from(c.is_active())); +} + +/// Most tiles a viewport configuration keeps. +const MAX_VPORT_TILES: usize = 64; + +/// A VPORT record: one tile of the configuration it names (`*ACTIVE` the one shown). Corners +/// are fractions of the drawing area; unusable tiles are skipped. +fn vport_record(d: &mut Drawing, t: &T) { + let name = t.s(2).map(|n| n.trim().to_string()).unwrap_or_default(); + let (min, max, center) = (t.p(10).xy(), t.p(11).xy(), t.p(12).xy()); + let height = t.fd(40, 0.0); + let unit = |v: f64| v.is_finite() && (-1e-9..=1.0 + 1e-9).contains(&v); + let ok = [min.x, min.y, max.x, max.y].into_iter().all(unit) + && max.x > min.x + 1e-6 + && max.y > min.y + 1e-6 + && center.is_finite() + && height.is_finite() + && height > 0.0; + if name.is_empty() || !ok { + return; + } + let aspect = t.f(41).filter(|a| a.is_finite() && *a > 0.0).unwrap_or(0.0); + let clamp = |p: Vec2| Vec2::new(p.x.clamp(0.0, 1.0), p.y.clamp(0.0, 1.0)); + let tile = VportTile { min: clamp(min), max: clamp(max), center, height, aspect }; + let name = if name.eq_ignore_ascii_case(VportConfig::ACTIVE) { VportConfig::ACTIVE.to_string() } else { name }; + let room = d.vports.len() < MAX_OBJECTS; + match d.vports.iter_mut().find(|c| c.name.eq_ignore_ascii_case(&name)) { + Some(c) if c.tiles.len() < MAX_VPORT_TILES => c.tiles.push(tile), + Some(_) => {} + None if room => d.vports.push(VportConfig { name, tiles: vec![tile] }), + None => {} + } +} + +/// The dash elements of an LTYPE record with their embedded text and shapes (DXF Reference, +/// LTYPE): each `49` length starts an element; `74` type flags (1 absolute rotation, 2 text, +/// 4 shape), `75` shape number, `340` STYLE, `46` scale, `50` rotation (radians), `44`/`45` +/// offset and `9` text follow it. +fn ltype_pattern(tags: &[Tag], styles: &HashMap) -> Vec { + let mut out: Vec = Vec::new(); + for t in tags.iter().take_while(|t| t.code < 1000) { + if t.code == 49 { + out.push(DashElement::dash(t.f64())); + continue; + } + let Some(el) = out.last_mut() else { continue }; + let complex = el.text.is_some() || el.shape.is_some(); + let v = t.f64(); + match t.code { + 74 => { + let flags = t.i64(); + el.absolute = flags & 1 != 0; + if flags & 2 != 0 { + el.text = Some(String::new()); + } else if flags & 4 != 0 { + el.shape = Some(0); + } + } + 75 if el.shape.is_some() => el.shape = Some(u16::try_from(t.i64()).unwrap_or(0)), + 340 if complex => el.style = styles.get(&t.str().trim().to_ascii_uppercase()).filter(|n| !n.trim().is_empty()).cloned(), + 9 if el.text.is_some() => el.text = Some(t.str()), + 46 if complex && v.is_finite() => el.scale = v, + 50 if complex && v.is_finite() => el.rotation = v, + 44 if complex && v.is_finite() => el.offset.x = v, + 45 if complex && v.is_finite() => el.offset.y = v, + _ => {} + } + } + out +} diff --git a/crates/io/src/dxf_read/text.rs b/crates/io/src/dxf_read/text.rs new file mode 100644 index 000000000..45ee2f003 --- /dev/null +++ b/crates/io/src/dxf_read/text.rs @@ -0,0 +1,175 @@ +use super::common::{T, common, ocs_n}; +use super::*; + +/// The properties an ATTRIB carries itself: those it has groups for. Its layer counts only when +/// it differs from its block reference's (an attribute defined on layer 0 goes on the reference's +/// layer, as it is drawn anyway). +pub(super) fn attrib_props(t: &T, insert_layer: &str) -> AttribProps { + let c = common(t); + AttribProps { + layer: t.s(8).filter(|l| !l.is_empty() && !l.eq_ignore_ascii_case(insert_layer)), + color: (t.i(62).is_some() || t.i(420).is_some()).then_some(c.color), + linetype: t.s(6).filter(|l| !l.is_empty()), + lineweight: t.i(370).is_some().then_some(c.lineweight), + } +} + +/// An attribute's modes from its own groups: flags (70) 4 verify and 8 preset, and Lock +/// position, the 280 after its tag in its subclass (a 280 before the tag is the subclass version). +pub(super) fn attrib_modes(own: &[Tag]) -> AttribModes { + let flags = own.iter().find(|t| t.code == 70).map(Tag::i64).unwrap_or(0); + let sub = own.iter().position(|t| t.code == 100 && t.str().trim().starts_with("AcDbAttribute")).unwrap_or(0); + let rest = own.get(sub..).unwrap_or_default(); + let lock_position = rest.iter().position(|t| t.code == 2).is_some_and(|at| rest.iter().skip(at + 1).any(|t| t.code == 280 && t.i64() != 0)); + AttribModes { verify: flags & 4 != 0, preset: flags & 8 != 0, lock_position } +} + +/// An ATTRIB's or ATTDEF's groups split at its embedded multiline text (`101 Embedded Object`): +/// the attribute's own groups and, for a multiline attribute, the MTEXT groups after the marker. +pub(super) fn split_embedded(tags: &[Tag]) -> (&[Tag], Option<&[Tag]>) { + match tags.iter().position(|t| t.code == 101) { + Some(i) => (tags.get(..i).unwrap_or(tags), tags.get(i + 1..)), + None => (tags, None), + } +} + +/// A multiline attribute's text, from the MTEXT groups embedded in it. +pub(super) fn multiline_attrib(embedded: Option<&[Tag]>, c: &Common) -> Option { + let mtext = mtext_from(embedded.filter(|e| !e.is_empty())?, c); + let read_as = cadcraft_fonts::plain_mtext(&mtext.contents); + Some(MultilineAttrib { mtext, read_as }) +} + +/// Multiline text from MTEXT groups (an MTEXT entity's, or a multiline attribute's embedded ones). +pub(super) fn mtext_from(tags: &[Tag], c: &Common) -> MText { + let t = T(tags); + // Contents: code 3 chunks followed by code 1. + let mut contents = String::new(); + for tg in tags.iter().filter(|x| x.code == 3) { + contents.push_str(&tg.str()); + } + contents.push_str(&t.s(1).unwrap_or_default()); + let contents = mtext_dxf::decode_contents(&contents); + let columns = mtext_dxf::read_columns(tags); + // The direction comes from the MTEXT's own groups only: in an R2018+ embedded object + // (group 101), 11 is the insertion point, not the X-axis direction (#667, as in #834). + let own = T(mtext_dxf::before_columns(tags)); + let mut rotation = match (own.f(11), own.f(21)) { + (Some(x), Some(y)) if x != 0.0 || y != 0.0 => Vec2::new(x, y).angle(), + _ => own.fd(50, 0.0).to_radians(), + }; + let mut attach = t.i(71).unwrap_or(1).clamp(1, 9) as u8; + if c.extrusion.z < 0.0 { + // Insertion point and direction are WCS, but the line height runs along extrusion × + // direction: under (0,0,-1) the glyphs are mirrored. Drawn readable (MIRRTEXT = 0) with + // the same footprint, the text turns round and left/right attachment swap. + rotation = cadcraft_geom::norm_angle(rotation + std::f64::consts::PI); + attach = match attach { + 1 | 4 | 7 => attach + 2, + 3 | 6 | 9 => attach - 2, + a => a, + }; + } + MText { + insert: t.p(10), + height: t.fd(40, 0.2).max(1e-9), + width: match &columns { + Some((_, w)) if t.fd(41, 0.0) <= 0.0 => *w, + _ => t.fd(41, 0.0).max(0.0), + }, + attach, + rotation, + style: t.s(7).unwrap_or_else(|| "Standard".into()), + contents, + line_spacing: t.fd(44, 1.0), + line_spacing_exact: t.i(73) == Some(2), + columns: columns.map(|(c, _)| c), + } +} + +pub(super) fn text_from(t: &T) -> Text { + let h = match t.i(72).unwrap_or(0) { + 1 => HAlign::Center, + 2 => HAlign::Right, + 3 => HAlign::Aligned, + 4 => HAlign::Middle, + 5 => HAlign::Fit, + _ => HAlign::Left, + }; + let v = match t.i(73).or_else(|| t.i(74)).unwrap_or(0) { + 1 => VAlign::Bottom, + 2 => VAlign::Middle, + 3 => VAlign::Top, + _ => VAlign::Baseline, + }; + let align = if t.f(11).is_some() { Some(t.p(11)) } else { None }; + let mut text = Text { + insert: t.p(10), + align_pt: if h == HAlign::Left && v == VAlign::Baseline { None } else { align }, + height: t.fd(40, 0.2).max(1e-9), + value: t.s(1).unwrap_or_default(), + rotation: t.fd(50, 0.0).to_radians(), + width_factor: t.fd(41, 1.0), + oblique: t.fd(51, 0.0).to_radians(), + style: t.s(7).unwrap_or_else(|| "Standard".into()), + halign: h, + valign: v, + }; + let n = Vec3::new(t.fd(210, 0.0), t.fd(220, 0.0), t.fd(230, 1.0)); + if n.z < 0.0 { + mirror_text(&mut text, align.map(|a| ocs_n(n, a)), ocs_n(n, t.p(10))); + } + text +} + +/// TEXT / ATTRIB / ATTDEF with extrusion (0,0,-1): the points are OCS and the text runs from its +/// insertion point towards -x in the WCS, with mirrored glyphs. CADCraft draws text readable +/// (MIRRTEXT = 0), so it keeps the mirrored footprint: the direction turns to -rotation and +/// left/right justification swap ends (start ↔ end point). +fn mirror_text(t: &mut Text, align: Option, insert: Vec3) { + t.rotation = cadcraft_geom::norm_angle(-t.rotation); + let baseline_left = |t: &Text, p: Vec3| if t.valign == VAlign::Baseline { None } else { Some(p) }; + match t.halign { + HAlign::Left => { + // TL/ML/BL are placed by their alignment point, baseline-left text by its start point. + t.halign = HAlign::Right; + t.insert = insert; + t.align_pt = Some(if t.valign == VAlign::Baseline { insert } else { align.unwrap_or(insert) }); + } + HAlign::Right => { + t.halign = HAlign::Left; + t.insert = align.unwrap_or(insert); + t.align_pt = baseline_left(t, t.insert); + } + HAlign::Aligned | HAlign::Fit => { + t.insert = align.unwrap_or(insert); + t.align_pt = Some(insert); + } + HAlign::Center | HAlign::Middle => { + t.insert = insert; + t.align_pt = align; + } + } +} + +/// An ATTDEF, whose multiline text is embedded MTEXT after its own groups. +pub(super) 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), + } +} diff --git a/crates/io/src/dxf_write.rs b/crates/io/src/dxf_write.rs index 385e07580..75360ebae 100644 --- a/crates/io/src/dxf_write.rs +++ b/crates/io/src/dxf_write.rs @@ -12,6 +12,25 @@ use cadcraft_geom::{Bounds2, Vec2, Vec3}; use crate::dxf_ext::{self, DimVal, K}; +mod assoc; +mod blocks; +mod common; +mod ctx; +mod entity; +mod header; +mod layout; +mod objects; +mod sections; + +#[cfg(test)] +pub(crate) use assoc::polyline_vertex_ref; +pub(crate) use blocks::is_generated_arrow; +#[cfg(test)] +pub(crate) use blocks::table_block_entities; +use ctx::Ctx; +use entity::entity; +use sections::tags_for; + struct W { t: Vec, next: u64, @@ -80,1708 +99,6 @@ impl W { } } -/// The `DIMASSOC` object of an associative dimension (stored in the dimension's extension -/// dictionary under `ACAD_DIMASSOC`). -struct AssocObj { - xdict: String, - handle: String, - /// Associativity flag (bit per point reference). - flags: i64, - refs: Vec, -} - -/// An `AcDbOsnapPointRef` of a DIMASSOC object. -struct OsnapRef { - osnap: i64, - main: Handle, - /// The 1-based segment of a polyline the point is an end of (group 91, with subentity type - /// 73 = 2, edge); 0 for an object made of one curve. - segment: i64, - param: f64, - point: Vec3, - other: Option, -} - -/// Handles and names the entity writer needs from the rest of the file. -#[derive(Default)] -struct Ctx { - /// Multiline styles: MLINESTYLE handles and their dictionary. - mlines: crate::dxf_mline::Plan, - /// First viewport of each layout that has no paper-space viewport (id 1) → the layout's index; - /// [`paper_view`] writes that layout's paper-space viewport ahead of it. - paper_views: HashMap, - /// Dimension → anonymous `*D` block name. - dim_blocks: HashMap, - /// Upper-case text style name → STYLE record handle. - styles: HashMap, - /// Upper-case arrow name (as stored in styles/overrides) → BLOCK_RECORD handle. - arrows: HashMap, - /// Upper-case linetype name → LTYPE record handle (DIMLTYPE, DIMLTEX1, DIMLTEX2). - ltypes: HashMap, - /// Associative dimension → its DIMASSOC object. - assoc: HashMap, - /// Table → (anonymous `*T` block name, BLOCK_RECORD handle). - tables: HashMap, - /// Table style name → TABLESTYLE handle (the first is the fallback). - table_styles: Vec<(String, String)>, - /// Multileader style name → MLEADERSTYLE handle (the first is the fallback). - mleader_styles: Vec<(String, String)>, - /// Upper-case block name → BLOCK_RECORD handle (multileader block content). - block_records: HashMap, - /// Raster images' IMAGEDEF, IMAGEDEF_REACTOR and dictionary handles. - images: crate::dxf_image::Plan, - /// The version the file ends up in (the DWG version for a DWG save): it decides which - /// entity types exist (MULTILEADER from 2007). - target: Version, -} - -impl Ctx { - /// Whether multileaders are written as MULTILEADER (AutoCAD 2007 / AC1021 and later); - /// older files get LEADER + MTEXT/INSERT. - fn native_mleader(&self) -> bool { - self.target >= Version::R2007 - } - fn style(&self, name: &str) -> Option { - self.styles.get(&name.to_ascii_uppercase()).cloned() - } - fn arrow(&self, name: &str) -> Option { - self.arrows.get(&name.trim().to_ascii_uppercase()).cloned() - } - fn table_style(&self, name: &str) -> String { - Self::named(&self.table_styles, name) - } - fn mleader_style(&self, name: &str) -> String { - Self::named(&self.mleader_styles, name) - } - /// The handle of the style `name` in `styles`, or of the first one. - fn named(styles: &[(String, String)], name: &str) -> String { - styles.iter().find(|(n, _)| n.eq_ignore_ascii_case(name)).or_else(|| styles.first()).map(|(_, h)| h.clone()).unwrap_or_else(|| "0".into()) - } - /// A dimension variable encoded with this file's handles; integers outside the 16-bit - /// range of their groups are dropped. - fn encode(&self, kind: K, v: &serde_json::Value) -> Option { - let ltype = |n: &str| self.ltypes.get(&n.trim().to_ascii_uppercase()).cloned(); - dxf_ext::encode(kind, v, &|n| self.style(n), &|n| self.arrow(n), <ype) - .filter(|x| !matches!(x, DimVal::Int(i) if i16::try_from(*i).is_err())) - } -} - -/// The persistent reactors of boundary objects: each source object of an associative hatch in -/// the same space or block → the hatches it bounds (AutoCAD links the two both ways; without the -/// reactor the hatch no longer follows its boundary there). -fn hatch_reactors(d: &Drawing) -> HashMap> { - let mut out: HashMap> = HashMap::new(); - let stores = std::iter::once(&d.model).chain(d.layouts.iter().map(|l| &l.entities)).chain(d.blocks.values().map(|b| &b.entities)); - for st in stores { - for e in st.iter() { - let EntityKind::Hatch(h) = &e.kind else { continue }; - if !h.associative { - continue; - } - for s in h.loops.iter().flat_map(|l| &l.sources) { - if *s != e.handle && st.contains(*s) { - let r = out.entry(*s).or_default(); - let own = e.handle.hex(); - if !r.contains(&own) { - r.push(own); - } - } - } - } - } - out -} - -/// `AcadAnnotative` xdata marking an annotative style. -fn annotative_xdata(w: &mut W) { - w.s(1001, "AcadAnnotative"); - w.s(1000, "AnnotativeData"); - w.s(1002, "{"); - w.i(1070, 1); - w.i(1070, 1); - w.s(1002, "}"); -} - -/// Override (`ACAD` DSTYLE) and associativity (`CADCRAFT` ASSOC) xdata of a dimension. -fn dim_xdata(w: &mut W, dm: &Dimension, cx: &Ctx) { - let mut ov = W { t: Vec::new(), next: 0, pstyles: HashMap::new(), layers: HashMap::new(), reactors: HashMap::new() }; - let mut sah = false; - for (k, v) in &dm.overrides { - let Some(field) = DimStyle::field_name(k) else { continue }; - let Some((code, kind)) = dxf_ext::dim_code(field) else { continue }; - let Some(val) = dxf_ext::canonical(field, v) else { continue }; - let Some(enc) = cx.encode(kind, &val) else { continue }; - sah |= matches!(code, 343 | 344); - ov.i(1070, i64::from(code)); - ov.dimval(code, enc, true); - } - if sah { - ov.i(1070, i64::from(dxf_ext::DIMSAH)); - ov.i(1070, 1); - } - if !ov.t.is_empty() { - w.s(1001, "ACAD"); - w.s(1000, "DSTYLE"); - w.s(1002, "{"); - w.xdata(ov.t); - w.s(1002, "}"); - } - let mut ours = Vec::new(); - if matches!(dm.kind, DimKind::ArcLength) { - ours.extend(dxf_ext::arclen_xdata(dm.p15)); - } - if !dm.assoc.is_empty() { - ours.extend(dxf_ext::assoc_xdata(&dm.assoc)); - } - ours.extend(dxf_ext::marks_xdata(&dm.marks)); - if !ours.is_empty() { - w.s(1001, dxf_ext::APP); - w.xdata(ours); - } -} - -/// Group 52 of a linear or aligned dimension with oblique extension lines: their angle less the -/// dimension line's (`base`), in degrees. -fn oblique_52(w: &mut W, dm: &Dimension, base: f64) { - if let Some(a) = dm.marks.oblique.filter(|a| a.is_finite()) { - w.f(52, cadcraft_geom::norm_angle(a - base).to_degrees()); - } -} - -/// Header variables we write, with their group codes (DXF Reference, HEADER section). -/// `P2`/`P3` = 2D/3D point. -const HEADER_VARS: &[(&str, i32)] = &[ - ("INSBASE", -3), - ("LIMMIN", -2), - ("LIMMAX", -2), - ("SNAPUNIT", -2), - ("GRIDUNIT", -2), - ("ORTHOMODE", 70), - ("FILLMODE", 70), - ("MIRRTEXT", 70), - ("LTSCALE", 40), - ("ATTMODE", 70), - ("TEXTSIZE", 40), - ("TRACEWID", 40), - ("CMLSTYLE", 2), - ("CMLJUST", 70), - ("CMLSCALE", 40), - ("TEXTSTYLE", 7), - ("CLAYER", 8), - ("CELTYPE", 6), - ("CECOLOR", 62), - ("CELTSCALE", 40), - ("DIMSCALE", 40), - ("DIMSTYLE", 2), - ("LUNITS", 70), - ("LUPREC", 70), - ("AUNITS", 70), - ("AUPREC", 70), - ("ELEVATION", 40), - ("THICKNESS", 40), - ("FILLETRAD", 40), - ("CHAMFERA", 40), - ("CHAMFERB", 40), - ("ANGBASE", 50), - ("ANGDIR", 70), - ("PDMODE", 70), - ("PDSIZE", 40), - ("PLINEWID", 40), - ("CELWEIGHT", 370), - ("LWDISPLAY", 290), - ("INSUNITS", 70), - ("MEASUREMENT", 70), - ("PSLTSCALE", 70), -]; - -/// The current dimension style's variables as header variables (`$DIMTXT`, `$DIMDEC`…), after -/// `$DIMSTYLE`: AutoCAD draws new and updated dimensions with the header's DIM* values, which -/// otherwise stay its defaults (0.18 text, 4 decimals) whatever the current style says (#1051). -/// `$DIMSCALE` is a header variable of its own. -fn dim_header_vars(w: &mut W, d: &Drawing) { - let name = d.header.str("DIMSTYLE", "Standard"); - let Some(style) = d.dim_styles.iter().find(|s| s.name.eq_ignore_ascii_case(&name)) else { return }; - let v = serde_json::to_value(style).unwrap_or_default(); - let none = |_: &str| None; - for (var, field) in cadcraft_doc::DIMVARS { - if *var == "DIMSCALE" { - continue; - } - let (Some(value), Some((_, _, kind))) = (v.get(*field), dxf_ext::DIM_CODES.iter().find(|(f, _, _)| f == field)) else { continue }; - match kind { - // Names, not handles, in the header. - K::TextStyle | K::Block => { - let Some(n) = value.as_str() else { continue }; - if *kind == K::TextStyle && n.trim().is_empty() { - continue; - } - w.s(9, format!("${var}")); - w.s(if *kind == K::TextStyle { 7 } else { 1 }, n); - } - K::Linetype => {} - _ => match dxf_ext::encode(*kind, value, &none, &none, &none) { - Some(DimVal::Real(x)) => { - w.s(9, format!("${var}")); - w.f(40, x); - } - Some(DimVal::Int(i)) if i16::try_from(i).is_ok() => { - w.s(9, format!("${var}")); - w.i(70, i); - } - Some(DimVal::Str(t)) => { - w.s(9, format!("${var}")); - w.s(1, t); - } - _ => {} - }, - } - } -} - -fn header_vars(w: &mut W, d: &Drawing) { - for (name, code) in HEADER_VARS { - let Some(v) = d.header.get(name) else { continue }; - match (*code, v) { - (-2 | -3, HVal::Point(p)) => { - let p = match *name { - "SNAPUNIT" => d.header.snap_unit().to3(0.0), - "GRIDUNIT" => d.header.grid_unit().to3(0.0), - _ => *p, - }; - w.s(9, format!("${name}")); - w.f(10, p.x); - w.f(20, p.y); - if *code == -3 { - w.f(30, p.z); - } - } - (-2 | -3, _) => {} - (c @ (40 | 50), v) => { - if let Some(f) = v.as_f64() { - // ANGBASE is held in radians; `$ANGBASE` is in degrees. - let f = if *name == "ANGBASE" { f.to_degrees() } else { f }; - w.s(9, format!("${name}")); - w.f(c, f); - } - } - (c @ (62 | 70 | 290 | 370), v) => { - if let Some(i) = v.as_i64() { - w.s(9, format!("${name}")); - w.i(c, i); - } - } - (c, v) => { - if let Some(t) = v.as_str() { - w.s(9, format!("${name}")); - w.s(c, t); - } - } - } - } - dim_header_vars(w, d); -} - -/// An entity's group 440 (DXF Reference): ByBlock is `0x01000000`, a fixed transparency its alpha -/// with the "by alpha" flag `0x02000000` (the alpha mapping layers use). ByLayer writes nothing. -fn transparency_440(t: Transparency) -> Option { - match t { - Transparency::ByLayer => None, - Transparency::ByBlock => Some(0x0100_0000), - Transparency::Percent(p) => Some(i64::from(0x0200_0000 | ((100 - u32::from(p.min(90))) * 255 / 100))), - } -} - -fn common(w: &mut W, e: &Entity, owner: &str, paper: bool, subclass: &str) { - common_x(w, e, owner, paper, subclass, None); -} - -/// Common entity groups; `assoc` adds the reactor and extension dictionary of a DIMASSOC. -fn common_x(w: &mut W, e: &Entity, owner: &str, paper: bool, subclass: &str, assoc: Option<&AssocObj>) { - w.s(5, e.handle.hex()); - let mut reactors: Vec = assoc.map(|a| a.handle.clone()).into_iter().collect(); - reactors.extend(w.reactors.get(&e.handle).into_iter().flatten().cloned()); - if !reactors.is_empty() { - let rs: Vec<&str> = reactors.iter().map(String::as_str).collect(); - w.group("ACAD_REACTORS", 330, &rs); - } - if let Some(a) = assoc { - w.group("ACAD_XDICTIONARY", 360, &[&a.xdict]); - } - w.s(330, owner); - w.s(100, "AcDbEntity"); - if paper { - w.i(67, 1); - } - layer_and_props(w, &e.common, None); - if (e.common.ltscale - 1.0).abs() > 1e-12 { - w.f(48, e.common.ltscale); - } - if !e.common.visible { - w.i(60, 1); - } - if let Some(v) = transparency_440(e.common.transparency) { - w.i(440, v); - } - // A named plot style (not ByLayer/ByBlock): its placeholder object. - if let Some(h) = w.pstyles.get(&e.common.plot_style.trim().to_ascii_uppercase()).cloned() { - w.s(390, h); - } - if !subclass.is_empty() { - w.s(100, subclass); - } -} - -/// Layer, linetype, colour and lineweight groups (8, 6, 62/420, 370). ByLayer is left out, unless -/// it replaces something else in `base` (an attribute's definition). -fn layer_and_props(w: &mut W, c: &Common, base: Option<&Common>) { - let write = |by_layer: bool, base_by_layer: fn(&Common) -> bool| !by_layer || base.is_some_and(|b| !base_by_layer(b)); - w.s(8, &c.layer); - if write(c.linetype.eq_ignore_ascii_case("bylayer"), |b| b.linetype.eq_ignore_ascii_case("bylayer")) { - w.s(6, &c.linetype); - } - match c.color { - Color::ByLayer if write(true, |b| b.color == Color::ByLayer) => w.i(62, 256), - Color::ByLayer => {} - Color::True(rgb) => { - w.i(62, i64::from(cadcraft_color::nearest_aci(rgb))); - w.i(420, i64::from(rgb.to_u32())); - } - c => w.i(62, i64::from(c.to_aci())), - } - if write(c.lineweight == Lineweight::ByLayer, |b| b.lineweight == Lineweight::ByLayer) { - w.i(370, i64::from(c.lineweight.to_dxf())); - } -} - -/// The properties attribute `a` of block reference `e` (of `ins`) is drawn with: its own, else its -/// definition's; an attribute defined on layer 0 is on the reference's layer. Also the -/// definition's properties. -fn attrib_common(d: &Drawing, e: &Entity, ins: &Insert, a: &Attrib) -> (Common, Common) { - let def = d - .block(&ins.block) - .and_then(|b| b.entities.iter().find(|be| matches!(&be.kind, EntityKind::AttDef(ad) if ad.tag.eq_ignore_ascii_case(&a.tag)))); - let base = def.map(|de| de.common.clone()).unwrap_or_default(); - let mut c = a.props.over(&base); - if c.layer == "0" { - c.layer.clone_from(&e.common.layer); - } - (c, base) -} - -/// A multiline attribute's groups after its own (DXF 2007 and later): attribute type 2 (ATTRIB) -/// or 4 (ATTDEF), its alignment point, then its text as embedded MTEXT (`101 Embedded Object`). -/// Only what differs from unrotated text goes in the embedded object, as AutoCAD writes it. -fn multiline_tags(w: &mut W, a: &Attrib, attdef: bool) { - let Some(m) = a.multiline_text() else { return }; - w.i(71, if attdef { 4 } else { 2 }); - w.i(72, if attdef { 0 } else { 1 }); - w.p(11, a.text.align_pt.unwrap_or(a.text.insert)); - w.s(101, "Embedded Object"); - w.p(10, m.insert); - w.f(40, m.height); - w.f(41, m.width); - w.f(46, 0.0); - w.i(71, i64::from(m.attach)); - w.i(72, 5); - let chars: Vec = crate::dxf_read::mtext_dxf::encode_contents(&m.contents).chars().collect(); - let chunks: Vec = chars.chunks(250).map(|c| c.iter().collect()).collect(); - let n = chunks.len(); - for (i, c) in chunks.iter().enumerate() { - w.s(if i + 1 == n { 1 } else { 3 }, c.clone()); - } - if n == 0 { - w.s(1, ""); - } - w.s(7, &m.style); - if m.rotation != 0.0 { - w.p(11, Vec3::new(m.rotation.cos(), m.rotation.sin(), 0.0)); - } - w.i(73, if m.line_spacing_exact { 2 } else { 1 }); - w.f(44, m.line_spacing); -} - -/// ATTRIB/ATTDEF flags (group 70): 1 invisible, 2 constant, 4 verify, 8 preset. -fn attrib_flags(a: &Attrib) -> i64 { - i64::from(a.invisible) | if a.constant { 2 } else { 0 } | if a.modes.verify { 4 } else { 0 } | if a.modes.preset { 8 } else { 0 } -} - -/// A TEXT's groups, or an attribute's (`attrib`, in a file of version `target`) with its -/// subclass: the subclass version (`280 0`, 2010 and later), prompt, tag, flags and alignment, -/// then Lock position (`280`, 2007 and later; readers take the 280 after the tag as it). -/// The insertion point (group 10) to write for `t`. A justified text is placed by its alignment -/// point (group 11), and its insertion point is the start of its baseline, which AutoCAD reads -/// from DWG as is (it recomputes it only for DXF): writing the alignment point there drew centred -/// and right-aligned text from the wrong end in AutoCAD (#1050). -/// -/// The stored point is kept when it is close to where CADCraft's font puts the baseline start: -/// then it is AutoCAD's own (exact for its fonts). It is replaced when it is clearly stale: the -/// alignment point itself (text made here) or a text whose length changed since. Close means -/// along the baseline within a tenth of the text's width plus half its height (AutoCAD's fonts, -/// or the ones CADCraft substitutes for them, differ most in width, and most on short texts) and -/// across it within a fifth of its height: Left Middle and Left Bottom text made here, whose -/// alignment point is only half or a third of a height from the baseline start, must not pass -/// for AutoCAD's. -fn text_insert(d: &Drawing, t: &Text) -> Vec3 { - let computed = computed_text_insert(d, t); - if t.align_pt.is_some_and(|a| a.xy() == t.insert.xy()) { - return computed; - } - let h = t.height.abs(); - let off = (t.insert.xy() - computed.xy()).rotate(-t.rotation); - let close = if t.value.contains('\n') { - // A multiline attribute's lines are placed by its embedded MTEXT: a one-line baseline - // start says little about them, so only the distance counts. - let width = t.align_pt.map_or(0.0, |a| 2.0 * a.xy().dist(computed.xy())); - off.len() <= 0.1 * width + 0.5 * h - } else { - let width = cadcraft_fonts::line_width(&t.value, h, t.width_factor.abs()); - off.x.abs() <= 0.1 * width + 0.5 * h && off.y.abs() <= 0.2 * h - }; - if close { t.insert } else { computed } -} - -fn computed_text_insert(d: &Drawing, t: &Text) -> Vec3 { - let justified = !matches!((t.halign, t.valign), (HAlign::Left, VAlign::Baseline) | (HAlign::Aligned | HAlign::Fit, _)); - let Some(a) = t.align_pt.filter(|_| justified) else { return t.insert }; - let ts = d.text_style(&t.style).cloned().unwrap_or_default(); - let (h, v) = cadcraft_render::text_align(t.halign, t.valign); - let p = cadcraft_fonts::TextParams { - insert: t.insert.xy(), - align_pt: Some(a.xy()), - height: t.height, - rotation: t.rotation, - width_factor: t.width_factor, - oblique: t.oblique, - h, - v, - backwards: ts.backwards, - upside_down: ts.upside_down, - }; - let q = cadcraft_fonts::baseline_start(&cadcraft_fonts::TextFont::resolve(&ts.font), &t.value, &p); - if q.is_finite() { Vec3::new(q.x, q.y, a.z) } else { t.insert } -} - -fn text_tags(w: &mut W, d: &Drawing, t: &Text, attrib: Option<(&Attrib, Version)>, attdef_prompt: Option<&str>) { - w.p(10, text_insert(d, t)); - w.f(40, t.height); - w.s(1, &t.value); - if t.rotation != 0.0 { - w.f(50, t.rotation.to_degrees()); - } - if (t.width_factor - 1.0).abs() > 1e-12 { - w.f(41, t.width_factor); - } - if t.oblique != 0.0 { - w.f(51, t.oblique.to_degrees()); - } - w.s(7, &t.style); - let h = match t.halign { - HAlign::Left => 0, - HAlign::Center => 1, - HAlign::Right => 2, - HAlign::Aligned => 3, - HAlign::Middle => 4, - HAlign::Fit => 5, - }; - let v = match t.valign { - VAlign::Baseline => 0, - VAlign::Bottom => 1, - VAlign::Middle => 2, - VAlign::Top => 3, - }; - if h != 0 { - w.i(72, h); - } - if let Some(a) = t.align_pt { - w.p(11, a); - } - match attrib { - Some((a, target)) => { - w.s(100, if attdef_prompt.is_some() { "AcDbAttributeDefinition" } else { "AcDbAttribute" }); - if target >= Version::R2010 { - w.i(280, 0); - } - if let Some(prompt) = attdef_prompt { - w.s(3, prompt); - } - w.s(2, &a.tag); - w.i(70, attrib_flags(a)); - if v != 0 { - w.i(74, v); - } - if target >= Version::R2007 { - w.i(280, i64::from(a.modes.lock_position)); - } - } - None => { - w.s(100, "AcDbText"); - if v != 0 { - w.i(73, v); - } - } - } -} - -fn entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: bool, cx: &Ctx) { - match &e.kind { - EntityKind::Line(l) => { - w.s(0, "LINE"); - common(w, e, owner, paper, "AcDbLine"); - thickness(w, e); - w.p(10, l.a); - w.p(11, l.b); - } - EntityKind::Point(p) => { - w.s(0, "POINT"); - common(w, e, owner, paper, "AcDbPoint"); - w.p(10, p.p); - thickness(w, e); - if p.angle != 0.0 { - w.f(50, p.angle.to_degrees()); - } - } - EntityKind::Circle(c) => { - w.s(0, "CIRCLE"); - common(w, e, owner, paper, "AcDbCircle"); - thickness(w, e); - w.p(10, c.center); - w.f(40, c.radius); - } - EntityKind::Arc(a) => { - w.s(0, "ARC"); - common(w, e, owner, paper, "AcDbCircle"); - thickness(w, e); - w.p(10, a.center); - w.f(40, a.radius); - w.s(100, "AcDbArc"); - w.f(50, a.start.to_degrees()); - w.f(51, a.end.to_degrees()); - } - EntityKind::Ellipse(el) => { - w.s(0, "ELLIPSE"); - common(w, e, owner, paper, "AcDbEllipse"); - w.p(10, el.center); - w.p(11, el.major); - w.f(40, el.ratio); - w.f(41, el.start); - w.f(42, el.end); - } - EntityKind::LwPolyline(p) => { - w.s(0, "LWPOLYLINE"); - common(w, e, owner, paper, "AcDbPolyline"); - w.i(90, p.vertices.len() as i64); - w.i(70, i64::from(p.closed) | if p.plinegen { 128 } else { 0 }); - if p.const_width > 0.0 { - w.f(43, p.const_width); - } - if p.elevation != 0.0 { - w.f(38, p.elevation); - } - thickness(w, e); - for v in &p.vertices { - w.p2(10, v.p); - if v.start_width != 0.0 || v.end_width != 0.0 { - w.f(40, v.start_width); - w.f(41, v.end_width); - } - if v.bulge != 0.0 { - w.f(42, v.bulge); - } - } - } - EntityKind::MLine(m) => { - w.s(0, "MLINE"); - common(w, e, owner, paper, "AcDbMline"); - w.t.extend(crate::dxf_mline::entity_tags(m, &cx.mlines.handle(&m.style))); - } - EntityKind::Polyline3d(p) => { - w.s(0, "POLYLINE"); - common(w, e, owner, paper, "AcDb3dPolyline"); - w.i(66, 1); - w.p(10, Vec3::ZERO); - thickness(w, e); - w.i(70, 8 | i64::from(p.closed)); - for q in &p.points { - let vh = w.h(); - w.s(0, "VERTEX"); - w.s(5, vh); - w.s(330, e.handle.hex()); - w.s(100, "AcDbEntity"); - w.s(8, &e.common.layer); - w.s(100, "AcDbVertex"); - w.s(100, "AcDb3dPolylineVertex"); - w.p(10, *q); - w.i(70, 32); - } - let sh = w.h(); - w.s(0, "SEQEND"); - w.s(5, sh); - w.s(330, e.handle.hex()); - w.s(100, "AcDbEntity"); - w.s(8, &e.common.layer); - } - EntityKind::Spline(s) => { - w.s(0, "SPLINE"); - common(w, e, owner, paper, "AcDbSpline"); - let flags = 8 | i64::from(s.closed) | if s.is_periodic() { 2 } else { 0 } | if s.weights.is_empty() { 0 } else { 4 }; - w.i(70, flags); - w.i(71, s.degree as i64); - w.i(72, s.knots.len() as i64); - w.i(73, s.control.len() as i64); - w.i(74, s.fit.len() as i64); - w.f(42, 1e-10); - w.f(43, 1e-10); - // Fit data: tolerance and end tangents (the knot parametrisation is implied by the knots). - if !s.fit.is_empty() { - w.f(44, s.fit_opts.tolerance); - if let Some(t) = s.fit_opts.start_tangent { - w.p(12, t.to3(0.0)); - } - if let Some(t) = s.fit_opts.end_tangent { - w.p(13, t.to3(0.0)); - } - } - for k in &s.knots { - w.f(40, *k); - } - for (i, c) in s.control.iter().enumerate() { - w.p(10, c.to3(0.0)); - if let Some(wt) = s.weights.get(i) { - w.f(41, *wt); - } - } - for f in &s.fit { - w.p(11, f.to3(0.0)); - } - } - EntityKind::Helix(h) => { - // A cubic spline through points on the helix, then the helix data (DXF Reference). - w.s(0, "HELIX"); - common(w, e, owner, paper, "AcDbSpline"); - let (s, z) = h.spline3(); - w.i(70, 0); - w.i(71, s.degree as i64); - w.i(72, s.knots.len() as i64); - w.i(73, s.control.len() as i64); - w.i(74, 0); - w.f(42, 1e-10); - w.f(43, 1e-10); - for k in &s.knots { - w.f(40, *k); - } - for (c, z) in s.control.iter().zip(&z) { - w.p(10, c.to3(*z)); - } - w.s(100, "AcDbHelix"); - w.i(90, 29); - w.i(91, 63); - w.p(10, h.center); - w.p(11, h.start_point()); - w.p(12, Vec3::Z); - w.f(40, h.base_radius); - w.f(41, h.turns); - w.f(42, h.turn_height()); - w.i(290, i64::from(h.ccw)); - w.i(280, h.constrain.to_dxf()); - } - EntityKind::Ray(r) | EntityKind::XLine(r) => { - let ray = matches!(e.kind, EntityKind::Ray(_)); - w.s(0, if ray { "RAY" } else { "XLINE" }); - common(w, e, owner, paper, if ray { "AcDbRay" } else { "AcDbXline" }); - w.p(10, r.base); - w.p(11, r.dir); - } - EntityKind::Text(t) => { - w.s(0, "TEXT"); - common(w, e, owner, paper, "AcDbText"); - thickness(w, e); - text_tags(w, d, t, None, None); - } - // Closed polylines, one per loop (`dxf_region`): REGION's own data is ACIS. - EntityKind::Region(r) => { - for (i, tags) in crate::dxf_region::polylines(r).into_iter().enumerate() { - w.s(0, "LWPOLYLINE"); - if i == 0 { - common(w, e, owner, paper, "AcDbPolyline"); - } else { - // The other loops get handles of their own (only the handle and properties are written). - let h = Handle::parse_hex(&w.h()).unwrap_or(e.handle); - let part = Entity { handle: h, common: e.common.clone(), kind: EntityKind::Point(Point { p: Vec3::ZERO, angle: 0.0 }) }; - common(w, &part, owner, paper, "AcDbPolyline"); - } - w.t.extend(tags); - } - } - EntityKind::AttDef(a) => { - w.s(0, "ATTDEF"); - common(w, e, owner, paper, "AcDbText"); - text_tags(w, d, &a.text, Some((a, cx.target)), Some(&a.prompt)); - if cx.target >= Version::R2007 { - multiline_tags(w, a, true); - } - } - EntityKind::MText(t) => { - w.s(0, "MTEXT"); - common(w, e, owner, paper, "AcDbMText"); - w.p(10, t.insert); - w.f(40, t.height); - w.f(41, t.width); - w.i(71, i64::from(t.attach)); - w.i(72, 1); - // Long contents go in 250-char 3-chunks, the remainder in 1. - let chars: Vec = crate::dxf_read::mtext_dxf::encode_contents(&t.contents).chars().collect(); - let chunks: Vec = chars.chunks(250).map(|c| c.iter().collect()).collect(); - let n = chunks.len(); - for (i, c) in chunks.iter().enumerate() { - w.s(if i + 1 == n { 1 } else { 3 }, c.clone()); - } - if n == 0 { - w.s(1, ""); - } - w.s(7, &t.style); - if t.rotation != 0.0 { - w.f(50, t.rotation.to_degrees()); - } - // Line spacing style: 1 = at least, 2 = exactly. - w.i(73, if t.line_spacing_exact { 2 } else { 1 }); - w.f(44, t.line_spacing); - w.t.extend(crate::dxf_read::mtext_dxf::column_tags(t)); - } - EntityKind::Insert(i) => { - w.s(0, "INSERT"); - common(w, e, owner, paper, "AcDbBlockReference"); - if !i.attribs.is_empty() { - w.i(66, 1); - } - w.s(2, &i.block); - w.p(10, i.insert); - w.f(41, i.scale.x); - w.f(42, i.scale.y); - w.f(43, i.scale.z); - w.f(50, i.rotation.to_degrees()); - if i.cols > 1 || i.rows > 1 { - w.i(70, i64::from(i.cols)); - w.i(71, i64::from(i.rows)); - w.f(44, i.col_spacing); - w.f(45, i.row_spacing); - } - if !i.attribs.is_empty() { - for a in &i.attribs { - let ah = w.h(); - w.s(0, "ATTRIB"); - w.s(5, ah); - w.s(330, e.handle.hex()); - w.s(100, "AcDbEntity"); - let (c, base) = attrib_common(d, e, i, a); - layer_and_props(w, &c, Some(&base)); - // Hidden with its definition (a dynamic block's visibility state hides it). - if !base.visible { - w.i(60, 1); - } - w.s(100, "AcDbText"); - text_tags(w, d, &a.text, Some((a, cx.target)), None); - if cx.target >= Version::R2007 { - multiline_tags(w, a, false); - } - } - let sh = w.h(); - w.s(0, "SEQEND"); - w.s(5, sh); - w.s(330, e.handle.hex()); - w.s(100, "AcDbEntity"); - w.s(8, &e.common.layer); - } - } - EntityKind::Dimension(dm) => { - w.s(0, "DIMENSION"); - common_x(w, e, owner, paper, "AcDbDimension", cx.assoc.get(&e.handle)); - let bname = cx.dim_blocks.get(&e.handle).cloned().or_else(|| dm.block.clone()).unwrap_or_default(); - w.s(2, bname); - w.p(10, dm.defpt); - let style = d.dim_style(&dm.style).cloned().unwrap_or_default(); - let g = cadcraft_render::dimension_geometry(dm, &style, d.header.f64("DIMSCALE", 1.0)); - w.p(11, if dm.user_text_pos { dm.text_mid } else { g.text_pos.to3(0.0) }); - let ty: i64 = match dm.kind { - DimKind::Linear { .. } => 0, - DimKind::Aligned => 1, - DimKind::Angular => 2, - DimKind::Diameter => 3, - DimKind::Radius => 4, - DimKind::Angular3P => 5, - DimKind::Ordinate { x_type } => 6 | if x_type { 64 } else { 0 }, - // Written as aligned, marked by CADCraft xdata (see `dim_xdata`). - DimKind::ArcLength => 1, - }; - w.i(70, ty | 32 | if dm.user_text_pos { 128 } else { 0 }); - // The actual measurement (read-only in DXF): AutoLISP's entget shows it. - w.f(42, g.measurement); - if !dm.text.is_empty() { - w.s(1, &dm.text); - } - w.s(3, &dm.style); - if dm.text_rotation != 0.0 { - w.f(53, dm.text_rotation.to_degrees()); - } - match dm.kind { - DimKind::Linear { rotation } => { - w.s(100, "AcDbAlignedDimension"); - w.p(13, dm.p13); - w.p(14, dm.p14); - w.f(50, rotation.to_degrees()); - oblique_52(w, dm, rotation); - w.s(100, "AcDbRotatedDimension"); - } - DimKind::Aligned | DimKind::ArcLength => { - w.s(100, "AcDbAlignedDimension"); - w.p(13, dm.p13); - w.p(14, dm.p14); - if matches!(dm.kind, DimKind::Aligned) { - oblique_52(w, dm, (dm.p14.xy() - dm.p13.xy()).angle()); - } - } - DimKind::Radius => { - w.s(100, "AcDbRadialDimension"); - w.p(15, dm.p15); - w.f(40, 0.0); - } - DimKind::Diameter => { - w.s(100, "AcDbDiametricDimension"); - w.p(15, dm.p15); - w.f(40, 0.0); - } - DimKind::Angular => { - w.s(100, "AcDb2LineAngularDimension"); - w.p(13, dm.p13); - w.p(14, dm.p14); - w.p(15, dm.p15); - w.p(16, dm.p16); - } - DimKind::Angular3P => { - w.s(100, "AcDb3PointAngularDimension"); - w.p(13, dm.p13); - w.p(14, dm.p14); - w.p(15, dm.p15); - } - DimKind::Ordinate { .. } => { - w.s(100, "AcDbOrdinateDimension"); - w.p(13, dm.p13); - w.p(14, dm.p14); - } - } - dim_xdata(w, dm, cx); - } - EntityKind::Table(t) => { - // Tables without a generated block (none expected) are not written. - let Some((bname, brh)) = cx.tables.get(&e.handle) else { return }; - w.s(0, "ACAD_TABLE"); - common(w, e, owner, paper, "AcDbBlockReference"); - w.s(2, bname); - w.p(10, t.insert); - w.s(100, "AcDbTable"); - w.i(280, 0); - w.s(342, cx.table_style(&t.style)); - w.s(343, brh); - w.p(11, Vec3::new(1.0, 0.0, 0.0)); - w.i(90, 0); - let (rows, cols) = table_size(t); - w.i(91, rows as i64); - w.i(92, cols as i64); - for c in [93, 94, 95, 96] { - w.i(c, 0); - } - for h in t.row_heights.iter().take(rows) { - w.f(141, *h); - } - for c in t.col_widths.iter().take(cols) { - w.f(142, *c); - } - let cov = cadcraft_render::table_covered(t); - let span = |v: u32| i64::from(v.min(i16::MAX as u32)); - for r in 0..rows { - for c in 0..cols { - let cell = t.cells.get(r).and_then(|x| x.get(c)); - let covered = cov.get(r).and_then(|x| x.get(c)).copied().unwrap_or(false); - let merge = cell.and_then(|x| x.merged).filter(|&(a, b)| a >= 1 && b >= 1 && (a, b) != (1, 1)); - w.i(171, 1); - w.i(172, 0); - w.i(173, i64::from(covered || merge.is_some())); - w.i(174, 0); - let (rs, cs) = if covered { (0, 0) } else { merge.map(|(a, b)| (span(a), span(b))).unwrap_or((1, 1)) }; - w.i(175, cs); - w.i(176, rs); - w.i(91, 0); - w.i(178, 0); - w.f(145, 0.0); - if let Some(h) = cell.and_then(|x| x.text_height).filter(|h| h.is_finite() && *h > 0.0) { - w.f(140, h); - } - // Long text: 250-character code-2 chunks, the rest in code 1. - let chars: Vec = cell.map(|x| x.text.chars().collect()).unwrap_or_default(); - let chunks: Vec = chars.chunks(250).map(|c| c.iter().collect()).collect(); - let n = chunks.len(); - for (i, ch) in chunks.into_iter().enumerate() { - w.s(if i + 1 == n { 1 } else { 2 }, ch); - } - if n == 0 { - w.s(1, ""); - } - } - } - w.s(1001, dxf_ext::APP); - w.s(1000, "TABLE"); - w.i(1070, i64::from(t.title)); - w.i(1070, i64::from(t.header)); - w.f(1040, t.text_height); - w.s(1000, &t.style); - for c in crate::dxf_table::table_xdata(t) { - w.s(1000, c); - } - } - EntityKind::MLeader(m) if !cx.native_mleader() => mleader_fallback(w, d, e, m, owner, paper, cx), - EntityKind::MLeader(m) => { - w.s(0, "MULTILEADER"); - common(w, e, owner, paper, "AcDbMLeader"); - let refs = crate::dxf_mleader::Refs { - style: cx.mleader_style(&m.style), - text_style: m.text.as_ref().and_then(|t| cx.style(&t.style)), - block: m.block.as_ref().and_then(|b| cx.block_records.get(&b.block.to_ascii_uppercase()).cloned()), - }; - w.t.extend(crate::dxf_mleader::entity_tags(d, m, &refs)); - } - EntityKind::Leader(l) => { - w.s(0, "LEADER"); - common(w, e, owner, paper, "AcDbLeader"); - w.s(3, &l.style); - w.i(71, i64::from(l.arrow)); - w.i(72, i64::from(l.spline)); - w.i(76, l.vertices.len() as i64); - for v in &l.vertices { - w.p(10, *v); - } - } - EntityKind::Image(i) => { - // Every image has its IMAGEDEF (planned in `write`); the fallback never happens. - let Some((def, reactor)) = cx.images.by_entity.get(&e.handle) else { return }; - w.s(0, "IMAGE"); - common(w, e, owner, paper, "AcDbRasterImage"); - w.t.extend(crate::dxf_image::entity_tags(i, def, reactor)); - } - EntityKind::Solid(s) | EntityKind::Trace(s) => { - let solid = matches!(e.kind, EntityKind::Solid(_)); - w.s(0, if solid { "SOLID" } else { "TRACE" }); - common(w, e, owner, paper, "AcDbTrace"); - w.p(10, s.corners[0]); - w.p(11, s.corners[1]); - w.p(12, s.corners[2]); - w.p(13, s.corners[3]); - thickness(w, e); - } - EntityKind::Face3d(f) => { - w.s(0, "3DFACE"); - common(w, e, owner, paper, "AcDbFace"); - w.p(10, f.corners[0]); - w.p(11, f.corners[1]); - w.p(12, f.corners[2]); - w.p(13, f.corners[3]); - w.i(70, i64::from(f.hidden_edges)); - } - EntityKind::Hatch(h) => { - w.s(0, "HATCH"); - common(w, e, owner, paper, "AcDbHatch"); - w.p(10, Vec3::new(0.0, 0.0, h.elevation)); - w.p(210, Vec3::Z); - w.s(2, &h.pattern); - w.i(70, i64::from(h.solid)); - w.i(71, i64::from(h.associative)); - w.i(91, h.loops.len() as i64); - for l in &h.loops { - w.i(92, 2 | if l.outer { 1 } else { 0 }); - let has_bulge = l.vertices.iter().any(|v| v.bulge != 0.0); - w.i(72, i64::from(has_bulge)); - w.i(73, 1); - w.i(93, l.vertices.len() as i64); - for v in &l.vertices { - w.p2(10, v.p); - if has_bulge { - w.f(42, v.bulge); - } - } - // Source boundary objects of an associative hatch (the ones still beside it, in - // its space or block, which carry the hatch as a reactor). - let own = e.handle.hex(); - let sources: Vec = - l.sources.iter().filter(|s| h.associative && w.reactors.get(*s).is_some_and(|r| r.contains(&own))).map(|s| s.hex()).collect(); - w.i(97, sources.len() as i64); - for s in &sources { - w.s(330, s); - } - } - w.i(75, i64::from(h.style)); - w.i(76, 1); - if !h.solid { - w.f(52, h.angle.to_degrees()); - w.f(41, h.scale); - w.i(77, 0); - // The lines read from the file, else the library's pattern of that name. - let lines = if h.pattern_lines.is_empty() { - cadcraft_doc::library::pattern_for(d, &h.pattern).map(|p| p.lines).unwrap_or_default() - } else { - h.pattern_lines.clone() - }; - w.i(78, lines.len() as i64); - for pl in lines { - let ang = pl.angle.to_radians() + h.angle; - w.f(53, ang.to_degrees()); - let o = h.origin + Vec2::new(pl.origin.0, pl.origin.1).rotate(h.angle) * h.scale; - w.f(43, o.x); - w.f(44, o.y); - let dl = Vec2::new(pl.delta.0, pl.delta.1).rotate(ang) * h.scale; - w.f(45, dl.x); - w.f(46, dl.y); - w.i(79, pl.dashes.len() as i64); - for dsh in pl.dashes { - w.f(49, dsh * h.scale); - } - } - } - w.i(98, 0); - if let Some(g) = &h.gradient { - gradient(w, g); - } - hatch_xdata(w, h); - } - EntityKind::Viewport(v) => { - paper_view(w, d, e, owner, cx); - w.s(0, "VIEWPORT"); - common(w, e, owner, paper, "AcDbViewport"); - w.p(10, v.center); - w.f(40, v.width); - w.f(41, v.height); - // Status 68: a positive value is "on" (the stacking order; 1 is the active viewport), 0 off. - w.i(68, if v.on { i64::from(v.id.clamp(1, 32767)) } else { 0 }); - w.i(69, i64::from(v.id)); - w.p2(12, v.view_center); - w.f(45, v.view_height); - // Status flags: 16384 = display locked, 32768 = always set, 65536 = clipped by the - // 340 entity, 131072 = off. - let clip = v.clip.filter(|h| *h != e.handle && d.entity(*h).is_some()); - let flags = 32768 | if v.locked { 16384 } else { 0 } | if clip.is_some() { 65536 } else { 0 } | if v.on { 0 } else { 131072 }; - w.i(90, flags); - if let Some(h) = clip { - w.s(340, h.hex()); - } - // AutoCAD's frozen-layer list: one soft pointer per layer, to its LAYER record. Other - // programs read only this; CADCraft prefers its own xdata below when present. - for name in &v.frozen_layers { - if let Some(h) = w.layers.get(&name.to_ascii_uppercase()).cloned() { - w.s(341, h); - } - } - // One CADCraft xdata group holds both lists: readers stop at the first group of an app. - if !v.frozen_layers.is_empty() || !v.layer_colors.is_empty() { - w.s(1001, dxf_ext::APP); - if !v.frozen_layers.is_empty() { - w.xdata(dxf_ext::frozen_xdata(&v.frozen_layers)); - } - if !v.layer_colors.is_empty() { - w.xdata(dxf_ext::layer_colors_xdata(&v.layer_colors)); - } - } - } - EntityKind::Wipeout(wo) => { - // A 1x1 image spanning the boundary's extents. Clip vertices are in pixel space, whose origin is - // the image's top-left corner with y pointing down: the reader maps each vertex back through - // insert + u * (x + 0.5) + v * (0.5 - y). - // Polygonal clip boundaries are closed, so the first vertex is repeated when needed. - let b = Bounds2::from_points(wo.boundary.iter().copied()); - let o = if b.is_empty() { Vec2::ZERO } else { b.min }; - let (sx, sy) = (if b.width() > 0.0 { b.width() } else { 1.0 }, if b.height() > 0.0 { b.height() } else { 1.0 }); - w.s(0, "WIPEOUT"); - common(w, e, owner, paper, "AcDbWipeout"); - w.i(90, 0); - w.p(10, Vec3::new(o.x, o.y, 0.0)); - w.p(11, Vec3::new(sx, 0.0, 0.0)); - w.p(12, Vec3::new(0.0, sy, 0.0)); - w.p2(13, Vec2::new(1.0, 1.0)); - // Display flags 7 = show image, show unaligned, use clipping boundary; clipping on; default brightness/contrast/fade. - w.i(70, 7); - w.i(280, 1); - w.i(281, 50); - w.i(282, 50); - w.i(283, 0); - w.i(71, 2); - let mut pts = wo.boundary.clone(); - if let (Some(first), Some(last)) = (pts.first().copied(), pts.last().copied()) - && first != last - { - pts.push(first); - } - w.i(91, pts.len() as i64); - for q in pts { - w.p2(14, Vec2::new((q.x - o.x) / sx - 0.5, 0.5 - (q.y - o.y) / sy)); - } - } - EntityKind::Tolerance(t) => { - w.s(0, "TOLERANCE"); - common(w, e, owner, paper, "AcDbFcf"); - w.s(3, &t.style); - w.p(10, t.insert); - w.s(1, &t.text); - w.p(11, t.dir); - } - // Entity types CADCraft doesn't model aren't written; the save command reports them. - EntityKind::Unknown(_) => {} - } -} - -/// `$DWGCODEPAGE`: before 2007 the code page read from the file when CADCraft knows it (the text -/// is ASCII with `\U+XXXX` escapes, right in any code page, but the file keeps its declared one); -/// otherwise, and from 2007 (UTF-8 text), `ANSI_1252`. -fn code_page(d: &Drawing, version: Version) -> String { - let read = d.header.get("DWGCODEPAGE").and_then(HVal::as_str).map(str::trim).unwrap_or_default(); - let known = read.is_ascii() && read.len() <= 32 && cadcraft_dxf::decode_code_page(b"", read).is_some(); - if version < Version::R2007 && known { read.to_string() } else { "ANSI_1252".into() } -} - -/// A multileader for a file older than 2007 (no MULTILEADER): a LEADER per leader line, ending -/// at the landing, plus its MTEXT or its block's INSERT, with the multileader's properties. -fn mleader_fallback(w: &mut W, d: &Drawing, e: &Entity, m: &MLeader, owner: &str, paper: bool, cx: &Ctx) { - let part = |w: &mut W, kind: EntityKind| Entity { handle: Handle::parse_hex(&w.h()).unwrap_or(Handle(0)), common: e.common.clone(), kind }; - let style = d.header.str("DIMSTYLE", "Standard"); - for arm in m.leaders.iter().take(64) { - let mut vertices = arm.clone(); - vertices.push(m.landing); - let le = part(w, EntityKind::Leader(Leader { vertices, arrow: true, spline: m.spline, style: style.clone() })); - entity(w, d, &le, owner, paper, cx); - } - if let Some(t) = &m.text { - let te = part(w, EntityKind::MText(t.clone())); - entity(w, d, &te, owner, paper, cx); - } - if let Some(ins) = &m.block { - let ie = part(w, EntityKind::Insert(ins.clone())); - entity(w, d, &ie, owner, paper, cx); - } -} - -/// A hatch's gradient fill (DXF Reference, HATCH group codes 450–470): two-colour gradient -/// with its rotation (radians), shift (0 = centered, 1 = shifted) and per-colour 463 records -/// carrying ACI (63) and, for true colours, the RGB value (421). Those groups belong to R2004+ -/// files, so the same gradient also travels as `CADCRAFT` xdata, which survives this R2000 -/// file's conversion to DWG (where the native fields don't exist). -fn gradient(w: &mut W, g: &Gradient) { - w.i(450, 1); - w.i(451, 0); - w.f(460, g.angle); - w.f(461, if g.centered { 0.0 } else { 1.0 }); - w.i(452, 0); - w.f(462, 0.0); - w.i(453, 2); - for (k, c) in [(0.0, g.color1), (1.0, g.color2)] { - w.f(463, k); - match c { - Color::True(rgb) => { - w.i(63, i64::from(cadcraft_color::nearest_aci(rgb))); - w.i(421, i64::from(rgb.to_u32())); - } - c => w.i(63, i64::from(c.to_aci())), - } - } - w.s(470, &g.name); - w.s(1001, dxf_ext::APP); - w.s(1000, "GRADIENT"); - // A 1000 group holds at most 255 bytes; cut at a character boundary. - let cut = g.name.char_indices().map(|(i, c)| i + c.len_utf8()).take_while(|end| *end <= 255).last().unwrap_or(0); - w.s(1000, g.name.get(..cut).unwrap_or_default()); - w.f(1040, g.angle); - w.i(1070, i64::from(g.centered)); - w.s(1000, g.color1.name()); - w.s(1000, g.color2.name()); -} - -/// Group 39 (thickness) of the entities whose DXF Reference record has one; only when set. -fn thickness(w: &mut W, e: &Entity) { - if e.common.thickness != 0.0 { - w.f(39, e.common.thickness); - } -} - -/// Hatch origin and background colour as `CADCRAFT` xdata: `1000 HATCH`, the origin as a -/// world position (`1011`, moved with the hatch by other programs) and the background colour -/// name (`1000`, empty for none). The DXF Reference has no HATCH groups for either: other -/// readers get the origin through the pattern lines' base points (43/44), the background isn't -/// carried. Appended to the gradient's `CADCRAFT` xdata when there is one. -fn hatch_xdata(w: &mut W, h: &Hatch) { - if h.origin == Vec2::ZERO && h.background.is_none() { - return; - } - if h.gradient.is_none() { - w.s(1001, dxf_ext::APP); - } - w.s(1000, "HATCH"); - w.p(1011, h.origin.to3(0.0)); - w.s(1000, h.background.map(Color::name).unwrap_or_default()); -} - -/// What happens to the anonymous `*D` blocks read from a file (#495): a dimension that still -/// names its block regenerates its geometry under that name, keeping the block's entity -/// handles, and `*D` blocks nothing else references are not written again. -struct DimBlockPlan { - /// Dimension (model or paper space) → the read block whose name and handles it reuses. - reuse: HashMap>, - /// Upper-case names of `*D` blocks not written as ordinary blocks. - skip: std::collections::HashSet, -} - -fn is_dim_block(name: &str) -> bool { - name.get(..2).is_some_and(|p| p.eq_ignore_ascii_case("*D")) -} - -fn dim_block_plan(d: &Drawing) -> DimBlockPlan { - let spaces: Vec<&EntityStore> = std::iter::once(&d.model).chain(d.layouts.iter().map(|l| &l.entities)).collect(); - // Block names other objects use: inserts and multileader contents anywhere, and dimensions - // inside block definitions (their blocks are written as read). - let mut used = std::collections::HashSet::new(); - let note = |e: &Entity, dims: bool, used: &mut std::collections::HashSet| match &e.kind { - EntityKind::Insert(i) => { - used.insert(i.block.to_ascii_uppercase()); - } - EntityKind::MLeader(m) => used.extend(m.block.as_ref().map(|b| b.block.to_ascii_uppercase())), - EntityKind::Dimension(dm) if dims => used.extend(dm.block.as_ref().map(|n| n.to_ascii_uppercase())), - _ => {} - }; - for e in spaces.iter().flat_map(|s| s.iter()) { - note(e, false, &mut used); - } - for e in d.blocks.values().flat_map(|b| b.entities.iter()) { - note(e, true, &mut used); - } - let skip = d.blocks.keys().filter(|n| is_dim_block(n)).map(|n| n.to_ascii_uppercase()).filter(|n| !used.contains(n)).collect(); - let mut plan = DimBlockPlan { reuse: HashMap::new(), skip }; - let mut claimed = std::collections::HashSet::new(); - for e in spaces.iter().flat_map(|s| s.iter()) { - let EntityKind::Dimension(dm) = &e.kind else { continue }; - let Some(b) = dm.block.as_deref().filter(|n| is_dim_block(n)).and_then(|n| d.block(n)) else { continue }; - let key = b.name.to_ascii_uppercase(); - if plan.skip.contains(&key) && claimed.insert(key) { - plan.reuse.insert(e.handle, b.clone()); - } - } - plan -} - -/// Give regenerated dimension geometry the handles of the block it replaces, in order; the rest -/// keep handle 0 and get fresh ones. -fn reuse_handles(ents: &mut [Entity], old: &Block, taken: &mut std::collections::HashSet) { - let mut handles = old.entities.iter().map(|e| e.handle).filter(|h| h.0 != 0); - for e in ents.iter_mut() { - match handles.by_ref().find(|h| taken.insert(*h)) { - Some(h) => e.handle = h, - None => break, - } - } -} - -/// Anonymous dimension blocks (`*D1`…) with the rendered geometry, as consumers expect. -fn dim_block_entities(d: &Drawing, dm: &Dimension, layer: &str) -> 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 mut out = Vec::new(); - // Each part in its style colour (DIMCLRD, DIMCLRE, DIMCLRT), ByBlock when the style says so, - // as AutoCAD writes them: readers that draw the block drew every part ByBlock, i.e. in the - // 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)); - 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, - }; - 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() { - out.push(Entity { - handle: Handle(0), - common: dim_c.clone(), - kind: EntityKind::Solid(Solid { corners: [a.to3(0.0), b.to3(0.0), cc.to3(0.0), cc.to3(0.0)] }), - }); - } - } - // 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), - common: text_c, - kind: EntityKind::MText(MText { - insert: g.text_pos.to3(0.0), - 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, - }), - }); - } - out -} - -/// Rows and columns written for a table (bounded like the renderer). -fn table_size(t: &Table) -> (usize, usize) { - (t.row_heights.len().min(10_000), t.col_widths.len().min(10_000)) -} - -/// The anonymous `*T` block of a table: cell fills, borders and texts as its cell styles draw -/// them, for readers that only draw the block. -/// -/// The block is relative to its own origin: the ACAD_TABLE reference adds the insertion point. -pub(crate) fn table_block_entities(d: &Drawing, t: &Table, layer: &str) -> Vec { - let at_origin = Table { insert: Vec3::ZERO, ..t.clone() }; - let g = cadcraft_render::table_geometry(d, &at_origin); - let c = Common { layer: layer.into(), color: Color::ByBlock, ..Common::default() }; - let mut out = Vec::new(); - for f in &g.fills { - let [a, b, cc, dd] = f.corners.map(|p| p.to3(0.0)); - // SOLID corners go 1, 2, 4, 3. - out.push(Entity { - handle: Handle(0), - common: Common { color: f.color, ..c.clone() }, - kind: EntityKind::Solid(Solid { corners: [a, b, dd, cc] }), - }); - } - for l in &g.lines { - out.push(Entity { - handle: Handle(0), - common: Common { color: l.color, lineweight: l.lineweight, ..c.clone() }, - kind: EntityKind::Line(Line { a: l.a.to3(0.0), b: l.b.to3(0.0) }), - }); - } - let mut texts = Vec::new(); - for cell in &g.texts { - if !cell.text.is_empty() { - let style = if d.text_style(&cell.style).is_some() { cell.style.clone() } else { "Standard".into() }; - texts.push(Entity { - handle: Handle(0), - common: Common { color: cell.color, ..c.clone() }, - kind: EntityKind::MText(MText { - insert: cell.at.to3(0.0), - height: cell.height, - width: 0.0, - attach: cell.attach, - rotation: 0.0, - style, - contents: cell.text.clone(), - line_spacing: 1.0, - line_spacing_exact: false, - columns: None, - }), - }); - } - } - out.extend(texts); - out -} - -/// Our own geometry for an arrowhead block: unit size, tip at the origin, pointing along +X. -fn arrow_block_entities(kind: cadcraft_render::Arrowhead) -> Vec { - let g = cadcraft_render::arrowhead(kind, Vec2::ZERO, Vec2::X, 1.0); - let c = Common { layer: "0".into(), color: Color::ByBlock, ..Common::default() }; - let mut out = Vec::new(); - for l in &g.lines { - 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) }) }); - } - } - } - for t in g.tris.chunks(3) { - if let [a, b, cc] = t { - out.push(Entity { - handle: Handle(0), - common: c.clone(), - kind: EntityKind::Solid(Solid { corners: [a.to3(0.0), b.to3(0.0), cc.to3(0.0), cc.to3(0.0)] }), - }); - } - } - out -} - -/// Whether the entities of the block `name` are the arrowhead block CADCraft writes for that -/// standard arrow name ([`arrow_block_entities`]): the same lines and solids with the same -/// properties (layer 0, ByBlock). Any other content (AutoCAD's own `_Dot`, a user's -/// redefinition, an older CADCraft's geometry) is the file's. -pub(crate) fn is_generated_arrow(name: &str, entities: &[&Entity]) -> bool { - let Some((_, kind)) = dxf_ext::arrow_block_name(name) else { return false }; - let ours = arrow_block_entities(kind); - ours.len() == entities.len() && ours.iter().zip(entities).all(|(a, b)| a.kind == b.kind && a.common == b.common) -} - -/// Whether a DIMASSOC point reference to `k` names a point of it without naming a segment: -/// true for objects made of one curve. Polylines are referenced by segment ([`polyline_vertex_ref`]); -/// 3D polylines and multilines can't be yet, and references to them are left out, as AutoCAD -/// otherwise resolved them to the first segment and changed the dimension's value (#1049). -fn whole_object_ref(k: &EntityKind) -> bool { - !matches!(k, EntityKind::LwPolyline(_) | EntityKind::Polyline3d(_) | EntityKind::MLine(_)) -} - -/// A polyline vertex as AutoCAD references it (#1049): an end of one of its two segments, -/// which are numbered from 1 (segment k runs from vertex k−1 to vertex k; a closed polyline's -/// closing segment is the last). Returns (osnap, parameter, segment): the end of the incoming -/// segment (osnap 1, parameter 1.0), else the start of the outgoing one (osnap 13, 0.0). Only -/// straight segments are used, whose parameters are 0 and 1; a vertex between two arcs gets no -/// reference. -pub(crate) fn polyline_vertex_ref(p: &LwPolyline, index: usize) -> Option<(i64, f64, i64)> { - let n = p.vertices.len(); - if index >= n || n < 2 { - return None; - } - let segments = if p.closed { n } else { n - 1 }; - // Segment k (1-based) starts at vertex k−1; its bulge is that vertex's. - let straight = |k: usize| k >= 1 && k <= segments && p.vertices.get(k - 1).is_some_and(|v| v.bulge == 0.0); - let incoming = if index == 0 { if p.closed { Some(n) } else { None } } else { Some(index) }; - let outgoing = (index < segments).then_some(index + 1); - // Vertex 0 as the start of segment 1, the others as the end of the segment before, as - // AutoCAD writes them when it can. - let preferred = if index == 0 { - [outgoing.map(|k| (13, 0.0, k)), incoming.map(|k| (1, 1.0, k))] - } else { - [incoming.map(|k| (1, 1.0, k)), outgoing.map(|k| (13, 0.0, k))] - }; - preferred.into_iter().flatten().find(|(_, _, k)| straight(*k)).map(|(o, t, k)| (o, t, i64::try_from(k).unwrap_or(0))) -} - -/// Standard DIMASSOC point references of an associative dimension: the extension-line -/// origins of linear/aligned dimensions, both ends of both lines of an angular dimension, the -/// feature point of ordinate dimensions and the circle of a radial/diametric dimension, linked -/// to objects that exist ([`whole_object_ref`]). CADCraft's exact links travel in xdata as well. -fn std_assoc_refs(d: &Drawing, dm: &Dimension) -> (i64, Vec) { - if matches!(dm.kind, DimKind::Radius | DimKind::Diameter) { - return radial_ref(d, &dm.assoc); - } - let slots: &[&str] = match dm.kind { - DimKind::Linear { .. } | DimKind::Aligned => &["p13", "p14"], - DimKind::Angular => &["p13", "p14", "p15", "p16"], - DimKind::Angular3P => &["p13", "p14", "p15"], - // Saved as an aligned dimension (`dim_xdata` marks it): its extension origins are the arc ends. - DimKind::ArcLength => &["p13", "p14"], - DimKind::Ordinate { .. } => &["p13"], - DimKind::Radius | DimKind::Diameter => &[], - }; - let mut flags = 0; - let mut refs = Vec::new(); - for (slot, name) in slots.iter().enumerate() { - let Some(a) = dm.assoc.iter().find(|a| a.point == *name) else { continue }; - let Some(ent) = d.entity(a.handle) else { continue }; - let point = match *name { - "p13" => dm.p13, - "p14" => dm.p14, - "p15" => dm.p15, - "p16" => dm.p16, - _ => continue, - }; - if let EntityKind::LwPolyline(p) = &ent.kind { - let index = match &a.snap { - AssocSnap::Vertex { index } => Some(*index), - AssocSnap::Start => Some(0), - AssocSnap::End => p.vertices.len().checked_sub(1), - _ => None, - }; - let Some((osnap, param, segment)) = index.and_then(|i| polyline_vertex_ref(p, i)) else { continue }; - flags |= 1 << slot; - refs.push(OsnapRef { osnap, main: a.handle, segment, param, point, other: None }); - continue; - } - if !whole_object_ref(&ent.kind) { - continue; - } - let (osnap, param, other) = match &a.snap { - // AutoCAD marks a curve's start with osnap 13 and its end with osnap 1 (group 72). - AssocSnap::Start => (if matches!(ent.kind, EntityKind::Point(_)) { 1 } else { 13 }, 0.0, None), - AssocSnap::End => (1, 1.0, None), - AssocSnap::Vertex { .. } => (1, 0.0, None), - AssocSnap::Mid => (2, 0.0, None), - AssocSnap::Center => (if matches!(ent.kind, EntityKind::Point(_)) { 4 } else { 3 }, 0.0, None), - AssocSnap::OnCircle { angle } => (10, *angle, None), - AssocSnap::Intersection { other } => { - if !d.entity(*other).is_some_and(|o| whole_object_ref(&o.kind)) { - continue; - } - (6, 0.0, Some(*other)) - } - }; - flags |= 1 << slot; - refs.push(OsnapRef { osnap, main: a.handle, segment: 0, param, point, other }); - } - (flags, refs) -} - -/// The single standard reference of a radial or diametric dimension: the circle or arc it -/// measures, by its centre, with associativity flag 2. AutoCAD writes radial and diametric -/// dimensions that way: the one reference names the geometry, whose radius or diameter the -/// dimension then follows. Returns no flags and no references when the association does not name -/// a circle or arc, so such a dimension is saved unassociated rather than pointing at nothing. -fn radial_ref(d: &Drawing, assoc: &[DimAssoc]) -> (i64, Vec) { - for a in assoc { - let Some(ent) = d.entity(a.handle) else { continue }; - let centre = match &ent.kind { - EntityKind::Circle(c) => c.center.xy(), - EntityKind::Arc(arc) => arc.center.xy(), - _ => continue, - }; - if !centre.is_finite() { - continue; - } - return (2, vec![OsnapRef { osnap: 3, main: a.handle, segment: 0, param: 0.0, point: centre.to3(0.0), other: None }]); - } - (0, Vec::new()) -} - -/// Layouts with viewports but without the paper-space viewport (id 1, the sheet itself), keyed -/// by their first viewport. AutoCAD and DWG take a layout's first viewport as its paper-space -/// viewport (DWG does not store viewport ids; readers number them in order), so a layout made in -/// CADCraft, whose paper-space viewport is implicit, gets one written ahead of its viewports. -fn paper_views(d: &Drawing) -> HashMap { - let mut out = HashMap::new(); - 'layouts: for (i, l) in d.layouts.iter().enumerate() { - let mut first = None; - for e in l.entities.iter() { - if let EntityKind::Viewport(v) = &e.kind { - if v.id == 1 { - continue 'layouts; - } - first.get_or_insert(e.handle); - } - } - if let Some(h) = first { - out.insert(h, i); - } - } - out -} - -/// A layout's entities with its paper-space viewport (id 1) first. DWG stores no viewport ids and -/// readers number a layout's viewports in order, so the sheet must lead even when the draw order -/// (SORTENTS) put it behind a model viewport; otherwise that viewport becomes the sheet. -fn sheet_first(l: &Layout) -> Vec<&Entity> { - let sheet = |e: &&Entity| matches!(&e.kind, EntityKind::Viewport(v) if v.id == 1); - let all = || l.entities.iter().map(|e| e.as_ref()); - all().filter(sheet).chain(all().filter(|e| !sheet(e))).collect() -} - -/// The paper-space viewport (id 1) of the layout whose first viewport is `e`, if it needs one -/// ([`paper_views`]): the whole sheet, viewed 1:1. Marked with CADCraft xdata so that our reader -/// leaves it implicit again. -fn paper_view(w: &mut W, d: &Drawing, e: &Entity, owner: &str, cx: &Ctx) { - let Some(l) = cx.paper_views.get(&e.handle).and_then(|i| d.layouts.get(*i)) else { return }; - let size = cadcraft_render::paper::sheet(d, &l.name).map(|s| s.size).unwrap_or(Vec2::new(12.0, 9.0)); - let center = size * 0.5; - let h = w.h(); - w.s(0, "VIEWPORT"); - w.s(5, h); - w.s(330, owner); - w.s(100, "AcDbEntity"); - w.i(67, 1); - w.s(8, "0"); - w.s(100, "AcDbViewport"); - w.p(10, center.to3(0.0)); - w.f(40, size.x); - w.f(41, size.y); - w.i(68, 1); - w.i(69, 1); - w.p2(12, center); - w.f(45, size.y); - w.i(90, 32768); - w.s(1001, dxf_ext::APP); - w.s(1000, dxf_ext::PAPER_VIEW); -} - -/// The VPORT table: the `*Active` model space tiles (one viewport on the extents when there is no -/// tiled configuration) and the saved configurations, each tile a record, the current one first. -fn vport_table(w: &mut W, d: &Drawing, ext: Bounds2) { - let single = VportTile { - min: Vec2::ZERO, - max: Vec2::new(1.0, 1.0), - center: if ext.is_empty() { Vec2::new(6.0, 4.5) } else { ext.center() }, - height: if ext.is_empty() { 9.0 } else { (ext.height() * 1.1).max(1e-6) }, - aspect: 1.6, - }; - let tiled = d.vports.iter().find(|c| c.is_active() && c.tiles.len() > 1); - let mut records: Vec<(&str, &VportTile)> = match tiled { - Some(c) => c.tiles.iter().map(|t| (VportConfig::ACTIVE, t)).collect(), - None => vec![(VportConfig::ACTIVE, &single)], - }; - records.extend(d.vports.iter().filter(|c| !c.is_active() && !c.name.is_empty()).flat_map(|c| c.tiles.iter().map(|t| (c.name.as_str(), t)))); - let th = table_head(w, "VPORT", records.len()); - let finite = |p: Vec2, or: Vec2| if p.is_finite() { p } else { or }; - for (name, t) in records { - record_head(w, "VPORT", &th, "AcDbViewportTableRecord"); - w.s(2, name); - w.i(70, 0); - w.p2(10, finite(t.min, Vec2::ZERO)); - w.p2(11, finite(t.max, Vec2::new(1.0, 1.0))); - w.p2(12, finite(t.center, Vec2::ZERO)); - // Snap grid origin and rotation (SNAPBASE, SNAPANG in degrees): R13+ files keep them here. - w.p2(13, d.header.point("SNAPBASE").map(|p| p.xy()).filter(|p| p.is_finite()).unwrap_or(Vec2::ZERO)); - w.p2(14, d.header.snap_unit()); - w.p2(15, d.header.grid_unit()); - w.p(16, Vec3::Z); - w.p(17, Vec3::ZERO); - w.f(40, if t.height.is_finite() && t.height > 0.0 { t.height } else { 1.0 }); - w.f(41, if t.aspect.is_finite() && t.aspect > 0.0 { t.aspect } else { 1.0 }); - w.f(42, 50.0); - w.f(50, d.header.f64("SNAPANG", 0.0)); - } - w.s(0, "ENDTAB"); -} - -fn table_head(w: &mut W, name: &str, count: usize) -> String { - let h = w.h(); - w.s(0, "TABLE"); - w.s(2, name); - w.s(5, h.clone()); - w.s(330, "0"); - w.s(100, "AcDbSymbolTable"); - w.i(70, count as i64); - h -} - -fn record_head(w: &mut W, kind: &str, owner: &str, subclass: &str) -> String { - let h = w.h(); - w.s(0, kind); - w.s(if kind == "DIMSTYLE" { 105 } else { 5 }, h.clone()); - w.s(330, owner); - w.s(100, "AcDbSymbolTableRecord"); - w.s(100, subclass); - h -} - /// Write a drawing as ASCII DXF. /// The drawing as R2000 ASCII DXF. pub fn write(d: &Drawing) -> String { @@ -1813,1106 +130,6 @@ fn bridge_markers(tags: &mut [Tag]) { } } -/// The drawing's DXF tags, declaring `version` in `$ACADVER`, with what `target` (the version the -/// file ends up in: `version`, or the DWG version the bridge writes) has. -fn tags_for(d: &Drawing, version: Version, target: Version) -> Vec { - // An in-place block edit in progress is not written: the drawing as before it. - let committed = d.committed(); - let d: &Drawing = &committed; - // Structural handles start above every entity handle. - let mut w = W { - t: Vec::new(), - next: d.handseed.max(0x100).saturating_add(0x1000), - pstyles: HashMap::new(), - layers: HashMap::new(), - reactors: hatch_reactors(d), - }; - // Pre-allocate handles for block records. - let paper_layouts = d.paper_space_layouts(); - let ms_br = w.h(); - // Xrefs are saved as references: without their objects and the symbols that came with them. - let saved_view = crate::dxf_xref::saved_view(d); - let d = saved_view.as_ref().unwrap_or(d); - let ps_brs: Vec<(String, String, &Layout)> = paper_layouts - .iter() - .enumerate() - .map(|(i, l)| (if i == 0 { "*Paper_Space".to_string() } else { format!("*Paper_Space{}", i - 1) }, String::new(), *l)) - .collect(); - let ps_brs: Vec<(String, String, &Layout)> = ps_brs.into_iter().map(|(n, _, l)| (n, w.h(), l)).collect(); - let dim_plan = dim_block_plan(d); - let mut user_blocks: Vec<(String, String, &Block)> = d - .blocks - .values() - .filter(|b| !dim_plan.skip.contains(&b.name.to_ascii_uppercase())) - .map(|b| (b.name.clone(), String::new(), b.as_ref())) - .collect(); - user_blocks.iter_mut().for_each(|b| b.1 = format!("{:X}", 0)); - let user_blocks: Vec<(String, String, &Block)> = user_blocks.into_iter().map(|(n, _, b)| (n, w.h(), b)).collect(); - let mut cx = Ctx { paper_views: paper_views(d), target, ..Ctx::default() }; - cx.block_records = user_blocks.iter().map(|(n, h, _)| (n.to_ascii_uppercase(), h.clone())).collect(); - // Dimension blocks. - let mut dim_defs: Vec<(String, String, Vec)> = Vec::new(); // filled below - let mut n = 1; - let mut reused_handles = std::collections::HashSet::new(); - let all_spaces: Vec<&EntityStore> = std::iter::once(&d.model).chain(d.layouts.iter().map(|l| &l.entities)).collect(); - for st in &all_spaces { - for e in st.iter() { - if let EntityKind::Dimension(dm) = &e.kind { - let reused = dim_plan.reuse.get(&e.handle); - let ents: Vec; - let name = match reused { - // A dimension still linked to the block it was read with hasn't changed since - // (edits drop the link, and the screen draws that block): the block is written - // as read, so other programs show it as they drew it. - Some(b) if !b.entities.is_empty() => { - ents = b.entities.iter().map(|x| x.as_ref().clone()).collect(); - reused_handles.extend(ents.iter().map(|x| x.handle).filter(|h| h.0 != 0)); - b.name.clone() - } - Some(b) => { - let mut fresh = dim_block_entities(d, dm, &e.common.layer); - reuse_handles(&mut fresh, b, &mut reused_handles); - ents = fresh; - b.name.clone() - } - None => { - ents = dim_block_entities(d, dm, &e.common.layer); - let mut name = format!("*D{n}"); - while d.block(&name).is_some() { - n += 1; - name = format!("*D{n}"); - } - n += 1; - name - } - }; - let brh = w.h(); - cx.dim_blocks.insert(e.handle, name.clone()); - dim_defs.push((name, brh, ents)); - } - } - } - // Every entity, including block contents (for arrows, tables and overrides). - let every: Vec<&Entity> = all_spaces - .iter() - .flat_map(|s| s.iter().map(|e| e.as_ref())) - .chain(d.blocks.values().flat_map(|b| b.entities.iter().map(|e| e.as_ref()))) - .collect(); - // Arrowhead blocks: user blocks by name, otherwise generated from our own geometry. - let mut arrow_names: Vec = Vec::new(); - for s in &d.dim_styles { - arrow_names.extend([s.arrow_block.clone(), s.arrow_block1.clone(), s.arrow_block2.clone(), s.leader_arrow_block.clone()]); - } - for e in &every { - if let EntityKind::Dimension(dm) = &e.kind { - for (k, v) in &dm.overrides { - let Some(f) = DimStyle::field_name(k).filter(|f| f.starts_with("arrowBlock") || *f == "leaderArrowBlock") else { continue }; - if let Some(s) = dxf_ext::canonical(f, v).as_ref().and_then(|v| v.as_str()) { - arrow_names.push(s.to_string()); - } - } - } - } - let mut arrow_defs: Vec<(String, String, Vec)> = Vec::new(); - for name in arrow_names { - let key = name.trim().to_ascii_uppercase(); - if key.is_empty() || cx.arrows.contains_key(&key) { - continue; - } - if let Some((_, h, _)) = user_blocks.iter().find(|(n, _, _)| n.eq_ignore_ascii_case(name.trim())) { - cx.arrows.insert(key, h.clone()); - continue; - } - let Some((bname, kind)) = dxf_ext::arrow_block_name(&name) else { continue }; - let existing = arrow_defs.iter().find(|(n, _, _)| n.eq_ignore_ascii_case(&bname)).map(|(_, h, _)| h.clone()); - let existing = existing.or_else(|| user_blocks.iter().find(|(n, _, _)| n.eq_ignore_ascii_case(&bname)).map(|(_, h, _)| h.clone())); - let h = match existing { - Some(h) => h, - None => { - let h = w.h(); - arrow_defs.push((bname, h.clone(), arrow_block_entities(kind))); - h - } - }; - cx.arrows.insert(key, h); - } - // Table blocks (`*T`). - let mut table_defs: Vec<(String, String, Vec)> = Vec::new(); - let mut tn = 1; - for e in &every { - if let EntityKind::Table(t) = &e.kind { - if cx.tables.contains_key(&e.handle) { - continue; - } - let mut name = format!("*T{tn}"); - while d.block(&name).is_some() { - tn += 1; - name = format!("*T{tn}"); - } - tn += 1; - let brh = w.h(); - cx.tables.insert(e.handle, (name.clone(), brh.clone())); - table_defs.push((name, brh, table_block_entities(d, t, &e.common.layer))); - } - } - // Image definitions (IMAGEDEF objects) of raster images anywhere in the drawing. - cx.images = crate::dxf_image::plan(every.iter().copied(), || w.h()); - cx.mlines = crate::dxf_mline::plan(d, every.iter().copied(), || w.h()); - // DIMASSOC objects of associative dimensions in model and paper space. - for st in &all_spaces { - for e in st.iter() { - if let EntityKind::Dimension(dm) = &e.kind { - let (flags, refs) = std_assoc_refs(d, dm); - if !refs.is_empty() { - cx.assoc.insert(e.handle, AssocObj { xdict: w.h(), handle: w.h(), flags, refs }); - } - } - } - } - let default_table_style = [TableStyle::default()]; - let table_styles: &[TableStyle] = if d.table_styles.is_empty() { &default_table_style } else { &d.table_styles }; - cx.table_styles = table_styles.iter().map(|s| (s.name.clone(), w.h())).collect(); - let default_mleader_style = [MLeaderStyle::default()]; - let mleader_styles: &[MLeaderStyle] = if d.mleader_styles.is_empty() { &default_mleader_style } else { &d.mleader_styles }; - cx.mleader_styles = mleader_styles.iter().map(|s| (s.name.clone(), w.h())).collect(); - let root_dict = w.h(); - let group_dict = w.h(); - let layout_dict = w.h(); - let table_style_dict = w.h(); - // CADCraft data in XRECORDs of the named object dictionary: (key, handle, text chunks). - let mut xrecords: Vec<(&str, String, Vec)> = Vec::new(); - // The drawing's own next handle: table records and objects are numbered above it, so - // reading it back (instead of $HANDSEED) keeps their handles across saves (#495). - xrecords.push((dxf_ext::HANDSEED_KEY, w.h(), vec![format!("{:X}", d.handseed)])); - if let Some(name) = d.header.get("CMLEADERSTYLE").and_then(HVal::as_str) { - xrecords.push((dxf_ext::MLEADER_STYLE_KEY, w.h(), vec![name.to_string()])); - } - if let Some(chunks) = dxf_ext::constraint_chunks(&d.constraints, &d.parametric) { - xrecords.push((dxf_ext::CONSTRAINTS_KEY, w.h(), chunks)); - } - if let Some(chunks) = dxf_ext::layer_state_chunks(&d.layer_states) { - xrecords.push((dxf_ext::LAYER_STATES_KEY, w.h(), chunks)); - } - if let Some(chunks) = dxf_ext::plot_style_chunks(&d.plot_style_tables) { - xrecords.push((dxf_ext::PLOT_STYLES_KEY, w.h(), chunks)); - } - if let Some(chunks) = dxf_ext::anno_chunks(d) { - xrecords.push((dxf_ext::ANNOTATION_KEY, w.h(), chunks)); - } - // The annotation scale list: the ACAD_SCALELIST dictionary of SCALE objects. - let scale_dict = w.h(); - let scales: Vec<(&cadcraft_doc::AnnoScale, String)> = d.scales.iter().filter(|s| s.is_valid()).map(|s| (s, w.h())).collect(); - // Plot style names used by layers and objects (Normal always), each a placeholder object. - let pstyle_dict = w.h(); - let mut pstyle_names: Vec = vec![cadcraft_doc::NORMAL_STYLE.to_string()]; - let named = d.layers.iter().map(|l| l.plot_style.as_str()).chain(every.iter().map(|e| e.common.plot_style.as_str())); - for n in named { - let n = n.trim(); - let logical = n.is_empty() || n.eq_ignore_ascii_case("bylayer") || n.eq_ignore_ascii_case("byblock"); - if !logical && pstyle_names.len() < dxf_ext::MAX_PLOT_STYLE_NAMES && !pstyle_names.iter().any(|x| x.eq_ignore_ascii_case(n)) { - pstyle_names.push(n.to_string()); - } - } - let pstyles: Vec<(String, String)> = pstyle_names.into_iter().map(|n| (n, w.h())).collect(); - w.pstyles = pstyles.iter().map(|(n, h)| (n.to_ascii_uppercase(), h.clone())).collect(); - let model_layout = w.h(); - let layout_handles: Vec = ps_brs.iter().map(|_| w.h()).collect(); - - // ---------------- HEADER ---------------- - w.s(0, "SECTION"); - w.s(2, "HEADER"); - w.s(9, "$ACADVER"); - w.s(1, version.code()); - w.s(9, "$DWGCODEPAGE"); - w.s(3, code_page(d, version)); - let ext = d.extents(&Space::Model); - if !ext.is_empty() { - w.s(9, "$EXTMIN"); - w.p(10, ext.min.to3(0.0)); - w.s(9, "$EXTMAX"); - w.p(10, ext.max.to3(0.0)); - } - header_vars(&mut w, d); - let seed_pos = w.t.len(); - w.s(9, "$HANDSEED"); - w.s(5, "0"); - w.s(0, "ENDSEC"); - - // ---------------- CLASSES ---------------- - w.s(0, "SECTION"); - w.s(2, "CLASSES"); - let mut classes = vec![ - ("TABLESTYLE", "AcDbTableStyle", 4095, false), - ("MLEADERSTYLE", "AcDbMLeaderStyle", 4095, false), - ("ACDBDICTIONARYWDFLT", "AcDbDictionaryWithDefault", 0, false), - ("ACDBPLACEHOLDER", "AcDbPlaceHolder", 0, false), - ]; - if !cx.tables.is_empty() { - classes.push(("ACAD_TABLE", "AcDbTable", 1025, true)); - } - if every.iter().any(|e| matches!(e.kind, EntityKind::Helix(_))) { - classes.push(("HELIX", "AcDbHelix", 4095, true)); - } - if !cx.assoc.is_empty() { - classes.push(("DIMASSOC", "AcDbDimAssoc", 0, false)); - } - if cx.native_mleader() && every.iter().any(|e| matches!(e.kind, EntityKind::MLeader(_))) { - classes.push(("MULTILEADER", "AcDbMLeader", 1025, true)); - } - if every.iter().any(|e| matches!(e.kind, EntityKind::Wipeout(_))) { - classes.push(("WIPEOUT", "AcDbWipeout", 127, true)); - } - if !cx.images.is_empty() { - classes.push(("IMAGE", "AcDbRasterImage", 127, true)); - classes.push(("IMAGEDEF", "AcDbRasterImageDef", 0, false)); - classes.push(("IMAGEDEF_REACTOR", "AcDbRasterImageDefReactor", 1, false)); - classes.push(("RASTERVARIABLES", "AcDbRasterVariables", 0, false)); - } - classes.push(("SCALE", "AcDbScale", 0, false)); - for (dxf_name, cpp, proxy, is_entity) in classes { - w.s(0, "CLASS"); - w.s(1, dxf_name); - w.s(2, cpp); - w.s(3, "ObjectDBX Classes"); - w.i(90, proxy); - w.i(280, 0); - w.i(281, i64::from(is_entity)); - } - w.s(0, "ENDSEC"); - - // ---------------- TABLES ---------------- - w.s(0, "SECTION"); - w.s(2, "TABLES"); - vport_table(&mut w, d, ext); - // LTYPE. Embedded text and shapes point at STYLE records (340), which get their handles - // in the STYLE table below: (tag position, style name, is a shape) are filled in there. - let mut ltype_handles: HashMap = HashMap::new(); - let mut ltype_style_refs: Vec<(usize, Option, bool)> = Vec::new(); - let th = table_head(&mut w, "LTYPE", d.linetypes.len()); - for lt in &d.linetypes { - let h = record_head(&mut w, "LTYPE", &th, "AcDbLinetypeTableRecord"); - ltype_handles.entry(lt.name.to_ascii_uppercase()).or_insert(h.clone()); - cx.ltypes.entry(lt.name.to_ascii_uppercase()).or_insert(h); - w.s(2, <.name); - w.i(70, if crate::dxf_xref::dependent(<.name) { 48 } else { 0 }); - w.s(3, <.description); - w.i(72, 65); - w.i(73, lt.pattern.len() as i64); - w.f(40, lt.pattern_length()); - for el in <.pattern { - w.f(49, el.length); - // 74: 2 = text, 4 = shape, + 1 when the rotation is absolute (DXF Reference, LTYPE). - let (kind, shape) = match (&el.text, el.shape) { - (Some(_), _) => (2, 0), - (None, Some(n)) => (4, n), - (None, None) => (0, 0), - }; - if kind == 0 { - w.i(74, 0); - continue; - } - w.i(74, kind | i64::from(el.absolute)); - w.i(75, i64::from(shape)); - ltype_style_refs.push((w.t.len(), el.style.clone(), kind == 4)); - w.s(340, "0"); - w.f(46, el.scale); - w.f(50, el.rotation); - w.f(44, el.offset.x); - w.f(45, el.offset.y); - if let Some(t) = &el.text { - w.s(9, t.clone()); - } - } - } - w.s(0, "ENDTAB"); - // LAYER - let th = table_head(&mut w, "LAYER", d.layers.len()); - for l in &d.layers { - let lh = record_head(&mut w, "LAYER", &th, "AcDbLayerTableRecord"); - w.layers.insert(l.name.to_ascii_uppercase(), lh); - w.s(2, &l.name); - // 16 | 32: an xref's layer (`xref|name`), externally dependent and resolved. - let xref = if crate::dxf_xref::dependent(&l.name) { 48 } else { 0 }; - w.i(70, i64::from(l.frozen) | if l.vp_freeze_new { 2 } else { 0 } | if l.locked { 4 } else { 0 } | xref); - let aci = match l.color { - Color::True(rgb) => i64::from(cadcraft_color::nearest_aci(rgb)), - c => i64::from(c.to_aci()).clamp(1, 255), - }; - w.i(62, if l.on { aci } else { -aci }); - if let Color::True(rgb) = l.color { - w.i(420, i64::from(rgb.to_u32())); - } - w.s(6, &l.linetype); - if !l.plot { - w.i(290, 0); - } - w.i(370, i64::from(l.lineweight.to_dxf())); - let normal = w.pstyles.get(&cadcraft_doc::NORMAL_STYLE.to_ascii_uppercase()).cloned(); - if let Some(h) = w.pstyles.get(&l.plot_style.trim().to_ascii_uppercase()).cloned().or(normal) { - w.s(390, h); - } - if l.transparency > 0 { - w.s(1001, dxf_ext::LAYER_TRANSPARENCY_APP); - w.i(1071, dxf_ext::transparency_to_dxf(l.transparency)); - } - if !l.description.is_empty() { - // The first string is the layer standard's name, unused here. - w.s(1001, dxf_ext::LAYER_DESCRIPTION_APP); - w.s(1000, ""); - w.s(1000, dxf_ext::xdata_str(&l.description)); - } - } - w.s(0, "ENDTAB"); - // STYLE. Linetype shapes from a shape file that isn't a text style get an unnamed shape - // style record (70 bit 1) naming the file. - let mut shape_files: Vec = Vec::new(); - for (_, style, shape) in <ype_style_refs { - let Some(f) = style.as_deref().map(str::trim).filter(|f| *shape && !f.is_empty() && d.text_style(f).is_none()) else { continue }; - if !shape_files.iter().any(|x| x.eq_ignore_ascii_case(f)) { - shape_files.push(f.to_string()); - } - } - let th = table_head(&mut w, "STYLE", d.text_styles.len() + shape_files.len()); - for s in &d.text_styles { - let h = record_head(&mut w, "STYLE", &th, "AcDbTextStyleTableRecord"); - cx.styles.entry(s.name.to_ascii_uppercase()).or_insert(h); - w.s(2, &s.name); - // 70 bit 4: vertical; 71 bits 2/4: backwards / upside down. - w.i(70, if s.vertical { 4 } else { 0 }); - w.f(40, s.height); - w.f(41, s.width_factor); - w.f(50, s.oblique.to_degrees()); - w.i(71, if s.backwards { 2 } else { 0 } | if s.upside_down { 4 } else { 0 }); - w.f(42, 0.2); - // The built-in font goes out as `txt`, which every reader has; CADCraft's own name - // travels in `CADCRAFT` xdata (`1000 FONT`, name) so the style reopens with it. - let file = if s.font == cadcraft_fonts_name() { "txt" } else { s.font.as_str() }; - w.s(3, file); - w.s(4, &s.big_font); - if s.annotative { - annotative_xdata(&mut w); - } - if file != s.font { - w.s(1001, dxf_ext::APP); - w.s(1000, "FONT"); - w.s(1000, dxf_ext::xdata_str(&s.font)); - } - } - let mut shape_handles: HashMap = HashMap::new(); - for f in &shape_files { - let h = record_head(&mut w, "STYLE", &th, "AcDbTextStyleTableRecord"); - shape_handles.insert(f.to_ascii_uppercase(), h); - w.s(2, ""); - w.i(70, 1); - w.f(40, 0.0); - w.f(41, 1.0); - w.f(50, 0.0); - w.i(71, 0); - w.f(42, 0.2); - w.s(3, f.clone()); - w.s(4, ""); - } - w.s(0, "ENDTAB"); - // Linetype text and shapes without a known style use Standard (else the first style). - let fallback_style = cx.style("Standard").or_else(|| d.text_styles.first().and_then(|s| cx.style(&s.name))).unwrap_or_else(|| "0".into()); - for (pos, style, shape) in ltype_style_refs { - let name = style.as_deref().map(str::trim).unwrap_or_default(); - let h = shape.then(|| shape_handles.get(&name.to_ascii_uppercase()).cloned()).flatten().or_else(|| cx.style(name)); - if let Some(t) = w.t.get_mut(pos) { - *t = Tag::s(340, h.unwrap_or_else(|| fallback_style.clone())); - } - } - // VIEW: named views (DXF Reference, VIEW). A view's layer state is CADCraft xdata. - let th = table_head(&mut w, "VIEW", d.views.len()); - for v in &d.views { - record_head(&mut w, "VIEW", &th, "AcDbViewTableRecord"); - w.s(2, &v.name); - w.i(70, i64::from(v.layout.is_some())); - w.f(40, v.height); - w.p2(10, v.center); - w.f(41, v.width); - w.p(11, Vec3::Z); - w.p(12, Vec3::ZERO); - w.f(42, 50.0); - w.f(43, 0.0); - w.f(44, 0.0); - w.f(50, if v.twist.is_finite() { v.twist.to_degrees() } else { 0.0 }); - w.i(71, 0); - w.i(281, 0); - w.i(72, 0); - let xdata = [("LAYERSTATE", v.layer_state.as_deref()), ("LAYOUT", v.layout.as_deref().filter(|l| !l.is_empty()))]; - if xdata.iter().any(|(_, x)| x.is_some()) { - w.s(1001, dxf_ext::APP); - for (key, value) in xdata { - if let Some(value) = value { - w.s(1000, key); - w.s(1000, dxf_ext::xdata_str(value)); - } - } - } - } - w.s(0, "ENDTAB"); - // UCS: named user coordinate systems (DXF Reference, UCS). - let th = table_head(&mut w, "UCS", d.ucss.len()); - for u in &d.ucss { - record_head(&mut w, "UCS", &th, "AcDbUCSTableRecord"); - w.s(2, &u.name); - w.i(70, 0); - w.p(10, u.origin); - w.p(11, u.x_axis); - w.p(12, u.y_axis); - w.i(79, 0); - w.f(146, 0.0); - } - w.s(0, "ENDTAB"); - let apps = ["ACAD", dxf_ext::APP, "AcadAnnotative", dxf_ext::LAYER_TRANSPARENCY_APP, dxf_ext::LAYER_DESCRIPTION_APP]; - let th = table_head(&mut w, "APPID", apps.len()); - for app in apps { - record_head(&mut w, "APPID", &th, "AcDbRegAppTableRecord"); - w.s(2, app); - w.i(70, 0); - } - w.s(0, "ENDTAB"); - // DIMSTYLE - let th = { - let h = w.h(); - w.s(0, "TABLE"); - w.s(2, "DIMSTYLE"); - w.s(5, h.clone()); - w.s(330, "0"); - w.s(100, "AcDbSymbolTable"); - w.i(70, d.dim_styles.len() as i64); - w.s(100, "AcDbDimStyleTable"); - w.i(71, 0); - h - }; - for s in &d.dim_styles { - record_head(&mut w, "DIMSTYLE", &th, "AcDbDimStyleTableRecord"); - w.s(2, &s.name); - w.i(70, 0); - let v = serde_json::to_value(s).unwrap_or_default(); - let defaults = serde_json::to_value(DimStyle::default()).unwrap_or_default(); - let mut newer = W { t: Vec::new(), next: 0, pstyles: HashMap::new(), layers: HashMap::new(), reactors: HashMap::new() }; - for (field, code, kind) in dxf_ext::DIM_CODES { - let Some(enc) = v.get(*field).and_then(|x| cx.encode(*kind, x)) else { continue }; - // DIMPOST is written even when empty: a reader that defaults a missing one to "<>" - // (the DWG bridge's) would otherwise give radius and diameter dimensions no "R" or - // "Ø" in AutoCAD (#1053). - if *code == dxf_ext::DIMPOST - && let DimVal::Str(t) = &enc - && t.is_empty() - { - w.s(*code, ""); - continue; - } - if !dxf_ext::NEWER_CODES.contains(code) { - w.dimval(*code, enc, false); - continue; - } - // 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)); - newer.dimval(*code, enc, true); - } - } - if !s.arrow_block1.trim().is_empty() || !s.arrow_block2.trim().is_empty() { - w.i(dxf_ext::DIMSAH, 1); - } - if s.annotative { - annotative_xdata(&mut w); - } - if !newer.t.is_empty() { - w.s(1001, dxf_ext::APP); - w.s(1000, dxf_ext::DIMVARS_LIST); - w.s(1002, "{"); - w.xdata(newer.t); - w.s(1002, "}"); - } - } - w.s(0, "ENDTAB"); - // BLOCK_RECORD - let th = table_head(&mut w, "BLOCK_RECORD", 1 + ps_brs.len() + user_blocks.len() + dim_defs.len() + arrow_defs.len() + table_defs.len()); - let br_rec = |w: &mut W, h: &str, name: &str, layout: Option<&str>, blk: Option<&Block>| { - w.s(0, "BLOCK_RECORD"); - w.s(5, h); - w.s(330, th.clone()); - w.s(100, "AcDbSymbolTableRecord"); - w.s(100, "AcDbBlockTableRecord"); - w.s(2, name); - w.s(340, layout.unwrap_or("0")); - // User blocks: insertion units (70) and explodability (280). - if let Some(b) = blk { - w.i(70, i64::from(b.units)); - w.i(280, i64::from(b.explodable)); - if let Some(a) = &b.array { - w.s(1001, dxf_ext::APP); - w.xdata(dxf_ext::array_xdata(a)); - } - } - }; - br_rec(&mut w, &ms_br, "*Model_Space", Some(&model_layout), None); - for (i, (n, h, _)) in ps_brs.iter().enumerate() { - br_rec(&mut w, h, n, layout_handles.get(i).map(String::as_str), None); - } - for (n, h, b) in &user_blocks { - br_rec(&mut w, h, n, None, Some(b)); - } - for (n, h, _) in dim_defs.iter().chain(&arrow_defs).chain(&table_defs) { - br_rec(&mut w, h, n, None, None); - } - w.s(0, "ENDTAB"); - w.s(0, "ENDSEC"); - - // ---------------- BLOCKS ---------------- - w.s(0, "SECTION"); - w.s(2, "BLOCKS"); - 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); - w.s(330, brh); - w.s(100, "AcDbEntity"); - if paper { - w.i(67, 1); - } - w.s(8, "0"); - w.s(100, "AcDbBlockBegin"); - w.s(2, name); - w.i(70, flags); - w.p(10, base); - w.s(3, name); - w.s(1, ""); - if !desc.is_empty() { - w.s(4, desc); - } - for e in ents { - entity(w, d, e, brh, false, &cx); - } - let eh = w.h(); - w.s(0, "ENDBLK"); - w.s(5, eh); - w.s(330, brh); - w.s(100, "AcDbEntity"); - if paper { - w.i(67, 1); - } - w.s(8, "0"); - w.s(100, "AcDbBlockEnd"); - }; - 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); - } else { - 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); - } - // 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()) { - for e in ents.iter_mut().filter(|e| e.handle.0 == 0) { - e.handle = Handle(w.next); - w.next = w.next.saturating_add(1); - } - } - for (n, h, ents) in &dim_defs { - 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); - } - for (n, h, ents) in &table_defs { - block(&mut w, (n, ""), h, Vec3::ZERO, 1, &mut ents.iter(), false); - } - w.s(0, "ENDSEC"); - - // ---------------- ENTITIES ---------------- - w.s(0, "SECTION"); - w.s(2, "ENTITIES"); - for e in d.model.iter() { - entity(&mut w, d, e, &ms_br, false, &cx); - } - if let Some((_, h, l)) = ps_brs.first() { - for e in sheet_first(l) { - entity(&mut w, d, e, h, true, &cx); - } - } - w.s(0, "ENDSEC"); - - // ---------------- OBJECTS ---------------- - // Groups (members that still exist) and multileader styles. - let group_owner = group_dict.clone(); - let written: std::collections::HashSet = w.t.iter().filter(|t| t.code == 5).map(Tag::str).collect(); - let groups: Vec<(&Group, String)> = d.groups.iter().filter(|g| !g.name.is_empty()).map(|g| (g, w.h())).collect(); - let mleader_dict = w.h(); - let mleader_handles: Vec = cx.mleader_styles.iter().map(|(_, h)| h.clone()).collect(); - w.s(0, "SECTION"); - w.s(2, "OBJECTS"); - w.s(0, "DICTIONARY"); - w.s(5, root_dict.clone()); - w.s(330, "0"); - w.s(100, "AcDbDictionary"); - w.i(281, 1); - w.s(3, "ACAD_GROUP"); - w.s(350, group_dict.clone()); - if !cx.images.is_empty() { - w.s(3, "ACAD_IMAGE_DICT"); - w.s(350, cx.images.dict.clone()); - w.s(3, "ACAD_IMAGE_VARS"); - w.s(350, cx.images.vars.clone()); - } - w.s(3, "ACAD_LAYOUT"); - w.s(350, layout_dict.clone()); - w.s(3, "ACAD_MLEADERSTYLE"); - w.s(350, mleader_dict.clone()); - w.s(3, "ACAD_MLINESTYLE"); - w.s(350, cx.mlines.dict.clone()); - w.s(3, dxf_ext::PLOT_STYLE_NAMES_KEY); - w.s(350, pstyle_dict.clone()); - w.s(3, "ACAD_TABLESTYLE"); - w.s(350, table_style_dict.clone()); - for (key, h, _) in &xrecords { - w.s(3, *key); - w.s(350, h); - } - w.s(3, "ACAD_SCALELIST"); - w.s(350, scale_dict.clone()); - w.s(0, "DICTIONARY"); - w.s(5, group_dict); - w.s(330, root_dict.clone()); - w.s(100, "AcDbDictionary"); - w.i(281, 1); - for (g, h) in &groups { - w.s(3, &g.name); - w.s(350, h); - } - for (g, h) in &groups { - group_obj(&mut w, g, h, &group_owner, &written); - } - // Multileader styles. - w.s(0, "DICTIONARY"); - w.s(5, mleader_dict.clone()); - w.s(330, root_dict.clone()); - w.s(100, "AcDbDictionary"); - w.i(281, 1); - for (s, h) in mleader_styles.iter().zip(&mleader_handles) { - w.s(3, &s.name); - w.s(350, h); - } - let byblock_ltype = ltype_handles.get("BYBLOCK").cloned(); - for (s, h) in mleader_styles.iter().zip(&mleader_handles) { - mleader_style_obj(&mut w, s, h, &mleader_dict, byblock_ltype.as_deref(), cx.style(&s.text_style)); - } - // Multiline styles. - w.t.extend(crate::dxf_mline::objects(&cx.mlines, &root_dict)); - // Table styles. - w.s(0, "DICTIONARY"); - w.s(5, table_style_dict.clone()); - w.s(330, root_dict.clone()); - w.s(100, "AcDbDictionary"); - w.i(281, 1); - for (name, h) in &cx.table_styles { - w.s(3, name); - w.s(350, h); - } - for (s, (_, h)) in table_styles.iter().zip(&cx.table_styles) { - table_style_obj(&mut w, d, s, h, &table_style_dict); - } - // Plot style names: a dictionary with a default (Normal) of placeholder objects. - w.s(0, "ACDBDICTIONARYWDFLT"); - w.s(5, pstyle_dict.clone()); - w.group("ACAD_REACTORS", 330, &[&root_dict]); - w.s(330, root_dict.clone()); - w.s(100, "AcDbDictionary"); - w.i(281, 1); - for (n, h) in &pstyles { - w.s(3, n); - w.s(350, h); - } - w.s(100, "AcDbDictionaryWithDefault"); - w.s(340, pstyles.first().map(|(_, h)| h.clone()).unwrap_or_default()); - for (_, h) in &pstyles { - w.s(0, "ACDBPLACEHOLDER"); - w.s(5, h); - w.group("ACAD_REACTORS", 330, &[&pstyle_dict]); - w.s(330, pstyle_dict.clone()); - } - // Parametric constraints and parameters, saved layer states, plot style tables (CADCraft - // data). - for (_, h, chunks) in &xrecords { - w.s(0, "XRECORD"); - w.s(5, h); - w.group("ACAD_REACTORS", 330, &[&root_dict]); - w.s(330, root_dict.clone()); - w.s(100, "AcDbXrecord"); - w.i(280, 1); - for c in chunks { - w.s(1, c.clone()); - } - } - scale_list(&mut w, &scales, &scale_dict, &root_dict); - // Dimension associativity: extension dictionary + DIMASSOC per dimension. - let mut assoc: Vec<(&Handle, &AssocObj)> = cx.assoc.iter().collect(); - assoc.sort_by_key(|(h, _)| **h); - for (dim, a) in assoc { - let dh = dim.hex(); - w.s(0, "DICTIONARY"); - w.s(5, a.xdict.clone()); - w.group("ACAD_REACTORS", 330, &[&dh]); - w.s(330, dh.clone()); - w.s(100, "AcDbDictionary"); - w.i(280, 1); - w.i(281, 1); - w.s(3, "ACAD_DIMASSOC"); - w.s(360, a.handle.clone()); - w.s(0, "DIMASSOC"); - w.s(5, a.handle.clone()); - w.group("ACAD_REACTORS", 330, &[&a.xdict]); - w.s(330, a.xdict.clone()); - w.s(100, "AcDbDimAssoc"); - w.s(330, dh); - w.i(90, a.flags); - w.i(70, 0); - w.i(71, 0); - for r in &a.refs { - w.s(1, "AcDbOsnapPointRef"); - w.i(72, r.osnap); - w.s(331, r.main.hex()); - w.i(73, if r.segment > 0 { 2 } else { 0 }); - w.i(91, r.segment); - w.s(301, ""); - w.f(40, r.param); - w.p(10, r.point); - if let Some(o) = r.other { - w.s(332, o.hex()); - w.i(74, 0); - w.i(92, 0); - w.s(302, ""); - } - w.i(75, 0); - } - } - w.s(0, "DICTIONARY"); - w.s(5, layout_dict.clone()); - w.s(330, root_dict.clone()); - w.s(100, "AcDbDictionary"); - w.i(281, 1); - w.s(3, "Model"); - w.s(350, model_layout.clone()); - for (i, (_, _, l)) in ps_brs.iter().enumerate() { - 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, 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 }; - let standard = page.scale_to_fit || scale == 1.0; - let plot_flags = 512 - | 32 - | if standard { 16 } else { 0 } - | if page.lineweights { 128 } else { 0 } - | if page.center { 4 } else { 0 } - | if page.show_plot_styles { 2 } else { 0 }; - let plot_type = match page.plot_area.as_str() { - "display" => 0, - "extents" => 1, - "limits" => 2, - "window" => 4, - _ => 5, - }; - w.s(0, "LAYOUT"); - w.s(5, h); - w.s(330, layout_dict.clone()); - w.s(100, "AcDbPlotSettings"); - w.s(1, ""); - let device = page.device.trim(); - w.s(2, if device.is_empty() || device.eq_ignore_ascii_case("None") { "none_device" } else { device }); - w.s(4, page.paper.replace(' ', "_")); - w.s(6, ""); - for (c, v) in [ - (40, page.margins_mm[0]), - (41, page.margins_mm[1]), - (42, page.margins_mm[2]), - (43, page.margins_mm[3]), - (44, page.width_mm), - (45, page.height_mm), - (46, 0.0), - (47, 0.0), - (48, page.plot_window[0]), - (49, page.plot_window[1]), - (140, page.plot_window[2]), - (141, page.plot_window[3]), - (142, scale), - (143, 1.0), - ] { - w.f(c, v); - } - w.i(70, plot_flags); - // Plot paper units: 1 millimetres, 0 inches (#1058). - w.i(72, i64::from(cadcraft_render::paper::page_unit_mm(d, page) == 1.0)); - w.i(73, i64::from(page.landscape)); - w.i(74, plot_type); - w.s(7, &page.plot_style_table); - w.i(75, if page.scale_to_fit { 0 } else { 16 }); - w.f(147, scale); - w.f(148, 0.0); - w.f(149, 0.0); - w.s(100, "AcDbLayout"); - w.s(1, name); - w.i(70, flags); - w.i(71, tab); - w.p2(10, Vec2::ZERO); - w.p2(11, Vec2::new(12.0, 9.0)); - w.p(12, Vec3::ZERO); - w.p(14, Vec3::ZERO); - w.p(15, Vec3::ZERO); - w.f(146, 0.0); - w.p(13, Vec3::ZERO); - w.p(16, Vec3::new(1.0, 0.0, 0.0)); - w.p(17, Vec3::new(0.0, 1.0, 0.0)); - w.i(76, 0); - w.s(330, brh); - // The saved paper-space view (centre, height) as CADCraft xdata. - if let Some((c, height)) = view.filter(|(c, v)| c.x.is_finite() && c.y.is_finite() && v.is_finite()) { - w.s(1001, dxf_ext::APP); - w.s(1000, "PSVIEW"); - w.s(1002, "{"); - w.p(1010, c.to3(0.0)); - w.f(1040, height); - w.s(1002, "}"); - } - }; - // Model space has no page setup of its own: write what PLOT does for it, the drawing extents - // fitted to the paper (plot type 1, scaled to fit). The default page's "layout" area is - // 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); - 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); - } - // Raster image definitions. - w.t.extend(crate::dxf_image::objects(&cx.images, &root_dict)); - w.s(0, "ENDSEC"); - w.s(0, "EOF"); - let seed = format!("{:X}", w.next); - if let Some(t) = w.t.get_mut(seed_pos + 1) { - *t = Tag::s(5, seed); - } - crate::dxf_raw::finish(&mut w.t, d); - crate::dxf_raw::data_store(&mut w.t, d, target >= Version::R2013); - add_class_instance_counts(&mut w.t); - mark_xrefs(&mut w.t, d); - w.t -} - -/// Write DXF Reference group 91 on every CLASS record. Count records after `dxf_raw::finish` -/// restores foreign classes and objects so every class gets its actual instance count. -fn add_class_instance_counts(tags: &mut Vec) { - let mut counts: HashMap = HashMap::new(); - for t in tags.iter().filter(|t| t.code == 0) { - let kind = t.str(); - if kind != "CLASS" { - *counts.entry(kind.to_ascii_uppercase()).or_default() += 1; - } - } - - let mut start = 0; - while start < tags.len() { - if tags.get(start).is_none_or(|t| t.code != 0 || t.str() != "CLASS") { - start += 1; - continue; - } - let end = tags[start + 1..].iter().position(|t| t.code == 0).map_or(tags.len(), |i| start + 1 + i); - let name = tags[start + 1..end].iter().find(|t| t.code == 1).map(Tag::str).unwrap_or_default().to_ascii_uppercase(); - let count = counts.get(&name).copied().unwrap_or(0); - let inserted = if let Some(i) = tags[start + 1..end].iter().position(|t| t.code == 91) { - tags[start + 1 + i] = Tag::i(91, count); - false - } else if let Some(i) = tags[start + 1..end].iter().position(|t| t.code == 90) { - tags.insert(start + 2 + i, Tag::i(91, count)); - true - } else { - false - }; - // Inserting the group shifts the next record's start by one. - start = end + usize::from(inserted); - } -} - -/// Xref blocks (DXF Reference, BLOCK): bit 4 of group 70 and the external drawing's path in -/// group 1, set on the written BLOCK records of blocks that have an `xref_path`. -fn mark_xrefs(tags: &mut [Tag], d: &Drawing) { - // Overlays add bit 8. - let xrefs: HashMap = d - .blocks - .values() - .filter_map(|b| Some((b.name.to_ascii_uppercase(), (b.xref_path.as_deref()?, if b.xref_overlay { 12 } else { 4 })))) - .collect(); - if xrefs.is_empty() { - return; - } - // Inside a BLOCK record: the path once its name (2) says it is an xref. - let mut block: Option> = None; - for t in tags.iter_mut() { - match (t.code, block) { - (0, _) => block = (t.str() == "BLOCK").then_some(None), - (2, Some(None)) => block = Some(xrefs.get(&t.str().to_ascii_uppercase()).copied()), - (70, Some(Some((_, flags)))) => *t = Tag::i(70, t.i64() | flags), - (1, Some(Some((p, _)))) => *t = Tag::s(1, p), - _ => {} - } - } -} - -/// A TABLESTYLE object (DXF Reference, OBJECTS: TABLESTYLE): margins, title/header -/// suppression and per row type (data, column header, title) text and border settings. -fn table_style_obj(w: &mut W, d: &Drawing, s: &TableStyle, h: &str, dict: &str) { - w.s(0, "TABLESTYLE"); - w.s(5, h); - w.group("ACAD_REACTORS", 330, &[dict]); - w.s(330, dict); - w.s(100, "AcDbTableStyle"); - w.s(3, ""); - w.i(70, 0); - w.i(71, 0); - w.f(40, s.margin); - w.f(41, s.margin); - w.i(280, i64::from(!s.title)); - w.i(281, i64::from(!s.header)); - // Data, column header and title cell styles; CADCraft's complete cell styles in xdata. - for kind in crate::dxf_table::CELL_TYPES { - let cs = s.cell_style(kind); - let style = if d.text_style(&cs.text_style).is_some() { cs.text_style.clone() } else { "Standard".to_string() }; - w.t.extend(crate::dxf_table::cell_type_tags(&cs, &style)); - } - let x = crate::dxf_table::cell_styles_xdata(s); - if !x.is_empty() { - w.s(1001, dxf_ext::APP); - for c in x { - w.s(1000, c); - } - } -} - -/// A GROUP object (DXF Reference, OBJECTS: GROUP): description, unnamed (`*A…`) and selectable -/// flags, and the member entities that were written to this file. -fn group_obj(w: &mut W, g: &Group, h: &str, dict: &str, written: &std::collections::HashSet) { - w.s(0, "GROUP"); - w.s(5, h); - w.group("ACAD_REACTORS", 330, &[dict]); - w.s(330, dict); - w.s(100, "AcDbGroup"); - w.s(300, &g.description); - w.i(70, i64::from(g.name.starts_with('*'))); - w.i(71, i64::from(g.selectable)); - for m in g.members.iter().map(|m| m.hex()).filter(|m| written.contains(m)) { - w.s(340, m); - } -} - -/// A MLEADERSTYLE object (DXF Reference, OBJECTS: MLEADERSTYLE): MText content and straight -/// ByBlock leaders with the style's landing gap, dogleg length, arrow size, text style and -/// text height. -fn mleader_style_obj(w: &mut W, s: &MLeaderStyle, h: &str, dict: &str, byblock_ltype: Option<&str>, text_style: Option) { - // ByBlock as a 32-bit colour value (0xC1000000). - const BYBLOCK: i64 = -1_056_964_608; - w.s(0, "MLEADERSTYLE"); - w.s(5, h); - w.group("ACAD_REACTORS", 330, &[dict]); - w.s(330, dict); - w.s(100, "AcDbMLeaderStyle"); - w.i(179, 2); - w.i(170, 2); - w.i(171, 1); - w.i(172, 0); - w.i(90, 2); - w.f(40, 0.0); - w.f(41, 0.0); - w.i(173, 1); - w.i(91, BYBLOCK); - if let Some(lt) = byblock_ltype { - w.s(340, lt); - } - w.i(92, -2); - w.i(290, 1); - w.f(42, s.landing_gap); - w.i(291, 1); - w.f(43, s.dogleg); - w.s(3, ""); - w.f(44, s.arrow_size); - w.s(300, ""); - if let Some(ts) = text_style { - w.s(342, ts); - } - w.i(174, 1); - w.i(178, 1); - w.i(175, 1); - w.i(176, 0); - w.i(93, BYBLOCK); - w.f(45, s.text_height); - w.i(292, 0); - w.i(297, 0); - w.f(46, 4.0); - w.i(94, BYBLOCK); - for c in [47, 49, 140] { - w.f(c, 1.0); - } - w.i(293, 1); - w.f(141, 0.0); - w.i(294, 1); - w.i(177, 0); - w.f(142, 1.0); - w.i(295, 0); - w.i(296, i64::from(s.annotative)); - w.f(143, 0.125); - w.i(271, 0); - w.i(272, 9); - w.i(273, 9); -} - -/// The ACAD_SCALELIST dictionary and its SCALE objects (DXF Reference, OBJECTS section): name -/// (300), paper units (140), drawing units (141), unit scale flag (290). -fn scale_list(w: &mut W, scales: &[(&cadcraft_doc::AnnoScale, String)], dict: &str, root: &str) { - w.s(0, "DICTIONARY"); - w.s(5, dict); - w.group("ACAD_REACTORS", 330, &[root]); - w.s(330, root); - w.s(100, "AcDbDictionary"); - w.i(281, 1); - for (i, (_, h)) in scales.iter().enumerate() { - w.s(3, format!("S{i}")); - w.s(350, h.clone()); - } - for (s, h) in scales { - w.s(0, "SCALE"); - w.s(5, h.clone()); - w.group("ACAD_REACTORS", 330, &[dict]); - w.s(330, dict); - w.s(100, "AcDbScale"); - w.i(70, 0); - w.s(300, dxf_ext::xdata_str(&s.name)); - w.f(140, s.paper_units); - w.f(141, s.drawing_units); - w.i(290, i64::from(cadcraft_doc::same_factor(s.factor(), 1.0))); - } -} - -fn cadcraft_fonts_name() -> &'static str { - "CADCraft Stroke" -} - /// One entity's DXF groups, as the writer stores them, without its `0` group: its type name /// and the groups of its record (an entity written as several records, a POLYLINE and its /// VERTEXes, gives its first). Handles of objects outside the entity (owner, styles) are left diff --git a/crates/io/src/dxf_write/assoc.rs b/crates/io/src/dxf_write/assoc.rs new file mode 100644 index 000000000..9d9a607aa --- /dev/null +++ b/crates/io/src/dxf_write/assoc.rs @@ -0,0 +1,122 @@ +use super::ctx::OsnapRef; +use super::*; + +/// Whether a DIMASSOC point reference to `k` names a point of it without naming a segment: +/// true for objects made of one curve. Polylines are referenced by segment ([`polyline_vertex_ref`]); +/// 3D polylines and multilines can't be yet, and references to them are left out, as AutoCAD +/// otherwise resolved them to the first segment and changed the dimension's value (#1049). +fn whole_object_ref(k: &EntityKind) -> bool { + !matches!(k, EntityKind::LwPolyline(_) | EntityKind::Polyline3d(_) | EntityKind::MLine(_)) +} + +/// A polyline vertex as AutoCAD references it (#1049): an end of one of its two segments, +/// which are numbered from 1 (segment k runs from vertex k−1 to vertex k; a closed polyline's +/// closing segment is the last). Returns (osnap, parameter, segment): the end of the incoming +/// segment (osnap 1, parameter 1.0), else the start of the outgoing one (osnap 13, 0.0). Only +/// straight segments are used, whose parameters are 0 and 1; a vertex between two arcs gets no +/// reference. +pub(crate) fn polyline_vertex_ref(p: &LwPolyline, index: usize) -> Option<(i64, f64, i64)> { + let n = p.vertices.len(); + if index >= n || n < 2 { + return None; + } + let segments = if p.closed { n } else { n - 1 }; + // Segment k (1-based) starts at vertex k−1; its bulge is that vertex's. + let straight = |k: usize| k >= 1 && k <= segments && p.vertices.get(k - 1).is_some_and(|v| v.bulge == 0.0); + let incoming = if index == 0 { if p.closed { Some(n) } else { None } } else { Some(index) }; + let outgoing = (index < segments).then_some(index + 1); + // Vertex 0 as the start of segment 1, the others as the end of the segment before, as + // AutoCAD writes them when it can. + let preferred = if index == 0 { + [outgoing.map(|k| (13, 0.0, k)), incoming.map(|k| (1, 1.0, k))] + } else { + [incoming.map(|k| (1, 1.0, k)), outgoing.map(|k| (13, 0.0, k))] + }; + preferred.into_iter().flatten().find(|(_, _, k)| straight(*k)).map(|(o, t, k)| (o, t, i64::try_from(k).unwrap_or(0))) +} + +/// Standard DIMASSOC point references of an associative dimension: the extension-line +/// origins of linear/aligned dimensions, both ends of both lines of an angular dimension, the +/// feature point of ordinate dimensions and the circle of a radial/diametric dimension, linked +/// to objects that exist ([`whole_object_ref`]). CADCraft's exact links travel in xdata as well. +pub(super) fn std_assoc_refs(d: &Drawing, dm: &Dimension) -> (i64, Vec) { + if matches!(dm.kind, DimKind::Radius | DimKind::Diameter) { + return radial_ref(d, &dm.assoc); + } + let slots: &[&str] = match dm.kind { + DimKind::Linear { .. } | DimKind::Aligned => &["p13", "p14"], + DimKind::Angular => &["p13", "p14", "p15", "p16"], + DimKind::Angular3P => &["p13", "p14", "p15"], + // Saved as an aligned dimension (`dim_xdata` marks it): its extension origins are the arc ends. + DimKind::ArcLength => &["p13", "p14"], + DimKind::Ordinate { .. } => &["p13"], + DimKind::Radius | DimKind::Diameter => &[], + }; + let mut flags = 0; + let mut refs = Vec::new(); + for (slot, name) in slots.iter().enumerate() { + let Some(a) = dm.assoc.iter().find(|a| a.point == *name) else { continue }; + let Some(ent) = d.entity(a.handle) else { continue }; + let point = match *name { + "p13" => dm.p13, + "p14" => dm.p14, + "p15" => dm.p15, + "p16" => dm.p16, + _ => continue, + }; + if let EntityKind::LwPolyline(p) = &ent.kind { + let index = match &a.snap { + AssocSnap::Vertex { index } => Some(*index), + AssocSnap::Start => Some(0), + AssocSnap::End => p.vertices.len().checked_sub(1), + _ => None, + }; + let Some((osnap, param, segment)) = index.and_then(|i| polyline_vertex_ref(p, i)) else { continue }; + flags |= 1 << slot; + refs.push(OsnapRef { osnap, main: a.handle, segment, param, point, other: None }); + continue; + } + if !whole_object_ref(&ent.kind) { + continue; + } + let (osnap, param, other) = match &a.snap { + // AutoCAD marks a curve's start with osnap 13 and its end with osnap 1 (group 72). + AssocSnap::Start => (if matches!(ent.kind, EntityKind::Point(_)) { 1 } else { 13 }, 0.0, None), + AssocSnap::End => (1, 1.0, None), + AssocSnap::Vertex { .. } => (1, 0.0, None), + AssocSnap::Mid => (2, 0.0, None), + AssocSnap::Center => (if matches!(ent.kind, EntityKind::Point(_)) { 4 } else { 3 }, 0.0, None), + AssocSnap::OnCircle { angle } => (10, *angle, None), + AssocSnap::Intersection { other } => { + if !d.entity(*other).is_some_and(|o| whole_object_ref(&o.kind)) { + continue; + } + (6, 0.0, Some(*other)) + } + }; + flags |= 1 << slot; + refs.push(OsnapRef { osnap, main: a.handle, segment: 0, param, point, other }); + } + (flags, refs) +} + +/// The single standard reference of a radial or diametric dimension: the circle or arc it +/// measures, by its centre, with associativity flag 2. AutoCAD writes radial and diametric +/// dimensions that way: the one reference names the geometry, whose radius or diameter the +/// dimension then follows. Returns no flags and no references when the association does not name +/// a circle or arc, so such a dimension is saved unassociated rather than pointing at nothing. +fn radial_ref(d: &Drawing, assoc: &[DimAssoc]) -> (i64, Vec) { + for a in assoc { + let Some(ent) = d.entity(a.handle) else { continue }; + let centre = match &ent.kind { + EntityKind::Circle(c) => c.center.xy(), + EntityKind::Arc(arc) => arc.center.xy(), + _ => continue, + }; + if !centre.is_finite() { + continue; + } + return (2, vec![OsnapRef { osnap: 3, main: a.handle, segment: 0, param: 0.0, point: centre.to3(0.0), other: None }]); + } + (0, Vec::new()) +} diff --git a/crates/io/src/dxf_write/blocks.rs b/crates/io/src/dxf_write/blocks.rs new file mode 100644 index 000000000..6514d49bc --- /dev/null +++ b/crates/io/src/dxf_write/blocks.rs @@ -0,0 +1,221 @@ +use super::*; + +/// What happens to the anonymous `*D` blocks read from a file (#495): a dimension that still +/// names its block regenerates its geometry under that name, keeping the block's entity +/// handles, and `*D` blocks nothing else references are not written again. +pub(super) struct DimBlockPlan { + /// Dimension (model or paper space) → the read block whose name and handles it reuses. + pub(super) reuse: HashMap>, + /// Upper-case names of `*D` blocks not written as ordinary blocks. + pub(super) skip: std::collections::HashSet, +} + +fn is_dim_block(name: &str) -> bool { + name.get(..2).is_some_and(|p| p.eq_ignore_ascii_case("*D")) +} + +pub(super) fn dim_block_plan(d: &Drawing) -> DimBlockPlan { + let spaces: Vec<&EntityStore> = std::iter::once(&d.model).chain(d.layouts.iter().map(|l| &l.entities)).collect(); + // Block names other objects use: inserts and multileader contents anywhere, and dimensions + // inside block definitions (their blocks are written as read). + let mut used = std::collections::HashSet::new(); + let note = |e: &Entity, dims: bool, used: &mut std::collections::HashSet| match &e.kind { + EntityKind::Insert(i) => { + used.insert(i.block.to_ascii_uppercase()); + } + EntityKind::MLeader(m) => used.extend(m.block.as_ref().map(|b| b.block.to_ascii_uppercase())), + EntityKind::Dimension(dm) if dims => used.extend(dm.block.as_ref().map(|n| n.to_ascii_uppercase())), + _ => {} + }; + for e in spaces.iter().flat_map(|s| s.iter()) { + note(e, false, &mut used); + } + for e in d.blocks.values().flat_map(|b| b.entities.iter()) { + note(e, true, &mut used); + } + let skip = d.blocks.keys().filter(|n| is_dim_block(n)).map(|n| n.to_ascii_uppercase()).filter(|n| !used.contains(n)).collect(); + let mut plan = DimBlockPlan { reuse: HashMap::new(), skip }; + let mut claimed = std::collections::HashSet::new(); + for e in spaces.iter().flat_map(|s| s.iter()) { + let EntityKind::Dimension(dm) = &e.kind else { continue }; + let Some(b) = dm.block.as_deref().filter(|n| is_dim_block(n)).and_then(|n| d.block(n)) else { continue }; + let key = b.name.to_ascii_uppercase(); + if plan.skip.contains(&key) && claimed.insert(key) { + plan.reuse.insert(e.handle, b.clone()); + } + } + plan +} + +/// Give regenerated dimension geometry the handles of the block it replaces, in order; the rest +/// keep handle 0 and get fresh ones. +pub(super) fn reuse_handles(ents: &mut [Entity], old: &Block, taken: &mut std::collections::HashSet) { + let mut handles = old.entities.iter().map(|e| e.handle).filter(|h| h.0 != 0); + for e in ents.iter_mut() { + match handles.by_ref().find(|h| taken.insert(*h)) { + Some(h) => e.handle = h, + None => break, + } + } +} + +/// Anonymous dimension blocks (`*D1`…) with the rendered geometry, as consumers expect. +pub(super) fn dim_block_entities(d: &Drawing, dm: &Dimension, layer: &str) -> 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 mut out = Vec::new(); + // Each part in its style colour (DIMCLRD, DIMCLRE, DIMCLRT), ByBlock when the style says so, + // as AutoCAD writes them: readers that draw the block drew every part ByBlock, i.e. in the + // 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)); + 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, + }; + 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() { + out.push(Entity { + handle: Handle(0), + common: dim_c.clone(), + kind: EntityKind::Solid(Solid { corners: [a.to3(0.0), b.to3(0.0), cc.to3(0.0), cc.to3(0.0)] }), + }); + } + } + // 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), + common: text_c, + kind: EntityKind::MText(MText { + insert: g.text_pos.to3(0.0), + 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, + }), + }); + } + out +} + +/// Rows and columns written for a table (bounded like the renderer). +pub(super) fn table_size(t: &Table) -> (usize, usize) { + (t.row_heights.len().min(10_000), t.col_widths.len().min(10_000)) +} + +/// The anonymous `*T` block of a table: cell fills, borders and texts as its cell styles draw +/// them, for readers that only draw the block. +/// +/// The block is relative to its own origin: the ACAD_TABLE reference adds the insertion point. +pub(crate) fn table_block_entities(d: &Drawing, t: &Table, layer: &str) -> Vec { + let at_origin = Table { insert: Vec3::ZERO, ..t.clone() }; + let g = cadcraft_render::table_geometry(d, &at_origin); + let c = Common { layer: layer.into(), color: Color::ByBlock, ..Common::default() }; + let mut out = Vec::new(); + for f in &g.fills { + let [a, b, cc, dd] = f.corners.map(|p| p.to3(0.0)); + // SOLID corners go 1, 2, 4, 3. + out.push(Entity { + handle: Handle(0), + common: Common { color: f.color, ..c.clone() }, + kind: EntityKind::Solid(Solid { corners: [a, b, dd, cc] }), + }); + } + for l in &g.lines { + out.push(Entity { + handle: Handle(0), + common: Common { color: l.color, lineweight: l.lineweight, ..c.clone() }, + kind: EntityKind::Line(Line { a: l.a.to3(0.0), b: l.b.to3(0.0) }), + }); + } + let mut texts = Vec::new(); + for cell in &g.texts { + if !cell.text.is_empty() { + let style = if d.text_style(&cell.style).is_some() { cell.style.clone() } else { "Standard".into() }; + texts.push(Entity { + handle: Handle(0), + common: Common { color: cell.color, ..c.clone() }, + kind: EntityKind::MText(MText { + insert: cell.at.to3(0.0), + height: cell.height, + width: 0.0, + attach: cell.attach, + rotation: 0.0, + style, + contents: cell.text.clone(), + line_spacing: 1.0, + line_spacing_exact: false, + columns: None, + }), + }); + } + } + out.extend(texts); + out +} + +/// Our own geometry for an arrowhead block: unit size, tip at the origin, pointing along +X. +pub(super) fn arrow_block_entities(kind: cadcraft_render::Arrowhead) -> Vec { + let g = cadcraft_render::arrowhead(kind, Vec2::ZERO, Vec2::X, 1.0); + let c = Common { layer: "0".into(), color: Color::ByBlock, ..Common::default() }; + let mut out = Vec::new(); + for l in &g.lines { + 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) }) }); + } + } + } + for t in g.tris.chunks(3) { + if let [a, b, cc] = t { + out.push(Entity { + handle: Handle(0), + common: c.clone(), + kind: EntityKind::Solid(Solid { corners: [a.to3(0.0), b.to3(0.0), cc.to3(0.0), cc.to3(0.0)] }), + }); + } + } + out +} + +/// Whether the entities of the block `name` are the arrowhead block CADCraft writes for that +/// standard arrow name ([`arrow_block_entities`]): the same lines and solids with the same +/// properties (layer 0, ByBlock). Any other content (AutoCAD's own `_Dot`, a user's +/// redefinition, an older CADCraft's geometry) is the file's. +pub(crate) fn is_generated_arrow(name: &str, entities: &[&Entity]) -> bool { + let Some((_, kind)) = dxf_ext::arrow_block_name(name) else { return false }; + let ours = arrow_block_entities(kind); + ours.len() == entities.len() && ours.iter().zip(entities).all(|(a, b)| a.kind == b.kind && a.common == b.common) +} diff --git a/crates/io/src/dxf_write/common.rs b/crates/io/src/dxf_write/common.rs new file mode 100644 index 000000000..91e00d888 --- /dev/null +++ b/crates/io/src/dxf_write/common.rs @@ -0,0 +1,299 @@ +use super::ctx::{AssocObj, Ctx}; +use super::*; + +/// `AcadAnnotative` xdata marking an annotative style. +pub(super) fn annotative_xdata(w: &mut W) { + w.s(1001, "AcadAnnotative"); + w.s(1000, "AnnotativeData"); + w.s(1002, "{"); + w.i(1070, 1); + w.i(1070, 1); + w.s(1002, "}"); +} + +/// Override (`ACAD` DSTYLE) and associativity (`CADCRAFT` ASSOC) xdata of a dimension. +pub(super) fn dim_xdata(w: &mut W, dm: &Dimension, cx: &Ctx) { + let mut ov = W { t: Vec::new(), next: 0, pstyles: HashMap::new(), layers: HashMap::new(), reactors: HashMap::new() }; + let mut sah = false; + for (k, v) in &dm.overrides { + let Some(field) = DimStyle::field_name(k) else { continue }; + let Some((code, kind)) = dxf_ext::dim_code(field) else { continue }; + let Some(val) = dxf_ext::canonical(field, v) else { continue }; + let Some(enc) = cx.encode(kind, &val) else { continue }; + sah |= matches!(code, 343 | 344); + ov.i(1070, i64::from(code)); + ov.dimval(code, enc, true); + } + if sah { + ov.i(1070, i64::from(dxf_ext::DIMSAH)); + ov.i(1070, 1); + } + if !ov.t.is_empty() { + w.s(1001, "ACAD"); + w.s(1000, "DSTYLE"); + w.s(1002, "{"); + w.xdata(ov.t); + w.s(1002, "}"); + } + let mut ours = Vec::new(); + if matches!(dm.kind, DimKind::ArcLength) { + ours.extend(dxf_ext::arclen_xdata(dm.p15)); + } + if !dm.assoc.is_empty() { + ours.extend(dxf_ext::assoc_xdata(&dm.assoc)); + } + ours.extend(dxf_ext::marks_xdata(&dm.marks)); + if !ours.is_empty() { + w.s(1001, dxf_ext::APP); + w.xdata(ours); + } +} + +/// Group 52 of a linear or aligned dimension with oblique extension lines: their angle less the +/// dimension line's (`base`), in degrees. +pub(super) fn oblique_52(w: &mut W, dm: &Dimension, base: f64) { + if let Some(a) = dm.marks.oblique.filter(|a| a.is_finite()) { + w.f(52, cadcraft_geom::norm_angle(a - base).to_degrees()); + } +} + +/// An entity's group 440 (DXF Reference): ByBlock is `0x01000000`, a fixed transparency its alpha +/// with the "by alpha" flag `0x02000000` (the alpha mapping layers use). ByLayer writes nothing. +fn transparency_440(t: Transparency) -> Option { + match t { + Transparency::ByLayer => None, + Transparency::ByBlock => Some(0x0100_0000), + Transparency::Percent(p) => Some(i64::from(0x0200_0000 | ((100 - u32::from(p.min(90))) * 255 / 100))), + } +} + +pub(super) fn common(w: &mut W, e: &Entity, owner: &str, paper: bool, subclass: &str) { + common_x(w, e, owner, paper, subclass, None); +} + +/// Common entity groups; `assoc` adds the reactor and extension dictionary of a DIMASSOC. +pub(super) fn common_x(w: &mut W, e: &Entity, owner: &str, paper: bool, subclass: &str, assoc: Option<&AssocObj>) { + w.s(5, e.handle.hex()); + let mut reactors: Vec = assoc.map(|a| a.handle.clone()).into_iter().collect(); + reactors.extend(w.reactors.get(&e.handle).into_iter().flatten().cloned()); + if !reactors.is_empty() { + let rs: Vec<&str> = reactors.iter().map(String::as_str).collect(); + w.group("ACAD_REACTORS", 330, &rs); + } + if let Some(a) = assoc { + w.group("ACAD_XDICTIONARY", 360, &[&a.xdict]); + } + w.s(330, owner); + w.s(100, "AcDbEntity"); + if paper { + w.i(67, 1); + } + layer_and_props(w, &e.common, None); + if (e.common.ltscale - 1.0).abs() > 1e-12 { + w.f(48, e.common.ltscale); + } + if !e.common.visible { + w.i(60, 1); + } + if let Some(v) = transparency_440(e.common.transparency) { + w.i(440, v); + } + // A named plot style (not ByLayer/ByBlock): its placeholder object. + if let Some(h) = w.pstyles.get(&e.common.plot_style.trim().to_ascii_uppercase()).cloned() { + w.s(390, h); + } + if !subclass.is_empty() { + w.s(100, subclass); + } +} + +/// Layer, linetype, colour and lineweight groups (8, 6, 62/420, 370). ByLayer is left out, unless +/// it replaces something else in `base` (an attribute's definition). +pub(super) fn layer_and_props(w: &mut W, c: &Common, base: Option<&Common>) { + let write = |by_layer: bool, base_by_layer: fn(&Common) -> bool| !by_layer || base.is_some_and(|b| !base_by_layer(b)); + w.s(8, &c.layer); + if write(c.linetype.eq_ignore_ascii_case("bylayer"), |b| b.linetype.eq_ignore_ascii_case("bylayer")) { + w.s(6, &c.linetype); + } + match c.color { + Color::ByLayer if write(true, |b| b.color == Color::ByLayer) => w.i(62, 256), + Color::ByLayer => {} + Color::True(rgb) => { + w.i(62, i64::from(cadcraft_color::nearest_aci(rgb))); + w.i(420, i64::from(rgb.to_u32())); + } + c => w.i(62, i64::from(c.to_aci())), + } + if write(c.lineweight == Lineweight::ByLayer, |b| b.lineweight == Lineweight::ByLayer) { + w.i(370, i64::from(c.lineweight.to_dxf())); + } +} + +/// The properties attribute `a` of block reference `e` (of `ins`) is drawn with: its own, else its +/// definition's; an attribute defined on layer 0 is on the reference's layer. Also the +/// definition's properties. +pub(super) fn attrib_common(d: &Drawing, e: &Entity, ins: &Insert, a: &Attrib) -> (Common, Common) { + let def = d + .block(&ins.block) + .and_then(|b| b.entities.iter().find(|be| matches!(&be.kind, EntityKind::AttDef(ad) if ad.tag.eq_ignore_ascii_case(&a.tag)))); + let base = def.map(|de| de.common.clone()).unwrap_or_default(); + let mut c = a.props.over(&base); + if c.layer == "0" { + c.layer.clone_from(&e.common.layer); + } + (c, base) +} + +/// A multiline attribute's groups after its own (DXF 2007 and later): attribute type 2 (ATTRIB) +/// or 4 (ATTDEF), its alignment point, then its text as embedded MTEXT (`101 Embedded Object`). +/// Only what differs from unrotated text goes in the embedded object, as AutoCAD writes it. +pub(super) fn multiline_tags(w: &mut W, a: &Attrib, attdef: bool) { + let Some(m) = a.multiline_text() else { return }; + w.i(71, if attdef { 4 } else { 2 }); + w.i(72, if attdef { 0 } else { 1 }); + w.p(11, a.text.align_pt.unwrap_or(a.text.insert)); + w.s(101, "Embedded Object"); + w.p(10, m.insert); + w.f(40, m.height); + w.f(41, m.width); + w.f(46, 0.0); + w.i(71, i64::from(m.attach)); + w.i(72, 5); + let chars: Vec = crate::dxf_read::mtext_dxf::encode_contents(&m.contents).chars().collect(); + let chunks: Vec = chars.chunks(250).map(|c| c.iter().collect()).collect(); + let n = chunks.len(); + for (i, c) in chunks.iter().enumerate() { + w.s(if i + 1 == n { 1 } else { 3 }, c.clone()); + } + if n == 0 { + w.s(1, ""); + } + w.s(7, &m.style); + if m.rotation != 0.0 { + w.p(11, Vec3::new(m.rotation.cos(), m.rotation.sin(), 0.0)); + } + w.i(73, if m.line_spacing_exact { 2 } else { 1 }); + w.f(44, m.line_spacing); +} + +/// ATTRIB/ATTDEF flags (group 70): 1 invisible, 2 constant, 4 verify, 8 preset. +fn attrib_flags(a: &Attrib) -> i64 { + i64::from(a.invisible) | if a.constant { 2 } else { 0 } | if a.modes.verify { 4 } else { 0 } | if a.modes.preset { 8 } else { 0 } +} + +/// A TEXT's groups, or an attribute's (`attrib`, in a file of version `target`) with its +/// subclass: the subclass version (`280 0`, 2010 and later), prompt, tag, flags and alignment, +/// then Lock position (`280`, 2007 and later; readers take the 280 after the tag as it). +/// The insertion point (group 10) to write for `t`. A justified text is placed by its alignment +/// point (group 11), and its insertion point is the start of its baseline, which AutoCAD reads +/// from DWG as is (it recomputes it only for DXF): writing the alignment point there drew centred +/// and right-aligned text from the wrong end in AutoCAD (#1050). +/// +/// The stored point is kept when it is close to where CADCraft's font puts the baseline start: +/// then it is AutoCAD's own (exact for its fonts). It is replaced when it is clearly stale: the +/// alignment point itself (text made here) or a text whose length changed since. Close means +/// along the baseline within a tenth of the text's width plus half its height (AutoCAD's fonts, +/// or the ones CADCraft substitutes for them, differ most in width, and most on short texts) and +/// across it within a fifth of its height: Left Middle and Left Bottom text made here, whose +/// alignment point is only half or a third of a height from the baseline start, must not pass +/// for AutoCAD's. +fn text_insert(d: &Drawing, t: &Text) -> Vec3 { + let computed = computed_text_insert(d, t); + if t.align_pt.is_some_and(|a| a.xy() == t.insert.xy()) { + return computed; + } + let h = t.height.abs(); + let off = (t.insert.xy() - computed.xy()).rotate(-t.rotation); + let close = if t.value.contains('\n') { + // A multiline attribute's lines are placed by its embedded MTEXT: a one-line baseline + // start says little about them, so only the distance counts. + let width = t.align_pt.map_or(0.0, |a| 2.0 * a.xy().dist(computed.xy())); + off.len() <= 0.1 * width + 0.5 * h + } else { + let width = cadcraft_fonts::line_width(&t.value, h, t.width_factor.abs()); + off.x.abs() <= 0.1 * width + 0.5 * h && off.y.abs() <= 0.2 * h + }; + if close { t.insert } else { computed } +} + +fn computed_text_insert(d: &Drawing, t: &Text) -> Vec3 { + let justified = !matches!((t.halign, t.valign), (HAlign::Left, VAlign::Baseline) | (HAlign::Aligned | HAlign::Fit, _)); + let Some(a) = t.align_pt.filter(|_| justified) else { return t.insert }; + let ts = d.text_style(&t.style).cloned().unwrap_or_default(); + let (h, v) = cadcraft_render::text_align(t.halign, t.valign); + let p = cadcraft_fonts::TextParams { + insert: t.insert.xy(), + align_pt: Some(a.xy()), + height: t.height, + rotation: t.rotation, + width_factor: t.width_factor, + oblique: t.oblique, + h, + v, + backwards: ts.backwards, + upside_down: ts.upside_down, + }; + let q = cadcraft_fonts::baseline_start(&cadcraft_fonts::TextFont::resolve(&ts.font), &t.value, &p); + if q.is_finite() { Vec3::new(q.x, q.y, a.z) } else { t.insert } +} + +pub(super) fn text_tags(w: &mut W, d: &Drawing, t: &Text, attrib: Option<(&Attrib, Version)>, attdef_prompt: Option<&str>) { + w.p(10, text_insert(d, t)); + w.f(40, t.height); + w.s(1, &t.value); + if t.rotation != 0.0 { + w.f(50, t.rotation.to_degrees()); + } + if (t.width_factor - 1.0).abs() > 1e-12 { + w.f(41, t.width_factor); + } + if t.oblique != 0.0 { + w.f(51, t.oblique.to_degrees()); + } + w.s(7, &t.style); + let h = match t.halign { + HAlign::Left => 0, + HAlign::Center => 1, + HAlign::Right => 2, + HAlign::Aligned => 3, + HAlign::Middle => 4, + HAlign::Fit => 5, + }; + let v = match t.valign { + VAlign::Baseline => 0, + VAlign::Bottom => 1, + VAlign::Middle => 2, + VAlign::Top => 3, + }; + if h != 0 { + w.i(72, h); + } + if let Some(a) = t.align_pt { + w.p(11, a); + } + match attrib { + Some((a, target)) => { + w.s(100, if attdef_prompt.is_some() { "AcDbAttributeDefinition" } else { "AcDbAttribute" }); + if target >= Version::R2010 { + w.i(280, 0); + } + if let Some(prompt) = attdef_prompt { + w.s(3, prompt); + } + w.s(2, &a.tag); + w.i(70, attrib_flags(a)); + if v != 0 { + w.i(74, v); + } + if target >= Version::R2007 { + w.i(280, i64::from(a.modes.lock_position)); + } + } + None => { + w.s(100, "AcDbText"); + if v != 0 { + w.i(73, v); + } + } + } +} diff --git a/crates/io/src/dxf_write/ctx.rs b/crates/io/src/dxf_write/ctx.rs new file mode 100644 index 000000000..11c679a95 --- /dev/null +++ b/crates/io/src/dxf_write/ctx.rs @@ -0,0 +1,87 @@ +use super::*; + +/// The `DIMASSOC` object of an associative dimension (stored in the dimension's extension +/// dictionary under `ACAD_DIMASSOC`). +pub(super) struct AssocObj { + pub(super) xdict: String, + pub(super) handle: String, + /// Associativity flag (bit per point reference). + pub(super) flags: i64, + pub(super) refs: Vec, +} + +/// An `AcDbOsnapPointRef` of a DIMASSOC object. +pub(super) struct OsnapRef { + pub(super) osnap: i64, + pub(super) main: Handle, + /// The 1-based segment of a polyline the point is an end of (group 91, with subentity type + /// 73 = 2, edge); 0 for an object made of one curve. + pub(super) segment: i64, + pub(super) param: f64, + pub(super) point: Vec3, + pub(super) other: Option, +} + +/// Handles and names the entity writer needs from the rest of the file. +#[derive(Default)] +pub(super) struct Ctx { + /// Multiline styles: MLINESTYLE handles and their dictionary. + pub(super) mlines: crate::dxf_mline::Plan, + /// First viewport of each layout that has no paper-space viewport (id 1) → the layout's index; + /// [`paper_view`] writes that layout's paper-space viewport ahead of it. + pub(super) paper_views: HashMap, + /// Dimension → anonymous `*D` block name. + pub(super) dim_blocks: HashMap, + /// Upper-case text style name → STYLE record handle. + pub(super) styles: HashMap, + /// Upper-case arrow name (as stored in styles/overrides) → BLOCK_RECORD handle. + pub(super) arrows: HashMap, + /// Upper-case linetype name → LTYPE record handle (DIMLTYPE, DIMLTEX1, DIMLTEX2). + pub(super) ltypes: HashMap, + /// Associative dimension → its DIMASSOC object. + pub(super) assoc: HashMap, + /// Table → (anonymous `*T` block name, BLOCK_RECORD handle). + pub(super) tables: HashMap, + /// Table style name → TABLESTYLE handle (the first is the fallback). + pub(super) table_styles: Vec<(String, String)>, + /// Multileader style name → MLEADERSTYLE handle (the first is the fallback). + pub(super) mleader_styles: Vec<(String, String)>, + /// Upper-case block name → BLOCK_RECORD handle (multileader block content). + pub(super) block_records: HashMap, + /// Raster images' IMAGEDEF, IMAGEDEF_REACTOR and dictionary handles. + pub(super) images: crate::dxf_image::Plan, + /// The version the file ends up in (the DWG version for a DWG save): it decides which + /// entity types exist (MULTILEADER from 2007). + pub(super) target: Version, +} + +impl Ctx { + /// Whether multileaders are written as MULTILEADER (AutoCAD 2007 / AC1021 and later); + /// older files get LEADER + MTEXT/INSERT. + pub(super) fn native_mleader(&self) -> bool { + self.target >= Version::R2007 + } + pub(super) fn style(&self, name: &str) -> Option { + self.styles.get(&name.to_ascii_uppercase()).cloned() + } + fn arrow(&self, name: &str) -> Option { + self.arrows.get(&name.trim().to_ascii_uppercase()).cloned() + } + pub(super) fn table_style(&self, name: &str) -> String { + Self::named(&self.table_styles, name) + } + pub(super) fn mleader_style(&self, name: &str) -> String { + Self::named(&self.mleader_styles, name) + } + /// The handle of the style `name` in `styles`, or of the first one. + fn named(styles: &[(String, String)], name: &str) -> String { + styles.iter().find(|(n, _)| n.eq_ignore_ascii_case(name)).or_else(|| styles.first()).map(|(_, h)| h.clone()).unwrap_or_else(|| "0".into()) + } + /// A dimension variable encoded with this file's handles; integers outside the 16-bit + /// range of their groups are dropped. + pub(super) fn encode(&self, kind: K, v: &serde_json::Value) -> Option { + let ltype = |n: &str| self.ltypes.get(&n.trim().to_ascii_uppercase()).cloned(); + dxf_ext::encode(kind, v, &|n| self.style(n), &|n| self.arrow(n), <ype) + .filter(|x| !matches!(x, DimVal::Int(i) if i16::try_from(*i).is_err())) + } +} diff --git a/crates/io/src/dxf_write/entity.rs b/crates/io/src/dxf_write/entity.rs new file mode 100644 index 000000000..6c58cdfac --- /dev/null +++ b/crates/io/src/dxf_write/entity.rs @@ -0,0 +1,723 @@ +use super::blocks::table_size; +use super::common::{attrib_common, common, common_x, dim_xdata, layer_and_props, multiline_tags, oblique_52, text_tags}; +use super::ctx::Ctx; +use super::layout::paper_view; +use super::*; + +/// The persistent reactors of boundary objects: each source object of an associative hatch in +/// the same space or block → the hatches it bounds (AutoCAD links the two both ways; without the +/// reactor the hatch no longer follows its boundary there). +pub(super) fn hatch_reactors(d: &Drawing) -> HashMap> { + let mut out: HashMap> = HashMap::new(); + let stores = std::iter::once(&d.model).chain(d.layouts.iter().map(|l| &l.entities)).chain(d.blocks.values().map(|b| &b.entities)); + for st in stores { + for e in st.iter() { + let EntityKind::Hatch(h) = &e.kind else { continue }; + if !h.associative { + continue; + } + for s in h.loops.iter().flat_map(|l| &l.sources) { + if *s != e.handle && st.contains(*s) { + let r = out.entry(*s).or_default(); + let own = e.handle.hex(); + if !r.contains(&own) { + r.push(own); + } + } + } + } + } + out +} + +pub(super) fn entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: bool, cx: &Ctx) { + match &e.kind { + EntityKind::Line(l) => { + w.s(0, "LINE"); + common(w, e, owner, paper, "AcDbLine"); + thickness(w, e); + w.p(10, l.a); + w.p(11, l.b); + } + EntityKind::Point(p) => { + w.s(0, "POINT"); + common(w, e, owner, paper, "AcDbPoint"); + w.p(10, p.p); + thickness(w, e); + if p.angle != 0.0 { + w.f(50, p.angle.to_degrees()); + } + } + EntityKind::Circle(c) => { + w.s(0, "CIRCLE"); + common(w, e, owner, paper, "AcDbCircle"); + thickness(w, e); + w.p(10, c.center); + w.f(40, c.radius); + } + EntityKind::Arc(a) => { + w.s(0, "ARC"); + common(w, e, owner, paper, "AcDbCircle"); + thickness(w, e); + w.p(10, a.center); + w.f(40, a.radius); + w.s(100, "AcDbArc"); + w.f(50, a.start.to_degrees()); + w.f(51, a.end.to_degrees()); + } + EntityKind::Ellipse(el) => { + w.s(0, "ELLIPSE"); + common(w, e, owner, paper, "AcDbEllipse"); + w.p(10, el.center); + w.p(11, el.major); + w.f(40, el.ratio); + w.f(41, el.start); + w.f(42, el.end); + } + EntityKind::LwPolyline(p) => { + w.s(0, "LWPOLYLINE"); + common(w, e, owner, paper, "AcDbPolyline"); + w.i(90, p.vertices.len() as i64); + w.i(70, i64::from(p.closed) | if p.plinegen { 128 } else { 0 }); + if p.const_width > 0.0 { + w.f(43, p.const_width); + } + if p.elevation != 0.0 { + w.f(38, p.elevation); + } + thickness(w, e); + for v in &p.vertices { + w.p2(10, v.p); + if v.start_width != 0.0 || v.end_width != 0.0 { + w.f(40, v.start_width); + w.f(41, v.end_width); + } + if v.bulge != 0.0 { + w.f(42, v.bulge); + } + } + } + EntityKind::MLine(m) => { + w.s(0, "MLINE"); + common(w, e, owner, paper, "AcDbMline"); + w.t.extend(crate::dxf_mline::entity_tags(m, &cx.mlines.handle(&m.style))); + } + EntityKind::Polyline3d(p) => { + w.s(0, "POLYLINE"); + common(w, e, owner, paper, "AcDb3dPolyline"); + w.i(66, 1); + w.p(10, Vec3::ZERO); + thickness(w, e); + w.i(70, 8 | i64::from(p.closed)); + for q in &p.points { + let vh = w.h(); + w.s(0, "VERTEX"); + w.s(5, vh); + w.s(330, e.handle.hex()); + w.s(100, "AcDbEntity"); + w.s(8, &e.common.layer); + w.s(100, "AcDbVertex"); + w.s(100, "AcDb3dPolylineVertex"); + w.p(10, *q); + w.i(70, 32); + } + let sh = w.h(); + w.s(0, "SEQEND"); + w.s(5, sh); + w.s(330, e.handle.hex()); + w.s(100, "AcDbEntity"); + w.s(8, &e.common.layer); + } + EntityKind::Spline(s) => { + w.s(0, "SPLINE"); + common(w, e, owner, paper, "AcDbSpline"); + let flags = 8 | i64::from(s.closed) | if s.is_periodic() { 2 } else { 0 } | if s.weights.is_empty() { 0 } else { 4 }; + w.i(70, flags); + w.i(71, s.degree as i64); + w.i(72, s.knots.len() as i64); + w.i(73, s.control.len() as i64); + w.i(74, s.fit.len() as i64); + w.f(42, 1e-10); + w.f(43, 1e-10); + // Fit data: tolerance and end tangents (the knot parametrisation is implied by the knots). + if !s.fit.is_empty() { + w.f(44, s.fit_opts.tolerance); + if let Some(t) = s.fit_opts.start_tangent { + w.p(12, t.to3(0.0)); + } + if let Some(t) = s.fit_opts.end_tangent { + w.p(13, t.to3(0.0)); + } + } + for k in &s.knots { + w.f(40, *k); + } + for (i, c) in s.control.iter().enumerate() { + w.p(10, c.to3(0.0)); + if let Some(wt) = s.weights.get(i) { + w.f(41, *wt); + } + } + for f in &s.fit { + w.p(11, f.to3(0.0)); + } + } + EntityKind::Helix(h) => { + // A cubic spline through points on the helix, then the helix data (DXF Reference). + w.s(0, "HELIX"); + common(w, e, owner, paper, "AcDbSpline"); + let (s, z) = h.spline3(); + w.i(70, 0); + w.i(71, s.degree as i64); + w.i(72, s.knots.len() as i64); + w.i(73, s.control.len() as i64); + w.i(74, 0); + w.f(42, 1e-10); + w.f(43, 1e-10); + for k in &s.knots { + w.f(40, *k); + } + for (c, z) in s.control.iter().zip(&z) { + w.p(10, c.to3(*z)); + } + w.s(100, "AcDbHelix"); + w.i(90, 29); + w.i(91, 63); + w.p(10, h.center); + w.p(11, h.start_point()); + w.p(12, Vec3::Z); + w.f(40, h.base_radius); + w.f(41, h.turns); + w.f(42, h.turn_height()); + w.i(290, i64::from(h.ccw)); + w.i(280, h.constrain.to_dxf()); + } + EntityKind::Ray(r) | EntityKind::XLine(r) => { + let ray = matches!(e.kind, EntityKind::Ray(_)); + w.s(0, if ray { "RAY" } else { "XLINE" }); + common(w, e, owner, paper, if ray { "AcDbRay" } else { "AcDbXline" }); + w.p(10, r.base); + w.p(11, r.dir); + } + EntityKind::Text(t) => { + w.s(0, "TEXT"); + common(w, e, owner, paper, "AcDbText"); + thickness(w, e); + text_tags(w, d, t, None, None); + } + // Closed polylines, one per loop (`dxf_region`): REGION's own data is ACIS. + EntityKind::Region(r) => { + for (i, tags) in crate::dxf_region::polylines(r).into_iter().enumerate() { + w.s(0, "LWPOLYLINE"); + if i == 0 { + common(w, e, owner, paper, "AcDbPolyline"); + } else { + // The other loops get handles of their own (only the handle and properties are written). + let h = Handle::parse_hex(&w.h()).unwrap_or(e.handle); + let part = Entity { handle: h, common: e.common.clone(), kind: EntityKind::Point(Point { p: Vec3::ZERO, angle: 0.0 }) }; + common(w, &part, owner, paper, "AcDbPolyline"); + } + w.t.extend(tags); + } + } + EntityKind::AttDef(a) => { + w.s(0, "ATTDEF"); + common(w, e, owner, paper, "AcDbText"); + text_tags(w, d, &a.text, Some((a, cx.target)), Some(&a.prompt)); + if cx.target >= Version::R2007 { + multiline_tags(w, a, true); + } + } + EntityKind::MText(t) => { + w.s(0, "MTEXT"); + common(w, e, owner, paper, "AcDbMText"); + w.p(10, t.insert); + w.f(40, t.height); + w.f(41, t.width); + w.i(71, i64::from(t.attach)); + w.i(72, 1); + // Long contents go in 250-char 3-chunks, the remainder in 1. + let chars: Vec = crate::dxf_read::mtext_dxf::encode_contents(&t.contents).chars().collect(); + let chunks: Vec = chars.chunks(250).map(|c| c.iter().collect()).collect(); + let n = chunks.len(); + for (i, c) in chunks.iter().enumerate() { + w.s(if i + 1 == n { 1 } else { 3 }, c.clone()); + } + if n == 0 { + w.s(1, ""); + } + w.s(7, &t.style); + if t.rotation != 0.0 { + w.f(50, t.rotation.to_degrees()); + } + // Line spacing style: 1 = at least, 2 = exactly. + w.i(73, if t.line_spacing_exact { 2 } else { 1 }); + w.f(44, t.line_spacing); + w.t.extend(crate::dxf_read::mtext_dxf::column_tags(t)); + } + EntityKind::Insert(i) => { + w.s(0, "INSERT"); + common(w, e, owner, paper, "AcDbBlockReference"); + if !i.attribs.is_empty() { + w.i(66, 1); + } + w.s(2, &i.block); + w.p(10, i.insert); + w.f(41, i.scale.x); + w.f(42, i.scale.y); + w.f(43, i.scale.z); + w.f(50, i.rotation.to_degrees()); + if i.cols > 1 || i.rows > 1 { + w.i(70, i64::from(i.cols)); + w.i(71, i64::from(i.rows)); + w.f(44, i.col_spacing); + w.f(45, i.row_spacing); + } + if !i.attribs.is_empty() { + for a in &i.attribs { + let ah = w.h(); + w.s(0, "ATTRIB"); + w.s(5, ah); + w.s(330, e.handle.hex()); + w.s(100, "AcDbEntity"); + let (c, base) = attrib_common(d, e, i, a); + layer_and_props(w, &c, Some(&base)); + // Hidden with its definition (a dynamic block's visibility state hides it). + if !base.visible { + w.i(60, 1); + } + w.s(100, "AcDbText"); + text_tags(w, d, &a.text, Some((a, cx.target)), None); + if cx.target >= Version::R2007 { + multiline_tags(w, a, false); + } + } + let sh = w.h(); + w.s(0, "SEQEND"); + w.s(5, sh); + w.s(330, e.handle.hex()); + w.s(100, "AcDbEntity"); + w.s(8, &e.common.layer); + } + } + EntityKind::Dimension(dm) => { + w.s(0, "DIMENSION"); + common_x(w, e, owner, paper, "AcDbDimension", cx.assoc.get(&e.handle)); + let bname = cx.dim_blocks.get(&e.handle).cloned().or_else(|| dm.block.clone()).unwrap_or_default(); + w.s(2, bname); + w.p(10, dm.defpt); + let style = d.dim_style(&dm.style).cloned().unwrap_or_default(); + let g = cadcraft_render::dimension_geometry(dm, &style, d.header.f64("DIMSCALE", 1.0)); + w.p(11, if dm.user_text_pos { dm.text_mid } else { g.text_pos.to3(0.0) }); + let ty: i64 = match dm.kind { + DimKind::Linear { .. } => 0, + DimKind::Aligned => 1, + DimKind::Angular => 2, + DimKind::Diameter => 3, + DimKind::Radius => 4, + DimKind::Angular3P => 5, + DimKind::Ordinate { x_type } => 6 | if x_type { 64 } else { 0 }, + // Written as aligned, marked by CADCraft xdata (see `dim_xdata`). + DimKind::ArcLength => 1, + }; + w.i(70, ty | 32 | if dm.user_text_pos { 128 } else { 0 }); + // The actual measurement (read-only in DXF): AutoLISP's entget shows it. + w.f(42, g.measurement); + if !dm.text.is_empty() { + w.s(1, &dm.text); + } + w.s(3, &dm.style); + if dm.text_rotation != 0.0 { + w.f(53, dm.text_rotation.to_degrees()); + } + match dm.kind { + DimKind::Linear { rotation } => { + w.s(100, "AcDbAlignedDimension"); + w.p(13, dm.p13); + w.p(14, dm.p14); + w.f(50, rotation.to_degrees()); + oblique_52(w, dm, rotation); + w.s(100, "AcDbRotatedDimension"); + } + DimKind::Aligned | DimKind::ArcLength => { + w.s(100, "AcDbAlignedDimension"); + w.p(13, dm.p13); + w.p(14, dm.p14); + if matches!(dm.kind, DimKind::Aligned) { + oblique_52(w, dm, (dm.p14.xy() - dm.p13.xy()).angle()); + } + } + DimKind::Radius => { + w.s(100, "AcDbRadialDimension"); + w.p(15, dm.p15); + w.f(40, 0.0); + } + DimKind::Diameter => { + w.s(100, "AcDbDiametricDimension"); + w.p(15, dm.p15); + w.f(40, 0.0); + } + DimKind::Angular => { + w.s(100, "AcDb2LineAngularDimension"); + w.p(13, dm.p13); + w.p(14, dm.p14); + w.p(15, dm.p15); + w.p(16, dm.p16); + } + DimKind::Angular3P => { + w.s(100, "AcDb3PointAngularDimension"); + w.p(13, dm.p13); + w.p(14, dm.p14); + w.p(15, dm.p15); + } + DimKind::Ordinate { .. } => { + w.s(100, "AcDbOrdinateDimension"); + w.p(13, dm.p13); + w.p(14, dm.p14); + } + } + dim_xdata(w, dm, cx); + } + EntityKind::Table(t) => { + // Tables without a generated block (none expected) are not written. + let Some((bname, brh)) = cx.tables.get(&e.handle) else { return }; + w.s(0, "ACAD_TABLE"); + common(w, e, owner, paper, "AcDbBlockReference"); + w.s(2, bname); + w.p(10, t.insert); + w.s(100, "AcDbTable"); + w.i(280, 0); + w.s(342, cx.table_style(&t.style)); + w.s(343, brh); + w.p(11, Vec3::new(1.0, 0.0, 0.0)); + w.i(90, 0); + let (rows, cols) = table_size(t); + w.i(91, rows as i64); + w.i(92, cols as i64); + for c in [93, 94, 95, 96] { + w.i(c, 0); + } + for h in t.row_heights.iter().take(rows) { + w.f(141, *h); + } + for c in t.col_widths.iter().take(cols) { + w.f(142, *c); + } + let cov = cadcraft_render::table_covered(t); + let span = |v: u32| i64::from(v.min(i16::MAX as u32)); + for r in 0..rows { + for c in 0..cols { + let cell = t.cells.get(r).and_then(|x| x.get(c)); + let covered = cov.get(r).and_then(|x| x.get(c)).copied().unwrap_or(false); + let merge = cell.and_then(|x| x.merged).filter(|&(a, b)| a >= 1 && b >= 1 && (a, b) != (1, 1)); + w.i(171, 1); + w.i(172, 0); + w.i(173, i64::from(covered || merge.is_some())); + w.i(174, 0); + let (rs, cs) = if covered { (0, 0) } else { merge.map(|(a, b)| (span(a), span(b))).unwrap_or((1, 1)) }; + w.i(175, cs); + w.i(176, rs); + w.i(91, 0); + w.i(178, 0); + w.f(145, 0.0); + if let Some(h) = cell.and_then(|x| x.text_height).filter(|h| h.is_finite() && *h > 0.0) { + w.f(140, h); + } + // Long text: 250-character code-2 chunks, the rest in code 1. + let chars: Vec = cell.map(|x| x.text.chars().collect()).unwrap_or_default(); + let chunks: Vec = chars.chunks(250).map(|c| c.iter().collect()).collect(); + let n = chunks.len(); + for (i, ch) in chunks.into_iter().enumerate() { + w.s(if i + 1 == n { 1 } else { 2 }, ch); + } + if n == 0 { + w.s(1, ""); + } + } + } + w.s(1001, dxf_ext::APP); + w.s(1000, "TABLE"); + w.i(1070, i64::from(t.title)); + w.i(1070, i64::from(t.header)); + w.f(1040, t.text_height); + w.s(1000, &t.style); + for c in crate::dxf_table::table_xdata(t) { + w.s(1000, c); + } + } + EntityKind::MLeader(m) if !cx.native_mleader() => mleader_fallback(w, d, e, m, owner, paper, cx), + EntityKind::MLeader(m) => { + w.s(0, "MULTILEADER"); + common(w, e, owner, paper, "AcDbMLeader"); + let refs = crate::dxf_mleader::Refs { + style: cx.mleader_style(&m.style), + text_style: m.text.as_ref().and_then(|t| cx.style(&t.style)), + block: m.block.as_ref().and_then(|b| cx.block_records.get(&b.block.to_ascii_uppercase()).cloned()), + }; + w.t.extend(crate::dxf_mleader::entity_tags(d, m, &refs)); + } + EntityKind::Leader(l) => { + w.s(0, "LEADER"); + common(w, e, owner, paper, "AcDbLeader"); + w.s(3, &l.style); + w.i(71, i64::from(l.arrow)); + w.i(72, i64::from(l.spline)); + w.i(76, l.vertices.len() as i64); + for v in &l.vertices { + w.p(10, *v); + } + } + EntityKind::Image(i) => { + // Every image has its IMAGEDEF (planned in `write`); the fallback never happens. + let Some((def, reactor)) = cx.images.by_entity.get(&e.handle) else { return }; + w.s(0, "IMAGE"); + common(w, e, owner, paper, "AcDbRasterImage"); + w.t.extend(crate::dxf_image::entity_tags(i, def, reactor)); + } + EntityKind::Solid(s) | EntityKind::Trace(s) => { + let solid = matches!(e.kind, EntityKind::Solid(_)); + w.s(0, if solid { "SOLID" } else { "TRACE" }); + common(w, e, owner, paper, "AcDbTrace"); + w.p(10, s.corners[0]); + w.p(11, s.corners[1]); + w.p(12, s.corners[2]); + w.p(13, s.corners[3]); + thickness(w, e); + } + EntityKind::Face3d(f) => { + w.s(0, "3DFACE"); + common(w, e, owner, paper, "AcDbFace"); + w.p(10, f.corners[0]); + w.p(11, f.corners[1]); + w.p(12, f.corners[2]); + w.p(13, f.corners[3]); + w.i(70, i64::from(f.hidden_edges)); + } + EntityKind::Hatch(h) => { + w.s(0, "HATCH"); + common(w, e, owner, paper, "AcDbHatch"); + w.p(10, Vec3::new(0.0, 0.0, h.elevation)); + w.p(210, Vec3::Z); + w.s(2, &h.pattern); + w.i(70, i64::from(h.solid)); + w.i(71, i64::from(h.associative)); + w.i(91, h.loops.len() as i64); + for l in &h.loops { + w.i(92, 2 | if l.outer { 1 } else { 0 }); + let has_bulge = l.vertices.iter().any(|v| v.bulge != 0.0); + w.i(72, i64::from(has_bulge)); + w.i(73, 1); + w.i(93, l.vertices.len() as i64); + for v in &l.vertices { + w.p2(10, v.p); + if has_bulge { + w.f(42, v.bulge); + } + } + // Source boundary objects of an associative hatch (the ones still beside it, in + // its space or block, which carry the hatch as a reactor). + let own = e.handle.hex(); + let sources: Vec = + l.sources.iter().filter(|s| h.associative && w.reactors.get(*s).is_some_and(|r| r.contains(&own))).map(|s| s.hex()).collect(); + w.i(97, sources.len() as i64); + for s in &sources { + w.s(330, s); + } + } + w.i(75, i64::from(h.style)); + w.i(76, 1); + if !h.solid { + w.f(52, h.angle.to_degrees()); + w.f(41, h.scale); + w.i(77, 0); + // The lines read from the file, else the library's pattern of that name. + let lines = if h.pattern_lines.is_empty() { + cadcraft_doc::library::pattern_for(d, &h.pattern).map(|p| p.lines).unwrap_or_default() + } else { + h.pattern_lines.clone() + }; + w.i(78, lines.len() as i64); + for pl in lines { + let ang = pl.angle.to_radians() + h.angle; + w.f(53, ang.to_degrees()); + let o = h.origin + Vec2::new(pl.origin.0, pl.origin.1).rotate(h.angle) * h.scale; + w.f(43, o.x); + w.f(44, o.y); + let dl = Vec2::new(pl.delta.0, pl.delta.1).rotate(ang) * h.scale; + w.f(45, dl.x); + w.f(46, dl.y); + w.i(79, pl.dashes.len() as i64); + for dsh in pl.dashes { + w.f(49, dsh * h.scale); + } + } + } + w.i(98, 0); + if let Some(g) = &h.gradient { + gradient(w, g); + } + hatch_xdata(w, h); + } + EntityKind::Viewport(v) => { + paper_view(w, d, e, owner, cx); + w.s(0, "VIEWPORT"); + common(w, e, owner, paper, "AcDbViewport"); + w.p(10, v.center); + w.f(40, v.width); + w.f(41, v.height); + // Status 68: a positive value is "on" (the stacking order; 1 is the active viewport), 0 off. + w.i(68, if v.on { i64::from(v.id.clamp(1, 32767)) } else { 0 }); + w.i(69, i64::from(v.id)); + w.p2(12, v.view_center); + w.f(45, v.view_height); + // Status flags: 16384 = display locked, 32768 = always set, 65536 = clipped by the + // 340 entity, 131072 = off. + let clip = v.clip.filter(|h| *h != e.handle && d.entity(*h).is_some()); + let flags = 32768 | if v.locked { 16384 } else { 0 } | if clip.is_some() { 65536 } else { 0 } | if v.on { 0 } else { 131072 }; + w.i(90, flags); + if let Some(h) = clip { + w.s(340, h.hex()); + } + // AutoCAD's frozen-layer list: one soft pointer per layer, to its LAYER record. Other + // programs read only this; CADCraft prefers its own xdata below when present. + for name in &v.frozen_layers { + if let Some(h) = w.layers.get(&name.to_ascii_uppercase()).cloned() { + w.s(341, h); + } + } + // One CADCraft xdata group holds both lists: readers stop at the first group of an app. + if !v.frozen_layers.is_empty() || !v.layer_colors.is_empty() { + w.s(1001, dxf_ext::APP); + if !v.frozen_layers.is_empty() { + w.xdata(dxf_ext::frozen_xdata(&v.frozen_layers)); + } + if !v.layer_colors.is_empty() { + w.xdata(dxf_ext::layer_colors_xdata(&v.layer_colors)); + } + } + } + EntityKind::Wipeout(wo) => { + // A 1x1 image spanning the boundary's extents. Clip vertices are in pixel space, whose origin is + // the image's top-left corner with y pointing down: the reader maps each vertex back through + // insert + u * (x + 0.5) + v * (0.5 - y). + // Polygonal clip boundaries are closed, so the first vertex is repeated when needed. + let b = Bounds2::from_points(wo.boundary.iter().copied()); + let o = if b.is_empty() { Vec2::ZERO } else { b.min }; + let (sx, sy) = (if b.width() > 0.0 { b.width() } else { 1.0 }, if b.height() > 0.0 { b.height() } else { 1.0 }); + w.s(0, "WIPEOUT"); + common(w, e, owner, paper, "AcDbWipeout"); + w.i(90, 0); + w.p(10, Vec3::new(o.x, o.y, 0.0)); + w.p(11, Vec3::new(sx, 0.0, 0.0)); + w.p(12, Vec3::new(0.0, sy, 0.0)); + w.p2(13, Vec2::new(1.0, 1.0)); + // Display flags 7 = show image, show unaligned, use clipping boundary; clipping on; default brightness/contrast/fade. + w.i(70, 7); + w.i(280, 1); + w.i(281, 50); + w.i(282, 50); + w.i(283, 0); + w.i(71, 2); + let mut pts = wo.boundary.clone(); + if let (Some(first), Some(last)) = (pts.first().copied(), pts.last().copied()) + && first != last + { + pts.push(first); + } + w.i(91, pts.len() as i64); + for q in pts { + w.p2(14, Vec2::new((q.x - o.x) / sx - 0.5, 0.5 - (q.y - o.y) / sy)); + } + } + EntityKind::Tolerance(t) => { + w.s(0, "TOLERANCE"); + common(w, e, owner, paper, "AcDbFcf"); + w.s(3, &t.style); + w.p(10, t.insert); + w.s(1, &t.text); + w.p(11, t.dir); + } + // Entity types CADCraft doesn't model aren't written; the save command reports them. + EntityKind::Unknown(_) => {} + } +} + +/// A multileader for a file older than 2007 (no MULTILEADER): a LEADER per leader line, ending +/// at the landing, plus its MTEXT or its block's INSERT, with the multileader's properties. +fn mleader_fallback(w: &mut W, d: &Drawing, e: &Entity, m: &MLeader, owner: &str, paper: bool, cx: &Ctx) { + let part = |w: &mut W, kind: EntityKind| Entity { handle: Handle::parse_hex(&w.h()).unwrap_or(Handle(0)), common: e.common.clone(), kind }; + let style = d.header.str("DIMSTYLE", "Standard"); + for arm in m.leaders.iter().take(64) { + let mut vertices = arm.clone(); + vertices.push(m.landing); + let le = part(w, EntityKind::Leader(Leader { vertices, arrow: true, spline: m.spline, style: style.clone() })); + entity(w, d, &le, owner, paper, cx); + } + if let Some(t) = &m.text { + let te = part(w, EntityKind::MText(t.clone())); + entity(w, d, &te, owner, paper, cx); + } + if let Some(ins) = &m.block { + let ie = part(w, EntityKind::Insert(ins.clone())); + entity(w, d, &ie, owner, paper, cx); + } +} + +/// A hatch's gradient fill (DXF Reference, HATCH group codes 450–470): two-colour gradient +/// with its rotation (radians), shift (0 = centered, 1 = shifted) and per-colour 463 records +/// carrying ACI (63) and, for true colours, the RGB value (421). Those groups belong to R2004+ +/// files, so the same gradient also travels as `CADCRAFT` xdata, which survives this R2000 +/// file's conversion to DWG (where the native fields don't exist). +fn gradient(w: &mut W, g: &Gradient) { + w.i(450, 1); + w.i(451, 0); + w.f(460, g.angle); + w.f(461, if g.centered { 0.0 } else { 1.0 }); + w.i(452, 0); + w.f(462, 0.0); + w.i(453, 2); + for (k, c) in [(0.0, g.color1), (1.0, g.color2)] { + w.f(463, k); + match c { + Color::True(rgb) => { + w.i(63, i64::from(cadcraft_color::nearest_aci(rgb))); + w.i(421, i64::from(rgb.to_u32())); + } + c => w.i(63, i64::from(c.to_aci())), + } + } + w.s(470, &g.name); + w.s(1001, dxf_ext::APP); + w.s(1000, "GRADIENT"); + // A 1000 group holds at most 255 bytes; cut at a character boundary. + let cut = g.name.char_indices().map(|(i, c)| i + c.len_utf8()).take_while(|end| *end <= 255).last().unwrap_or(0); + w.s(1000, g.name.get(..cut).unwrap_or_default()); + w.f(1040, g.angle); + w.i(1070, i64::from(g.centered)); + w.s(1000, g.color1.name()); + w.s(1000, g.color2.name()); +} + +/// Group 39 (thickness) of the entities whose DXF Reference record has one; only when set. +fn thickness(w: &mut W, e: &Entity) { + if e.common.thickness != 0.0 { + w.f(39, e.common.thickness); + } +} + +/// Hatch origin and background colour as `CADCRAFT` xdata: `1000 HATCH`, the origin as a +/// world position (`1011`, moved with the hatch by other programs) and the background colour +/// name (`1000`, empty for none). The DXF Reference has no HATCH groups for either: other +/// readers get the origin through the pattern lines' base points (43/44), the background isn't +/// carried. Appended to the gradient's `CADCRAFT` xdata when there is one. +fn hatch_xdata(w: &mut W, h: &Hatch) { + if h.origin == Vec2::ZERO && h.background.is_none() { + return; + } + if h.gradient.is_none() { + w.s(1001, dxf_ext::APP); + } + w.s(1000, "HATCH"); + w.p(1011, h.origin.to3(0.0)); + w.s(1000, h.background.map(Color::name).unwrap_or_default()); +} diff --git a/crates/io/src/dxf_write/header.rs b/crates/io/src/dxf_write/header.rs new file mode 100644 index 000000000..edd62eb13 --- /dev/null +++ b/crates/io/src/dxf_write/header.rs @@ -0,0 +1,143 @@ +use super::*; + +/// Header variables we write, with their group codes (DXF Reference, HEADER section). +/// `P2`/`P3` = 2D/3D point. +const HEADER_VARS: &[(&str, i32)] = &[ + ("INSBASE", -3), + ("LIMMIN", -2), + ("LIMMAX", -2), + ("SNAPUNIT", -2), + ("GRIDUNIT", -2), + ("ORTHOMODE", 70), + ("FILLMODE", 70), + ("MIRRTEXT", 70), + ("LTSCALE", 40), + ("ATTMODE", 70), + ("TEXTSIZE", 40), + ("TRACEWID", 40), + ("CMLSTYLE", 2), + ("CMLJUST", 70), + ("CMLSCALE", 40), + ("TEXTSTYLE", 7), + ("CLAYER", 8), + ("CELTYPE", 6), + ("CECOLOR", 62), + ("CELTSCALE", 40), + ("DIMSCALE", 40), + ("DIMSTYLE", 2), + ("LUNITS", 70), + ("LUPREC", 70), + ("AUNITS", 70), + ("AUPREC", 70), + ("ELEVATION", 40), + ("THICKNESS", 40), + ("FILLETRAD", 40), + ("CHAMFERA", 40), + ("CHAMFERB", 40), + ("ANGBASE", 50), + ("ANGDIR", 70), + ("PDMODE", 70), + ("PDSIZE", 40), + ("PLINEWID", 40), + ("CELWEIGHT", 370), + ("LWDISPLAY", 290), + ("INSUNITS", 70), + ("MEASUREMENT", 70), + ("PSLTSCALE", 70), +]; + +/// The current dimension style's variables as header variables (`$DIMTXT`, `$DIMDEC`…), after +/// `$DIMSTYLE`: AutoCAD draws new and updated dimensions with the header's DIM* values, which +/// otherwise stay its defaults (0.18 text, 4 decimals) whatever the current style says (#1051). +/// `$DIMSCALE` is a header variable of its own. +fn dim_header_vars(w: &mut W, d: &Drawing) { + let name = d.header.str("DIMSTYLE", "Standard"); + let Some(style) = d.dim_styles.iter().find(|s| s.name.eq_ignore_ascii_case(&name)) else { return }; + let v = serde_json::to_value(style).unwrap_or_default(); + let none = |_: &str| None; + for (var, field) in cadcraft_doc::DIMVARS { + if *var == "DIMSCALE" { + continue; + } + let (Some(value), Some((_, _, kind))) = (v.get(*field), dxf_ext::DIM_CODES.iter().find(|(f, _, _)| f == field)) else { continue }; + match kind { + // Names, not handles, in the header. + K::TextStyle | K::Block => { + let Some(n) = value.as_str() else { continue }; + if *kind == K::TextStyle && n.trim().is_empty() { + continue; + } + w.s(9, format!("${var}")); + w.s(if *kind == K::TextStyle { 7 } else { 1 }, n); + } + K::Linetype => {} + _ => match dxf_ext::encode(*kind, value, &none, &none, &none) { + Some(DimVal::Real(x)) => { + w.s(9, format!("${var}")); + w.f(40, x); + } + Some(DimVal::Int(i)) if i16::try_from(i).is_ok() => { + w.s(9, format!("${var}")); + w.i(70, i); + } + Some(DimVal::Str(t)) => { + w.s(9, format!("${var}")); + w.s(1, t); + } + _ => {} + }, + } + } +} + +pub(super) fn header_vars(w: &mut W, d: &Drawing) { + for (name, code) in HEADER_VARS { + let Some(v) = d.header.get(name) else { continue }; + match (*code, v) { + (-2 | -3, HVal::Point(p)) => { + let p = match *name { + "SNAPUNIT" => d.header.snap_unit().to3(0.0), + "GRIDUNIT" => d.header.grid_unit().to3(0.0), + _ => *p, + }; + w.s(9, format!("${name}")); + w.f(10, p.x); + w.f(20, p.y); + if *code == -3 { + w.f(30, p.z); + } + } + (-2 | -3, _) => {} + (c @ (40 | 50), v) => { + if let Some(f) = v.as_f64() { + // ANGBASE is held in radians; `$ANGBASE` is in degrees. + let f = if *name == "ANGBASE" { f.to_degrees() } else { f }; + w.s(9, format!("${name}")); + w.f(c, f); + } + } + (c @ (62 | 70 | 290 | 370), v) => { + if let Some(i) = v.as_i64() { + w.s(9, format!("${name}")); + w.i(c, i); + } + } + (c, v) => { + if let Some(t) = v.as_str() { + w.s(9, format!("${name}")); + w.s(c, t); + } + } + } + } + dim_header_vars(w, d); +} + +/// `$DWGCODEPAGE`: before 2007 the code page read from the file when CADCraft knows it (the text +/// is ASCII with `\U+XXXX` escapes, right in any code page, but the file keeps its declared one); +/// otherwise, and from 2007 (UTF-8 text), `ANSI_1252`. +pub(super) fn code_page(d: &Drawing, version: Version) -> String { + let read = d.header.get("DWGCODEPAGE").and_then(HVal::as_str).map(str::trim).unwrap_or_default(); + let known = read.is_ascii() && read.len() <= 32 && cadcraft_dxf::decode_code_page(b"", read).is_some(); + if version < Version::R2007 && known { read.to_string() } else { "ANSI_1252".into() } +} diff --git a/crates/io/src/dxf_write/layout.rs b/crates/io/src/dxf_write/layout.rs new file mode 100644 index 000000000..ccffa2929 --- /dev/null +++ b/crates/io/src/dxf_write/layout.rs @@ -0,0 +1,121 @@ +use super::ctx::Ctx; +use super::*; + +/// Layouts with viewports but without the paper-space viewport (id 1, the sheet itself), keyed +/// by their first viewport. AutoCAD and DWG take a layout's first viewport as its paper-space +/// viewport (DWG does not store viewport ids; readers number them in order), so a layout made in +/// CADCraft, whose paper-space viewport is implicit, gets one written ahead of its viewports. +pub(super) fn paper_views(d: &Drawing) -> HashMap { + let mut out = HashMap::new(); + 'layouts: for (i, l) in d.layouts.iter().enumerate() { + let mut first = None; + for e in l.entities.iter() { + if let EntityKind::Viewport(v) = &e.kind { + if v.id == 1 { + continue 'layouts; + } + first.get_or_insert(e.handle); + } + } + if let Some(h) = first { + out.insert(h, i); + } + } + out +} + +/// A layout's entities with its paper-space viewport (id 1) first. DWG stores no viewport ids and +/// readers number a layout's viewports in order, so the sheet must lead even when the draw order +/// (SORTENTS) put it behind a model viewport; otherwise that viewport becomes the sheet. +pub(super) fn sheet_first(l: &Layout) -> Vec<&Entity> { + let sheet = |e: &&Entity| matches!(&e.kind, EntityKind::Viewport(v) if v.id == 1); + let all = || l.entities.iter().map(|e| e.as_ref()); + all().filter(sheet).chain(all().filter(|e| !sheet(e))).collect() +} + +/// The paper-space viewport (id 1) of the layout whose first viewport is `e`, if it needs one +/// ([`paper_views`]): the whole sheet, viewed 1:1. Marked with CADCraft xdata so that our reader +/// leaves it implicit again. +pub(super) fn paper_view(w: &mut W, d: &Drawing, e: &Entity, owner: &str, cx: &Ctx) { + let Some(l) = cx.paper_views.get(&e.handle).and_then(|i| d.layouts.get(*i)) else { return }; + let size = cadcraft_render::paper::sheet(d, &l.name).map(|s| s.size).unwrap_or(Vec2::new(12.0, 9.0)); + let center = size * 0.5; + let h = w.h(); + w.s(0, "VIEWPORT"); + w.s(5, h); + w.s(330, owner); + w.s(100, "AcDbEntity"); + w.i(67, 1); + w.s(8, "0"); + w.s(100, "AcDbViewport"); + w.p(10, center.to3(0.0)); + w.f(40, size.x); + w.f(41, size.y); + w.i(68, 1); + w.i(69, 1); + w.p2(12, center); + w.f(45, size.y); + w.i(90, 32768); + w.s(1001, dxf_ext::APP); + w.s(1000, dxf_ext::PAPER_VIEW); +} + +/// The VPORT table: the `*Active` model space tiles (one viewport on the extents when there is no +/// tiled configuration) and the saved configurations, each tile a record, the current one first. +pub(super) fn vport_table(w: &mut W, d: &Drawing, ext: Bounds2) { + let single = VportTile { + min: Vec2::ZERO, + max: Vec2::new(1.0, 1.0), + center: if ext.is_empty() { Vec2::new(6.0, 4.5) } else { ext.center() }, + height: if ext.is_empty() { 9.0 } else { (ext.height() * 1.1).max(1e-6) }, + aspect: 1.6, + }; + let tiled = d.vports.iter().find(|c| c.is_active() && c.tiles.len() > 1); + let mut records: Vec<(&str, &VportTile)> = match tiled { + Some(c) => c.tiles.iter().map(|t| (VportConfig::ACTIVE, t)).collect(), + None => vec![(VportConfig::ACTIVE, &single)], + }; + records.extend(d.vports.iter().filter(|c| !c.is_active() && !c.name.is_empty()).flat_map(|c| c.tiles.iter().map(|t| (c.name.as_str(), t)))); + let th = table_head(w, "VPORT", records.len()); + let finite = |p: Vec2, or: Vec2| if p.is_finite() { p } else { or }; + for (name, t) in records { + record_head(w, "VPORT", &th, "AcDbViewportTableRecord"); + w.s(2, name); + w.i(70, 0); + w.p2(10, finite(t.min, Vec2::ZERO)); + w.p2(11, finite(t.max, Vec2::new(1.0, 1.0))); + w.p2(12, finite(t.center, Vec2::ZERO)); + // Snap grid origin and rotation (SNAPBASE, SNAPANG in degrees): R13+ files keep them here. + w.p2(13, d.header.point("SNAPBASE").map(|p| p.xy()).filter(|p| p.is_finite()).unwrap_or(Vec2::ZERO)); + w.p2(14, d.header.snap_unit()); + w.p2(15, d.header.grid_unit()); + w.p(16, Vec3::Z); + w.p(17, Vec3::ZERO); + w.f(40, if t.height.is_finite() && t.height > 0.0 { t.height } else { 1.0 }); + w.f(41, if t.aspect.is_finite() && t.aspect > 0.0 { t.aspect } else { 1.0 }); + w.f(42, 50.0); + w.f(50, d.header.f64("SNAPANG", 0.0)); + } + w.s(0, "ENDTAB"); +} + +pub(super) fn table_head(w: &mut W, name: &str, count: usize) -> String { + let h = w.h(); + w.s(0, "TABLE"); + w.s(2, name); + w.s(5, h.clone()); + w.s(330, "0"); + w.s(100, "AcDbSymbolTable"); + w.i(70, count as i64); + h +} + +pub(super) fn record_head(w: &mut W, kind: &str, owner: &str, subclass: &str) -> String { + let h = w.h(); + w.s(0, kind); + w.s(if kind == "DIMSTYLE" { 105 } else { 5 }, h.clone()); + w.s(330, owner); + w.s(100, "AcDbSymbolTableRecord"); + w.s(100, subclass); + h +} diff --git a/crates/io/src/dxf_write/objects.rs b/crates/io/src/dxf_write/objects.rs new file mode 100644 index 000000000..2227ae096 --- /dev/null +++ b/crates/io/src/dxf_write/objects.rs @@ -0,0 +1,138 @@ +use super::*; + +/// A TABLESTYLE object (DXF Reference, OBJECTS: TABLESTYLE): margins, title/header +/// suppression and per row type (data, column header, title) text and border settings. +pub(super) fn table_style_obj(w: &mut W, d: &Drawing, s: &TableStyle, h: &str, dict: &str) { + w.s(0, "TABLESTYLE"); + w.s(5, h); + w.group("ACAD_REACTORS", 330, &[dict]); + w.s(330, dict); + w.s(100, "AcDbTableStyle"); + w.s(3, ""); + w.i(70, 0); + w.i(71, 0); + w.f(40, s.margin); + w.f(41, s.margin); + w.i(280, i64::from(!s.title)); + w.i(281, i64::from(!s.header)); + // Data, column header and title cell styles; CADCraft's complete cell styles in xdata. + for kind in crate::dxf_table::CELL_TYPES { + let cs = s.cell_style(kind); + let style = if d.text_style(&cs.text_style).is_some() { cs.text_style.clone() } else { "Standard".to_string() }; + w.t.extend(crate::dxf_table::cell_type_tags(&cs, &style)); + } + let x = crate::dxf_table::cell_styles_xdata(s); + if !x.is_empty() { + w.s(1001, dxf_ext::APP); + for c in x { + w.s(1000, c); + } + } +} + +/// A GROUP object (DXF Reference, OBJECTS: GROUP): description, unnamed (`*A…`) and selectable +/// flags, and the member entities that were written to this file. +pub(super) fn group_obj(w: &mut W, g: &Group, h: &str, dict: &str, written: &std::collections::HashSet) { + w.s(0, "GROUP"); + w.s(5, h); + w.group("ACAD_REACTORS", 330, &[dict]); + w.s(330, dict); + w.s(100, "AcDbGroup"); + w.s(300, &g.description); + w.i(70, i64::from(g.name.starts_with('*'))); + w.i(71, i64::from(g.selectable)); + for m in g.members.iter().map(|m| m.hex()).filter(|m| written.contains(m)) { + w.s(340, m); + } +} + +/// A MLEADERSTYLE object (DXF Reference, OBJECTS: MLEADERSTYLE): MText content and straight +/// ByBlock leaders with the style's landing gap, dogleg length, arrow size, text style and +/// text height. +pub(super) fn mleader_style_obj(w: &mut W, s: &MLeaderStyle, h: &str, dict: &str, byblock_ltype: Option<&str>, text_style: Option) { + // ByBlock as a 32-bit colour value (0xC1000000). + const BYBLOCK: i64 = -1_056_964_608; + w.s(0, "MLEADERSTYLE"); + w.s(5, h); + w.group("ACAD_REACTORS", 330, &[dict]); + w.s(330, dict); + w.s(100, "AcDbMLeaderStyle"); + w.i(179, 2); + w.i(170, 2); + w.i(171, 1); + w.i(172, 0); + w.i(90, 2); + w.f(40, 0.0); + w.f(41, 0.0); + w.i(173, 1); + w.i(91, BYBLOCK); + if let Some(lt) = byblock_ltype { + w.s(340, lt); + } + w.i(92, -2); + w.i(290, 1); + w.f(42, s.landing_gap); + w.i(291, 1); + w.f(43, s.dogleg); + w.s(3, ""); + w.f(44, s.arrow_size); + w.s(300, ""); + if let Some(ts) = text_style { + w.s(342, ts); + } + w.i(174, 1); + w.i(178, 1); + w.i(175, 1); + w.i(176, 0); + w.i(93, BYBLOCK); + w.f(45, s.text_height); + w.i(292, 0); + w.i(297, 0); + w.f(46, 4.0); + w.i(94, BYBLOCK); + for c in [47, 49, 140] { + w.f(c, 1.0); + } + w.i(293, 1); + w.f(141, 0.0); + w.i(294, 1); + w.i(177, 0); + w.f(142, 1.0); + w.i(295, 0); + w.i(296, i64::from(s.annotative)); + w.f(143, 0.125); + w.i(271, 0); + w.i(272, 9); + w.i(273, 9); +} + +/// The ACAD_SCALELIST dictionary and its SCALE objects (DXF Reference, OBJECTS section): name +/// (300), paper units (140), drawing units (141), unit scale flag (290). +pub(super) fn scale_list(w: &mut W, scales: &[(&cadcraft_doc::AnnoScale, String)], dict: &str, root: &str) { + w.s(0, "DICTIONARY"); + w.s(5, dict); + w.group("ACAD_REACTORS", 330, &[root]); + w.s(330, root); + w.s(100, "AcDbDictionary"); + w.i(281, 1); + for (i, (_, h)) in scales.iter().enumerate() { + w.s(3, format!("S{i}")); + w.s(350, h.clone()); + } + for (s, h) in scales { + w.s(0, "SCALE"); + w.s(5, h.clone()); + w.group("ACAD_REACTORS", 330, &[dict]); + w.s(330, dict); + w.s(100, "AcDbScale"); + w.i(70, 0); + w.s(300, dxf_ext::xdata_str(&s.name)); + w.f(140, s.paper_units); + w.f(141, s.drawing_units); + w.i(290, i64::from(cadcraft_doc::same_factor(s.factor(), 1.0))); + } +} + +pub(super) fn cadcraft_fonts_name() -> &'static str { + "CADCraft Stroke" +} diff --git a/crates/io/src/dxf_write/sections.rs b/crates/io/src/dxf_write/sections.rs new file mode 100644 index 000000000..7198eeee8 --- /dev/null +++ b/crates/io/src/dxf_write/sections.rs @@ -0,0 +1,972 @@ +use super::assoc::std_assoc_refs; +use super::blocks::{arrow_block_entities, dim_block_entities, dim_block_plan, reuse_handles, table_block_entities}; +use super::common::annotative_xdata; +use super::ctx::{AssocObj, Ctx}; +use super::entity::{entity, hatch_reactors}; +use super::header::{code_page, header_vars}; +use super::layout::{paper_views, record_head, sheet_first, table_head, vport_table}; +use super::objects::{cadcraft_fonts_name, group_obj, mleader_style_obj, scale_list, table_style_obj}; +use super::*; + +/// The drawing's DXF tags, declaring `version` in `$ACADVER`, with what `target` (the version the +/// file ends up in: `version`, or the DWG version the bridge writes) has. +pub(super) fn tags_for(d: &Drawing, version: Version, target: Version) -> Vec { + // An in-place block edit in progress is not written: the drawing as before it. + let committed = d.committed(); + let d: &Drawing = &committed; + // Structural handles start above every entity handle. + let mut w = W { + t: Vec::new(), + next: d.handseed.max(0x100).saturating_add(0x1000), + pstyles: HashMap::new(), + layers: HashMap::new(), + reactors: hatch_reactors(d), + }; + // Pre-allocate handles for block records. + let paper_layouts = d.paper_space_layouts(); + let ms_br = w.h(); + // Xrefs are saved as references: without their objects and the symbols that came with them. + let saved_view = crate::dxf_xref::saved_view(d); + let d = saved_view.as_ref().unwrap_or(d); + let ps_brs: Vec<(String, String, &Layout)> = paper_layouts + .iter() + .enumerate() + .map(|(i, l)| (if i == 0 { "*Paper_Space".to_string() } else { format!("*Paper_Space{}", i - 1) }, String::new(), *l)) + .collect(); + let ps_brs: Vec<(String, String, &Layout)> = ps_brs.into_iter().map(|(n, _, l)| (n, w.h(), l)).collect(); + let dim_plan = dim_block_plan(d); + let mut user_blocks: Vec<(String, String, &Block)> = d + .blocks + .values() + .filter(|b| !dim_plan.skip.contains(&b.name.to_ascii_uppercase())) + .map(|b| (b.name.clone(), String::new(), b.as_ref())) + .collect(); + user_blocks.iter_mut().for_each(|b| b.1 = format!("{:X}", 0)); + let user_blocks: Vec<(String, String, &Block)> = user_blocks.into_iter().map(|(n, _, b)| (n, w.h(), b)).collect(); + let mut cx = Ctx { paper_views: paper_views(d), target, ..Ctx::default() }; + cx.block_records = user_blocks.iter().map(|(n, h, _)| (n.to_ascii_uppercase(), h.clone())).collect(); + // Dimension blocks. + let mut dim_defs: Vec<(String, String, Vec)> = Vec::new(); // filled below + let mut n = 1; + let mut reused_handles = std::collections::HashSet::new(); + let all_spaces: Vec<&EntityStore> = std::iter::once(&d.model).chain(d.layouts.iter().map(|l| &l.entities)).collect(); + for st in &all_spaces { + for e in st.iter() { + if let EntityKind::Dimension(dm) = &e.kind { + let reused = dim_plan.reuse.get(&e.handle); + let ents: Vec; + let name = match reused { + // A dimension still linked to the block it was read with hasn't changed since + // (edits drop the link, and the screen draws that block): the block is written + // as read, so other programs show it as they drew it. + Some(b) if !b.entities.is_empty() => { + ents = b.entities.iter().map(|x| x.as_ref().clone()).collect(); + reused_handles.extend(ents.iter().map(|x| x.handle).filter(|h| h.0 != 0)); + b.name.clone() + } + Some(b) => { + let mut fresh = dim_block_entities(d, dm, &e.common.layer); + reuse_handles(&mut fresh, b, &mut reused_handles); + ents = fresh; + b.name.clone() + } + None => { + ents = dim_block_entities(d, dm, &e.common.layer); + let mut name = format!("*D{n}"); + while d.block(&name).is_some() { + n += 1; + name = format!("*D{n}"); + } + n += 1; + name + } + }; + let brh = w.h(); + cx.dim_blocks.insert(e.handle, name.clone()); + dim_defs.push((name, brh, ents)); + } + } + } + // Every entity, including block contents (for arrows, tables and overrides). + let every: Vec<&Entity> = all_spaces + .iter() + .flat_map(|s| s.iter().map(|e| e.as_ref())) + .chain(d.blocks.values().flat_map(|b| b.entities.iter().map(|e| e.as_ref()))) + .collect(); + // Arrowhead blocks: user blocks by name, otherwise generated from our own geometry. + let mut arrow_names: Vec = Vec::new(); + for s in &d.dim_styles { + arrow_names.extend([s.arrow_block.clone(), s.arrow_block1.clone(), s.arrow_block2.clone(), s.leader_arrow_block.clone()]); + } + for e in &every { + if let EntityKind::Dimension(dm) = &e.kind { + for (k, v) in &dm.overrides { + let Some(f) = DimStyle::field_name(k).filter(|f| f.starts_with("arrowBlock") || *f == "leaderArrowBlock") else { continue }; + if let Some(s) = dxf_ext::canonical(f, v).as_ref().and_then(|v| v.as_str()) { + arrow_names.push(s.to_string()); + } + } + } + } + let mut arrow_defs: Vec<(String, String, Vec)> = Vec::new(); + for name in arrow_names { + let key = name.trim().to_ascii_uppercase(); + if key.is_empty() || cx.arrows.contains_key(&key) { + continue; + } + if let Some((_, h, _)) = user_blocks.iter().find(|(n, _, _)| n.eq_ignore_ascii_case(name.trim())) { + cx.arrows.insert(key, h.clone()); + continue; + } + let Some((bname, kind)) = dxf_ext::arrow_block_name(&name) else { continue }; + let existing = arrow_defs.iter().find(|(n, _, _)| n.eq_ignore_ascii_case(&bname)).map(|(_, h, _)| h.clone()); + let existing = existing.or_else(|| user_blocks.iter().find(|(n, _, _)| n.eq_ignore_ascii_case(&bname)).map(|(_, h, _)| h.clone())); + let h = match existing { + Some(h) => h, + None => { + let h = w.h(); + arrow_defs.push((bname, h.clone(), arrow_block_entities(kind))); + h + } + }; + cx.arrows.insert(key, h); + } + // Table blocks (`*T`). + let mut table_defs: Vec<(String, String, Vec)> = Vec::new(); + let mut tn = 1; + for e in &every { + if let EntityKind::Table(t) = &e.kind { + if cx.tables.contains_key(&e.handle) { + continue; + } + let mut name = format!("*T{tn}"); + while d.block(&name).is_some() { + tn += 1; + name = format!("*T{tn}"); + } + tn += 1; + let brh = w.h(); + cx.tables.insert(e.handle, (name.clone(), brh.clone())); + table_defs.push((name, brh, table_block_entities(d, t, &e.common.layer))); + } + } + // Image definitions (IMAGEDEF objects) of raster images anywhere in the drawing. + cx.images = crate::dxf_image::plan(every.iter().copied(), || w.h()); + cx.mlines = crate::dxf_mline::plan(d, every.iter().copied(), || w.h()); + // DIMASSOC objects of associative dimensions in model and paper space. + for st in &all_spaces { + for e in st.iter() { + if let EntityKind::Dimension(dm) = &e.kind { + let (flags, refs) = std_assoc_refs(d, dm); + if !refs.is_empty() { + cx.assoc.insert(e.handle, AssocObj { xdict: w.h(), handle: w.h(), flags, refs }); + } + } + } + } + let default_table_style = [TableStyle::default()]; + let table_styles: &[TableStyle] = if d.table_styles.is_empty() { &default_table_style } else { &d.table_styles }; + cx.table_styles = table_styles.iter().map(|s| (s.name.clone(), w.h())).collect(); + let default_mleader_style = [MLeaderStyle::default()]; + let mleader_styles: &[MLeaderStyle] = if d.mleader_styles.is_empty() { &default_mleader_style } else { &d.mleader_styles }; + cx.mleader_styles = mleader_styles.iter().map(|s| (s.name.clone(), w.h())).collect(); + let root_dict = w.h(); + let group_dict = w.h(); + let layout_dict = w.h(); + let table_style_dict = w.h(); + // CADCraft data in XRECORDs of the named object dictionary: (key, handle, text chunks). + let mut xrecords: Vec<(&str, String, Vec)> = Vec::new(); + // The drawing's own next handle: table records and objects are numbered above it, so + // reading it back (instead of $HANDSEED) keeps their handles across saves (#495). + xrecords.push((dxf_ext::HANDSEED_KEY, w.h(), vec![format!("{:X}", d.handseed)])); + if let Some(name) = d.header.get("CMLEADERSTYLE").and_then(HVal::as_str) { + xrecords.push((dxf_ext::MLEADER_STYLE_KEY, w.h(), vec![name.to_string()])); + } + if let Some(chunks) = dxf_ext::constraint_chunks(&d.constraints, &d.parametric) { + xrecords.push((dxf_ext::CONSTRAINTS_KEY, w.h(), chunks)); + } + if let Some(chunks) = dxf_ext::layer_state_chunks(&d.layer_states) { + xrecords.push((dxf_ext::LAYER_STATES_KEY, w.h(), chunks)); + } + if let Some(chunks) = dxf_ext::plot_style_chunks(&d.plot_style_tables) { + xrecords.push((dxf_ext::PLOT_STYLES_KEY, w.h(), chunks)); + } + if let Some(chunks) = dxf_ext::anno_chunks(d) { + xrecords.push((dxf_ext::ANNOTATION_KEY, w.h(), chunks)); + } + // The annotation scale list: the ACAD_SCALELIST dictionary of SCALE objects. + let scale_dict = w.h(); + let scales: Vec<(&cadcraft_doc::AnnoScale, String)> = d.scales.iter().filter(|s| s.is_valid()).map(|s| (s, w.h())).collect(); + // Plot style names used by layers and objects (Normal always), each a placeholder object. + let pstyle_dict = w.h(); + let mut pstyle_names: Vec = vec![cadcraft_doc::NORMAL_STYLE.to_string()]; + let named = d.layers.iter().map(|l| l.plot_style.as_str()).chain(every.iter().map(|e| e.common.plot_style.as_str())); + for n in named { + let n = n.trim(); + let logical = n.is_empty() || n.eq_ignore_ascii_case("bylayer") || n.eq_ignore_ascii_case("byblock"); + if !logical && pstyle_names.len() < dxf_ext::MAX_PLOT_STYLE_NAMES && !pstyle_names.iter().any(|x| x.eq_ignore_ascii_case(n)) { + pstyle_names.push(n.to_string()); + } + } + let pstyles: Vec<(String, String)> = pstyle_names.into_iter().map(|n| (n, w.h())).collect(); + w.pstyles = pstyles.iter().map(|(n, h)| (n.to_ascii_uppercase(), h.clone())).collect(); + let model_layout = w.h(); + let layout_handles: Vec = ps_brs.iter().map(|_| w.h()).collect(); + + // ---------------- HEADER ---------------- + w.s(0, "SECTION"); + w.s(2, "HEADER"); + w.s(9, "$ACADVER"); + w.s(1, version.code()); + w.s(9, "$DWGCODEPAGE"); + w.s(3, code_page(d, version)); + let ext = d.extents(&Space::Model); + if !ext.is_empty() { + w.s(9, "$EXTMIN"); + w.p(10, ext.min.to3(0.0)); + w.s(9, "$EXTMAX"); + w.p(10, ext.max.to3(0.0)); + } + header_vars(&mut w, d); + let seed_pos = w.t.len(); + w.s(9, "$HANDSEED"); + w.s(5, "0"); + w.s(0, "ENDSEC"); + + // ---------------- CLASSES ---------------- + w.s(0, "SECTION"); + w.s(2, "CLASSES"); + let mut classes = vec![ + ("TABLESTYLE", "AcDbTableStyle", 4095, false), + ("MLEADERSTYLE", "AcDbMLeaderStyle", 4095, false), + ("ACDBDICTIONARYWDFLT", "AcDbDictionaryWithDefault", 0, false), + ("ACDBPLACEHOLDER", "AcDbPlaceHolder", 0, false), + ]; + if !cx.tables.is_empty() { + classes.push(("ACAD_TABLE", "AcDbTable", 1025, true)); + } + if every.iter().any(|e| matches!(e.kind, EntityKind::Helix(_))) { + classes.push(("HELIX", "AcDbHelix", 4095, true)); + } + if !cx.assoc.is_empty() { + classes.push(("DIMASSOC", "AcDbDimAssoc", 0, false)); + } + if cx.native_mleader() && every.iter().any(|e| matches!(e.kind, EntityKind::MLeader(_))) { + classes.push(("MULTILEADER", "AcDbMLeader", 1025, true)); + } + if every.iter().any(|e| matches!(e.kind, EntityKind::Wipeout(_))) { + classes.push(("WIPEOUT", "AcDbWipeout", 127, true)); + } + if !cx.images.is_empty() { + classes.push(("IMAGE", "AcDbRasterImage", 127, true)); + classes.push(("IMAGEDEF", "AcDbRasterImageDef", 0, false)); + classes.push(("IMAGEDEF_REACTOR", "AcDbRasterImageDefReactor", 1, false)); + classes.push(("RASTERVARIABLES", "AcDbRasterVariables", 0, false)); + } + classes.push(("SCALE", "AcDbScale", 0, false)); + for (dxf_name, cpp, proxy, is_entity) in classes { + w.s(0, "CLASS"); + w.s(1, dxf_name); + w.s(2, cpp); + w.s(3, "ObjectDBX Classes"); + w.i(90, proxy); + w.i(280, 0); + w.i(281, i64::from(is_entity)); + } + w.s(0, "ENDSEC"); + + // ---------------- TABLES ---------------- + w.s(0, "SECTION"); + w.s(2, "TABLES"); + vport_table(&mut w, d, ext); + // LTYPE. Embedded text and shapes point at STYLE records (340), which get their handles + // in the STYLE table below: (tag position, style name, is a shape) are filled in there. + let mut ltype_handles: HashMap = HashMap::new(); + let mut ltype_style_refs: Vec<(usize, Option, bool)> = Vec::new(); + let th = table_head(&mut w, "LTYPE", d.linetypes.len()); + for lt in &d.linetypes { + let h = record_head(&mut w, "LTYPE", &th, "AcDbLinetypeTableRecord"); + ltype_handles.entry(lt.name.to_ascii_uppercase()).or_insert(h.clone()); + cx.ltypes.entry(lt.name.to_ascii_uppercase()).or_insert(h); + w.s(2, <.name); + w.i(70, if crate::dxf_xref::dependent(<.name) { 48 } else { 0 }); + w.s(3, <.description); + w.i(72, 65); + w.i(73, lt.pattern.len() as i64); + w.f(40, lt.pattern_length()); + for el in <.pattern { + w.f(49, el.length); + // 74: 2 = text, 4 = shape, + 1 when the rotation is absolute (DXF Reference, LTYPE). + let (kind, shape) = match (&el.text, el.shape) { + (Some(_), _) => (2, 0), + (None, Some(n)) => (4, n), + (None, None) => (0, 0), + }; + if kind == 0 { + w.i(74, 0); + continue; + } + w.i(74, kind | i64::from(el.absolute)); + w.i(75, i64::from(shape)); + ltype_style_refs.push((w.t.len(), el.style.clone(), kind == 4)); + w.s(340, "0"); + w.f(46, el.scale); + w.f(50, el.rotation); + w.f(44, el.offset.x); + w.f(45, el.offset.y); + if let Some(t) = &el.text { + w.s(9, t.clone()); + } + } + } + w.s(0, "ENDTAB"); + // LAYER + let th = table_head(&mut w, "LAYER", d.layers.len()); + for l in &d.layers { + let lh = record_head(&mut w, "LAYER", &th, "AcDbLayerTableRecord"); + w.layers.insert(l.name.to_ascii_uppercase(), lh); + w.s(2, &l.name); + // 16 | 32: an xref's layer (`xref|name`), externally dependent and resolved. + let xref = if crate::dxf_xref::dependent(&l.name) { 48 } else { 0 }; + w.i(70, i64::from(l.frozen) | if l.vp_freeze_new { 2 } else { 0 } | if l.locked { 4 } else { 0 } | xref); + let aci = match l.color { + Color::True(rgb) => i64::from(cadcraft_color::nearest_aci(rgb)), + c => i64::from(c.to_aci()).clamp(1, 255), + }; + w.i(62, if l.on { aci } else { -aci }); + if let Color::True(rgb) = l.color { + w.i(420, i64::from(rgb.to_u32())); + } + w.s(6, &l.linetype); + if !l.plot { + w.i(290, 0); + } + w.i(370, i64::from(l.lineweight.to_dxf())); + let normal = w.pstyles.get(&cadcraft_doc::NORMAL_STYLE.to_ascii_uppercase()).cloned(); + if let Some(h) = w.pstyles.get(&l.plot_style.trim().to_ascii_uppercase()).cloned().or(normal) { + w.s(390, h); + } + if l.transparency > 0 { + w.s(1001, dxf_ext::LAYER_TRANSPARENCY_APP); + w.i(1071, dxf_ext::transparency_to_dxf(l.transparency)); + } + if !l.description.is_empty() { + // The first string is the layer standard's name, unused here. + w.s(1001, dxf_ext::LAYER_DESCRIPTION_APP); + w.s(1000, ""); + w.s(1000, dxf_ext::xdata_str(&l.description)); + } + } + w.s(0, "ENDTAB"); + // STYLE. Linetype shapes from a shape file that isn't a text style get an unnamed shape + // style record (70 bit 1) naming the file. + let mut shape_files: Vec = Vec::new(); + for (_, style, shape) in <ype_style_refs { + let Some(f) = style.as_deref().map(str::trim).filter(|f| *shape && !f.is_empty() && d.text_style(f).is_none()) else { continue }; + if !shape_files.iter().any(|x| x.eq_ignore_ascii_case(f)) { + shape_files.push(f.to_string()); + } + } + let th = table_head(&mut w, "STYLE", d.text_styles.len() + shape_files.len()); + for s in &d.text_styles { + let h = record_head(&mut w, "STYLE", &th, "AcDbTextStyleTableRecord"); + cx.styles.entry(s.name.to_ascii_uppercase()).or_insert(h); + w.s(2, &s.name); + // 70 bit 4: vertical; 71 bits 2/4: backwards / upside down. + w.i(70, if s.vertical { 4 } else { 0 }); + w.f(40, s.height); + w.f(41, s.width_factor); + w.f(50, s.oblique.to_degrees()); + w.i(71, if s.backwards { 2 } else { 0 } | if s.upside_down { 4 } else { 0 }); + w.f(42, 0.2); + // The built-in font goes out as `txt`, which every reader has; CADCraft's own name + // travels in `CADCRAFT` xdata (`1000 FONT`, name) so the style reopens with it. + let file = if s.font == cadcraft_fonts_name() { "txt" } else { s.font.as_str() }; + w.s(3, file); + w.s(4, &s.big_font); + if s.annotative { + annotative_xdata(&mut w); + } + if file != s.font { + w.s(1001, dxf_ext::APP); + w.s(1000, "FONT"); + w.s(1000, dxf_ext::xdata_str(&s.font)); + } + } + let mut shape_handles: HashMap = HashMap::new(); + for f in &shape_files { + let h = record_head(&mut w, "STYLE", &th, "AcDbTextStyleTableRecord"); + shape_handles.insert(f.to_ascii_uppercase(), h); + w.s(2, ""); + w.i(70, 1); + w.f(40, 0.0); + w.f(41, 1.0); + w.f(50, 0.0); + w.i(71, 0); + w.f(42, 0.2); + w.s(3, f.clone()); + w.s(4, ""); + } + w.s(0, "ENDTAB"); + // Linetype text and shapes without a known style use Standard (else the first style). + let fallback_style = cx.style("Standard").or_else(|| d.text_styles.first().and_then(|s| cx.style(&s.name))).unwrap_or_else(|| "0".into()); + for (pos, style, shape) in ltype_style_refs { + let name = style.as_deref().map(str::trim).unwrap_or_default(); + let h = shape.then(|| shape_handles.get(&name.to_ascii_uppercase()).cloned()).flatten().or_else(|| cx.style(name)); + if let Some(t) = w.t.get_mut(pos) { + *t = Tag::s(340, h.unwrap_or_else(|| fallback_style.clone())); + } + } + // VIEW: named views (DXF Reference, VIEW). A view's layer state is CADCraft xdata. + let th = table_head(&mut w, "VIEW", d.views.len()); + for v in &d.views { + record_head(&mut w, "VIEW", &th, "AcDbViewTableRecord"); + w.s(2, &v.name); + w.i(70, i64::from(v.layout.is_some())); + w.f(40, v.height); + w.p2(10, v.center); + w.f(41, v.width); + w.p(11, Vec3::Z); + w.p(12, Vec3::ZERO); + w.f(42, 50.0); + w.f(43, 0.0); + w.f(44, 0.0); + w.f(50, if v.twist.is_finite() { v.twist.to_degrees() } else { 0.0 }); + w.i(71, 0); + w.i(281, 0); + w.i(72, 0); + let xdata = [("LAYERSTATE", v.layer_state.as_deref()), ("LAYOUT", v.layout.as_deref().filter(|l| !l.is_empty()))]; + if xdata.iter().any(|(_, x)| x.is_some()) { + w.s(1001, dxf_ext::APP); + for (key, value) in xdata { + if let Some(value) = value { + w.s(1000, key); + w.s(1000, dxf_ext::xdata_str(value)); + } + } + } + } + w.s(0, "ENDTAB"); + // UCS: named user coordinate systems (DXF Reference, UCS). + let th = table_head(&mut w, "UCS", d.ucss.len()); + for u in &d.ucss { + record_head(&mut w, "UCS", &th, "AcDbUCSTableRecord"); + w.s(2, &u.name); + w.i(70, 0); + w.p(10, u.origin); + w.p(11, u.x_axis); + w.p(12, u.y_axis); + w.i(79, 0); + w.f(146, 0.0); + } + w.s(0, "ENDTAB"); + let apps = ["ACAD", dxf_ext::APP, "AcadAnnotative", dxf_ext::LAYER_TRANSPARENCY_APP, dxf_ext::LAYER_DESCRIPTION_APP]; + let th = table_head(&mut w, "APPID", apps.len()); + for app in apps { + record_head(&mut w, "APPID", &th, "AcDbRegAppTableRecord"); + w.s(2, app); + w.i(70, 0); + } + w.s(0, "ENDTAB"); + // DIMSTYLE + let th = { + let h = w.h(); + w.s(0, "TABLE"); + w.s(2, "DIMSTYLE"); + w.s(5, h.clone()); + w.s(330, "0"); + w.s(100, "AcDbSymbolTable"); + w.i(70, d.dim_styles.len() as i64); + w.s(100, "AcDbDimStyleTable"); + w.i(71, 0); + h + }; + for s in &d.dim_styles { + record_head(&mut w, "DIMSTYLE", &th, "AcDbDimStyleTableRecord"); + w.s(2, &s.name); + w.i(70, 0); + let v = serde_json::to_value(s).unwrap_or_default(); + let defaults = serde_json::to_value(DimStyle::default()).unwrap_or_default(); + let mut newer = W { t: Vec::new(), next: 0, pstyles: HashMap::new(), layers: HashMap::new(), reactors: HashMap::new() }; + for (field, code, kind) in dxf_ext::DIM_CODES { + let Some(enc) = v.get(*field).and_then(|x| cx.encode(*kind, x)) else { continue }; + // DIMPOST is written even when empty: a reader that defaults a missing one to "<>" + // (the DWG bridge's) would otherwise give radius and diameter dimensions no "R" or + // "Ø" in AutoCAD (#1053). + if *code == dxf_ext::DIMPOST + && let DimVal::Str(t) = &enc + && t.is_empty() + { + w.s(*code, ""); + continue; + } + if !dxf_ext::NEWER_CODES.contains(code) { + w.dimval(*code, enc, false); + continue; + } + // 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)); + newer.dimval(*code, enc, true); + } + } + if !s.arrow_block1.trim().is_empty() || !s.arrow_block2.trim().is_empty() { + w.i(dxf_ext::DIMSAH, 1); + } + if s.annotative { + annotative_xdata(&mut w); + } + if !newer.t.is_empty() { + w.s(1001, dxf_ext::APP); + w.s(1000, dxf_ext::DIMVARS_LIST); + w.s(1002, "{"); + w.xdata(newer.t); + w.s(1002, "}"); + } + } + w.s(0, "ENDTAB"); + // BLOCK_RECORD + let th = table_head(&mut w, "BLOCK_RECORD", 1 + ps_brs.len() + user_blocks.len() + dim_defs.len() + arrow_defs.len() + table_defs.len()); + let br_rec = |w: &mut W, h: &str, name: &str, layout: Option<&str>, blk: Option<&Block>| { + w.s(0, "BLOCK_RECORD"); + w.s(5, h); + w.s(330, th.clone()); + w.s(100, "AcDbSymbolTableRecord"); + w.s(100, "AcDbBlockTableRecord"); + w.s(2, name); + w.s(340, layout.unwrap_or("0")); + // User blocks: insertion units (70) and explodability (280). + if let Some(b) = blk { + w.i(70, i64::from(b.units)); + w.i(280, i64::from(b.explodable)); + if let Some(a) = &b.array { + w.s(1001, dxf_ext::APP); + w.xdata(dxf_ext::array_xdata(a)); + } + } + }; + br_rec(&mut w, &ms_br, "*Model_Space", Some(&model_layout), None); + for (i, (n, h, _)) in ps_brs.iter().enumerate() { + br_rec(&mut w, h, n, layout_handles.get(i).map(String::as_str), None); + } + for (n, h, b) in &user_blocks { + br_rec(&mut w, h, n, None, Some(b)); + } + for (n, h, _) in dim_defs.iter().chain(&arrow_defs).chain(&table_defs) { + br_rec(&mut w, h, n, None, None); + } + w.s(0, "ENDTAB"); + w.s(0, "ENDSEC"); + + // ---------------- BLOCKS ---------------- + w.s(0, "SECTION"); + w.s(2, "BLOCKS"); + 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); + w.s(330, brh); + w.s(100, "AcDbEntity"); + if paper { + w.i(67, 1); + } + w.s(8, "0"); + w.s(100, "AcDbBlockBegin"); + w.s(2, name); + w.i(70, flags); + w.p(10, base); + w.s(3, name); + w.s(1, ""); + if !desc.is_empty() { + w.s(4, desc); + } + for e in ents { + entity(w, d, e, brh, false, &cx); + } + let eh = w.h(); + w.s(0, "ENDBLK"); + w.s(5, eh); + w.s(330, brh); + w.s(100, "AcDbEntity"); + if paper { + w.i(67, 1); + } + w.s(8, "0"); + w.s(100, "AcDbBlockEnd"); + }; + 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); + } else { + 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); + } + // 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()) { + for e in ents.iter_mut().filter(|e| e.handle.0 == 0) { + e.handle = Handle(w.next); + w.next = w.next.saturating_add(1); + } + } + for (n, h, ents) in &dim_defs { + 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); + } + for (n, h, ents) in &table_defs { + block(&mut w, (n, ""), h, Vec3::ZERO, 1, &mut ents.iter(), false); + } + w.s(0, "ENDSEC"); + + // ---------------- ENTITIES ---------------- + w.s(0, "SECTION"); + w.s(2, "ENTITIES"); + for e in d.model.iter() { + entity(&mut w, d, e, &ms_br, false, &cx); + } + if let Some((_, h, l)) = ps_brs.first() { + for e in sheet_first(l) { + entity(&mut w, d, e, h, true, &cx); + } + } + w.s(0, "ENDSEC"); + + // ---------------- OBJECTS ---------------- + // Groups (members that still exist) and multileader styles. + let group_owner = group_dict.clone(); + let written: std::collections::HashSet = w.t.iter().filter(|t| t.code == 5).map(Tag::str).collect(); + let groups: Vec<(&Group, String)> = d.groups.iter().filter(|g| !g.name.is_empty()).map(|g| (g, w.h())).collect(); + let mleader_dict = w.h(); + let mleader_handles: Vec = cx.mleader_styles.iter().map(|(_, h)| h.clone()).collect(); + w.s(0, "SECTION"); + w.s(2, "OBJECTS"); + w.s(0, "DICTIONARY"); + w.s(5, root_dict.clone()); + w.s(330, "0"); + w.s(100, "AcDbDictionary"); + w.i(281, 1); + w.s(3, "ACAD_GROUP"); + w.s(350, group_dict.clone()); + if !cx.images.is_empty() { + w.s(3, "ACAD_IMAGE_DICT"); + w.s(350, cx.images.dict.clone()); + w.s(3, "ACAD_IMAGE_VARS"); + w.s(350, cx.images.vars.clone()); + } + w.s(3, "ACAD_LAYOUT"); + w.s(350, layout_dict.clone()); + w.s(3, "ACAD_MLEADERSTYLE"); + w.s(350, mleader_dict.clone()); + w.s(3, "ACAD_MLINESTYLE"); + w.s(350, cx.mlines.dict.clone()); + w.s(3, dxf_ext::PLOT_STYLE_NAMES_KEY); + w.s(350, pstyle_dict.clone()); + w.s(3, "ACAD_TABLESTYLE"); + w.s(350, table_style_dict.clone()); + for (key, h, _) in &xrecords { + w.s(3, *key); + w.s(350, h); + } + w.s(3, "ACAD_SCALELIST"); + w.s(350, scale_dict.clone()); + w.s(0, "DICTIONARY"); + w.s(5, group_dict); + w.s(330, root_dict.clone()); + w.s(100, "AcDbDictionary"); + w.i(281, 1); + for (g, h) in &groups { + w.s(3, &g.name); + w.s(350, h); + } + for (g, h) in &groups { + group_obj(&mut w, g, h, &group_owner, &written); + } + // Multileader styles. + w.s(0, "DICTIONARY"); + w.s(5, mleader_dict.clone()); + w.s(330, root_dict.clone()); + w.s(100, "AcDbDictionary"); + w.i(281, 1); + for (s, h) in mleader_styles.iter().zip(&mleader_handles) { + w.s(3, &s.name); + w.s(350, h); + } + let byblock_ltype = ltype_handles.get("BYBLOCK").cloned(); + for (s, h) in mleader_styles.iter().zip(&mleader_handles) { + mleader_style_obj(&mut w, s, h, &mleader_dict, byblock_ltype.as_deref(), cx.style(&s.text_style)); + } + // Multiline styles. + w.t.extend(crate::dxf_mline::objects(&cx.mlines, &root_dict)); + // Table styles. + w.s(0, "DICTIONARY"); + w.s(5, table_style_dict.clone()); + w.s(330, root_dict.clone()); + w.s(100, "AcDbDictionary"); + w.i(281, 1); + for (name, h) in &cx.table_styles { + w.s(3, name); + w.s(350, h); + } + for (s, (_, h)) in table_styles.iter().zip(&cx.table_styles) { + table_style_obj(&mut w, d, s, h, &table_style_dict); + } + // Plot style names: a dictionary with a default (Normal) of placeholder objects. + w.s(0, "ACDBDICTIONARYWDFLT"); + w.s(5, pstyle_dict.clone()); + w.group("ACAD_REACTORS", 330, &[&root_dict]); + w.s(330, root_dict.clone()); + w.s(100, "AcDbDictionary"); + w.i(281, 1); + for (n, h) in &pstyles { + w.s(3, n); + w.s(350, h); + } + w.s(100, "AcDbDictionaryWithDefault"); + w.s(340, pstyles.first().map(|(_, h)| h.clone()).unwrap_or_default()); + for (_, h) in &pstyles { + w.s(0, "ACDBPLACEHOLDER"); + w.s(5, h); + w.group("ACAD_REACTORS", 330, &[&pstyle_dict]); + w.s(330, pstyle_dict.clone()); + } + // Parametric constraints and parameters, saved layer states, plot style tables (CADCraft + // data). + for (_, h, chunks) in &xrecords { + w.s(0, "XRECORD"); + w.s(5, h); + w.group("ACAD_REACTORS", 330, &[&root_dict]); + w.s(330, root_dict.clone()); + w.s(100, "AcDbXrecord"); + w.i(280, 1); + for c in chunks { + w.s(1, c.clone()); + } + } + scale_list(&mut w, &scales, &scale_dict, &root_dict); + // Dimension associativity: extension dictionary + DIMASSOC per dimension. + let mut assoc: Vec<(&Handle, &AssocObj)> = cx.assoc.iter().collect(); + assoc.sort_by_key(|(h, _)| **h); + for (dim, a) in assoc { + let dh = dim.hex(); + w.s(0, "DICTIONARY"); + w.s(5, a.xdict.clone()); + w.group("ACAD_REACTORS", 330, &[&dh]); + w.s(330, dh.clone()); + w.s(100, "AcDbDictionary"); + w.i(280, 1); + w.i(281, 1); + w.s(3, "ACAD_DIMASSOC"); + w.s(360, a.handle.clone()); + w.s(0, "DIMASSOC"); + w.s(5, a.handle.clone()); + w.group("ACAD_REACTORS", 330, &[&a.xdict]); + w.s(330, a.xdict.clone()); + w.s(100, "AcDbDimAssoc"); + w.s(330, dh); + w.i(90, a.flags); + w.i(70, 0); + w.i(71, 0); + for r in &a.refs { + w.s(1, "AcDbOsnapPointRef"); + w.i(72, r.osnap); + w.s(331, r.main.hex()); + w.i(73, if r.segment > 0 { 2 } else { 0 }); + w.i(91, r.segment); + w.s(301, ""); + w.f(40, r.param); + w.p(10, r.point); + if let Some(o) = r.other { + w.s(332, o.hex()); + w.i(74, 0); + w.i(92, 0); + w.s(302, ""); + } + w.i(75, 0); + } + } + w.s(0, "DICTIONARY"); + w.s(5, layout_dict.clone()); + w.s(330, root_dict.clone()); + w.s(100, "AcDbDictionary"); + w.i(281, 1); + w.s(3, "Model"); + w.s(350, model_layout.clone()); + for (i, (_, _, l)) in ps_brs.iter().enumerate() { + 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, 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 }; + let standard = page.scale_to_fit || scale == 1.0; + let plot_flags = 512 + | 32 + | if standard { 16 } else { 0 } + | if page.lineweights { 128 } else { 0 } + | if page.center { 4 } else { 0 } + | if page.show_plot_styles { 2 } else { 0 }; + let plot_type = match page.plot_area.as_str() { + "display" => 0, + "extents" => 1, + "limits" => 2, + "window" => 4, + _ => 5, + }; + w.s(0, "LAYOUT"); + w.s(5, h); + w.s(330, layout_dict.clone()); + w.s(100, "AcDbPlotSettings"); + w.s(1, ""); + let device = page.device.trim(); + w.s(2, if device.is_empty() || device.eq_ignore_ascii_case("None") { "none_device" } else { device }); + w.s(4, page.paper.replace(' ', "_")); + w.s(6, ""); + for (c, v) in [ + (40, page.margins_mm[0]), + (41, page.margins_mm[1]), + (42, page.margins_mm[2]), + (43, page.margins_mm[3]), + (44, page.width_mm), + (45, page.height_mm), + (46, 0.0), + (47, 0.0), + (48, page.plot_window[0]), + (49, page.plot_window[1]), + (140, page.plot_window[2]), + (141, page.plot_window[3]), + (142, scale), + (143, 1.0), + ] { + w.f(c, v); + } + w.i(70, plot_flags); + // Plot paper units: 1 millimetres, 0 inches (#1058). + w.i(72, i64::from(cadcraft_render::paper::page_unit_mm(d, page) == 1.0)); + w.i(73, i64::from(page.landscape)); + w.i(74, plot_type); + w.s(7, &page.plot_style_table); + w.i(75, if page.scale_to_fit { 0 } else { 16 }); + w.f(147, scale); + w.f(148, 0.0); + w.f(149, 0.0); + w.s(100, "AcDbLayout"); + w.s(1, name); + w.i(70, flags); + w.i(71, tab); + w.p2(10, Vec2::ZERO); + w.p2(11, Vec2::new(12.0, 9.0)); + w.p(12, Vec3::ZERO); + w.p(14, Vec3::ZERO); + w.p(15, Vec3::ZERO); + w.f(146, 0.0); + w.p(13, Vec3::ZERO); + w.p(16, Vec3::new(1.0, 0.0, 0.0)); + w.p(17, Vec3::new(0.0, 1.0, 0.0)); + w.i(76, 0); + w.s(330, brh); + // The saved paper-space view (centre, height) as CADCraft xdata. + if let Some((c, height)) = view.filter(|(c, v)| c.x.is_finite() && c.y.is_finite() && v.is_finite()) { + w.s(1001, dxf_ext::APP); + w.s(1000, "PSVIEW"); + w.s(1002, "{"); + w.p(1010, c.to3(0.0)); + w.f(1040, height); + w.s(1002, "}"); + } + }; + // Model space has no page setup of its own: write what PLOT does for it, the drawing extents + // fitted to the paper (plot type 1, scaled to fit). The default page's "layout" area is + // 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); + 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); + } + // Raster image definitions. + w.t.extend(crate::dxf_image::objects(&cx.images, &root_dict)); + w.s(0, "ENDSEC"); + w.s(0, "EOF"); + let seed = format!("{:X}", w.next); + if let Some(t) = w.t.get_mut(seed_pos + 1) { + *t = Tag::s(5, seed); + } + crate::dxf_raw::finish(&mut w.t, d); + crate::dxf_raw::data_store(&mut w.t, d, target >= Version::R2013); + add_class_instance_counts(&mut w.t); + mark_xrefs(&mut w.t, d); + w.t +} + +/// Write DXF Reference group 91 on every CLASS record. Count records after `dxf_raw::finish` +/// restores foreign classes and objects so every class gets its actual instance count. +fn add_class_instance_counts(tags: &mut Vec) { + let mut counts: HashMap = HashMap::new(); + for t in tags.iter().filter(|t| t.code == 0) { + let kind = t.str(); + if kind != "CLASS" { + *counts.entry(kind.to_ascii_uppercase()).or_default() += 1; + } + } + + let mut start = 0; + while start < tags.len() { + if tags.get(start).is_none_or(|t| t.code != 0 || t.str() != "CLASS") { + start += 1; + continue; + } + let end = tags[start + 1..].iter().position(|t| t.code == 0).map_or(tags.len(), |i| start + 1 + i); + let name = tags[start + 1..end].iter().find(|t| t.code == 1).map(Tag::str).unwrap_or_default().to_ascii_uppercase(); + let count = counts.get(&name).copied().unwrap_or(0); + let inserted = if let Some(i) = tags[start + 1..end].iter().position(|t| t.code == 91) { + tags[start + 1 + i] = Tag::i(91, count); + false + } else if let Some(i) = tags[start + 1..end].iter().position(|t| t.code == 90) { + tags.insert(start + 2 + i, Tag::i(91, count)); + true + } else { + false + }; + // Inserting the group shifts the next record's start by one. + start = end + usize::from(inserted); + } +} + +/// Xref blocks (DXF Reference, BLOCK): bit 4 of group 70 and the external drawing's path in +/// group 1, set on the written BLOCK records of blocks that have an `xref_path`. +fn mark_xrefs(tags: &mut [Tag], d: &Drawing) { + // Overlays add bit 8. + let xrefs: HashMap = d + .blocks + .values() + .filter_map(|b| Some((b.name.to_ascii_uppercase(), (b.xref_path.as_deref()?, if b.xref_overlay { 12 } else { 4 })))) + .collect(); + if xrefs.is_empty() { + return; + } + // Inside a BLOCK record: the path once its name (2) says it is an xref. + let mut block: Option> = None; + for t in tags.iter_mut() { + match (t.code, block) { + (0, _) => block = (t.str() == "BLOCK").then_some(None), + (2, Some(None)) => block = Some(xrefs.get(&t.str().to_ascii_uppercase()).copied()), + (70, Some(Some((_, flags)))) => *t = Tag::i(70, t.i64() | flags), + (1, Some(Some((p, _)))) => *t = Tag::s(1, p), + _ => {} + } + } +} 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/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/render/src/blocks.rs b/crates/render/src/blocks.rs new file mode 100644 index 000000000..d8a62fddc --- /dev/null +++ b/crates/render/src/blocks.rs @@ -0,0 +1,124 @@ +use super::draw::entity; +use super::props::{resolve, resolve_common, transparency}; +use super::text_entity::{mtext, place_text_entity}; +use super::*; + +pub(crate) fn sub_ctx<'a>(ctx: &Ctx<'a>, e: &Entity, m: Mat3) -> Ctx<'a> { + let layer = if e.common.layer == "0" { ctx.block_layer.clone() } else { Some(e.common.layer.clone()) }; + let color = match e.common.color { + Color::ByBlock => ctx.block_color, + Color::ByLayer => ctx.layer_color(layer.as_deref().unwrap_or("0")).unwrap_or(Color::Index(7)), + c => c, + }; + let lw = match e.common.lineweight { + Lineweight::ByBlock => ctx.block_lw, + Lineweight::ByLayer => ctx.d.layer(layer.as_deref().unwrap_or("0")).map(|l| l.lineweight).unwrap_or(Lineweight::Default), + x => x, + }; + let lt = match e.common.linetype.to_ascii_lowercase().as_str() { + "byblock" => ctx.block_ltype.clone(), + "bylayer" => ctx.d.layer(layer.as_deref().unwrap_or("0")).map(|l| l.linetype.clone()).unwrap_or_else(|| "Continuous".into()), + _ => e.common.linetype.clone(), + }; + Ctx { + d: ctx.d, + xf: ctx.xf.then_before(m), + block_color: color, + block_layer: layer, + block_lw: lw, + block_ltype: lt, + depth: ctx.depth + 1, + top: ctx.top, + frozen: ctx.frozen, + vp_colors: ctx.vp_colors, + // The reference's own linetype scale (group 48) multiplies that of the objects inside, + // as LTSCALE does for the drawing (tests/block_ltscale.rs). + lt_factor: ctx.lt_factor * if e.common.ltscale.is_finite() && e.common.ltscale > 0.0 { e.common.ltscale } else { 1.0 }, + block_pstyle: ctx.block_pstyle, + // The reference's own, set by the caller (`Builder::transparency` while it draws). + block_transparency: ctx.block_transparency, + } +} + +pub(crate) fn insert(b: &mut Builder, ctx: &Ctx, e: &Entity, ins: &cadcraft_doc::Insert, rgb: Ink) { + if ctx.depth >= cadcraft_doc::MAX_BLOCK_DEPTH { + return; + } + if let Some(blk) = ctx.d.block(&ins.block) { + let cols = ins.cols.clamp(1, 10_000); + let rows = ins.rows.clamp(1, 10_000); + 'copies: for r in 0..rows { + for c in 0..cols { + if !b.expand() { + break 'copies; + } + let off = Vec2::new(ins.col_spacing * f64::from(c), ins.row_spacing * f64::from(r)).rotate(ins.rotation); + let m = Mat3::translate(off).then_before(ins.transform(blk.base.xy())); + let sub = Ctx { block_pstyle: b.named, block_transparency: b.transparency, ..sub_ctx(ctx, e, m) }; + for be in blk.entities.iter() { + // Constant/visible attribute definitions inside blocks are not drawn; attribs are. + if matches!(be.kind, EntityKind::AttDef(_)) { + continue; + } + if !b.expand() { + break 'copies; + } + entity(b, &sub, be); + } + } + } + } + if b.opts.text { + // ATTMODE (ATTDISP): 0 shows no attributes, 1 the visible ones, 2 all of them. + let attmode = ctx.d.header.i64("ATTMODE", 1); + // The reference's transparency; each attribute draws with its own. + let reference = b.transparency; + for a in &ins.attribs { + if attmode == 0 || (a.invisible && attmode != 2) { + continue; + } + // A multiline attribute is drawn as its multiline text. + let multiline = a.multiline_text(); + let sh = if multiline.is_none() { Some(place_text_entity(ctx.d, &a.text, &a.text.value).0) } else { None }; + // Properties of the attribute's definition (as AutoCAD copies them to the attribute + // on insertion), except those set on the attribute itself: ByBlock = the reference's, + // layer 0 = the reference's layer. + let def = ctx.d.block(&ins.block).and_then(|blk| blk.entities.iter().find(|be| attdef_tag(be, &a.tag))); + let (ink, lw, shown, t) = match (def, a.props.is_empty()) { + (None, true) => (rgb, resolve(ctx, e, b.plotting).1, true, reference), + (def, _) => { + let c = a.props.over(&def.map(|d| d.common.clone()).unwrap_or_else(by_block)); + let (ink, lw, _, _, shown, layer_t) = resolve_common(&sub_ctx(ctx, e, Mat3::IDENTITY), &c, b.plotting); + let t = if b.opts.transparency { transparency(c.transparency, layer_t, reference) } else { 0 }; + (ink, lw, shown, t) + } + }; + if shown { + b.transparency = t; + let lw = if b.opts.lineweights { lw } else { 0.0 }; + match (&multiline, &sh) { + (Some(m), _) => mtext(b, ctx, m, ink, lw), + (None, Some(sh)) => b.shaped(ctx, ink, lw, sh), + (None, None) => {} + } + } + } + b.transparency = reference; + } +} + +/// Properties that follow the block reference's (an attribute without a definition). +fn by_block() -> cadcraft_doc::Common { + cadcraft_doc::Common { + color: Color::ByBlock, + linetype: "ByBlock".into(), + lineweight: Lineweight::ByBlock, + transparency: cadcraft_doc::Transparency::ByBlock, + ..cadcraft_doc::Common::default() + } +} + +/// The attribute definition with tag `tag` (any case). +fn attdef_tag(e: &Entity, tag: &str) -> bool { + matches!(&e.kind, EntityKind::AttDef(d) if d.tag.eq_ignore_ascii_case(tag)) +} diff --git a/crates/render/src/dim.rs b/crates/render/src/dim.rs index 9b7f613eb..a2f0021a2 100644 --- a/crates/render/src/dim.rs +++ b/crates/render/src/dim.rs @@ -12,6 +12,14 @@ use cadcraft_geom::{Arc, Vec2, ccw_sweep}; use crate::units::{NumFormat, format_angle_with_separator, format_decimal, format_linear}; +mod arrowheads; +mod text; + +use arrowheads::tick; +pub use arrowheads::{ArrowGeom, Arrowhead, arrow, arrowhead}; +pub use text::{DimText, format_linear_value}; +use text::{apply_override, inspected, linear_text}; + /// Most DIMBREAK gaps drawn per dimension. const MAX_BREAKS: usize = 1000; @@ -145,183 +153,6 @@ impl DimGeometry { } } -/// The arrowheads drawn in code (DIMBLK names; our own geometry). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Arrowhead { - ClosedFilled, - ClosedBlank, - Closed, - Dot, - DotSmall, - DotBlank, - Oblique, - ArchTick, - Open, - Open90, - Open30, - Origin, - Origin2, - Small, - BoxFilled, - BoxBlank, - DatumFilled, - DatumBlank, - Integral, - None, -} - -impl Arrowhead { - /// Parse a DIMBLK value ("" / "_DOT" / "Architectural tick" / "closed blank"…). - pub fn parse(name: &str) -> Arrowhead { - let n: String = name.trim().trim_start_matches('_').chars().filter(char::is_ascii_alphanumeric).collect::().to_ascii_lowercase(); - match n.as_str() { - "" | "closedfilled" | "filled" => Arrowhead::ClosedFilled, - "closedblank" | "blank" => Arrowhead::ClosedBlank, - "closed" => Arrowhead::Closed, - "dot" => Arrowhead::Dot, - "dotsmall" => Arrowhead::DotSmall, - "dotblank" => Arrowhead::DotBlank, - "oblique" => Arrowhead::Oblique, - "archtick" | "architecturaltick" | "tick" => Arrowhead::ArchTick, - "open" => Arrowhead::Open, - "open90" | "rightangle" => Arrowhead::Open90, - "open30" => Arrowhead::Open30, - "origin" | "originindicator" => Arrowhead::Origin, - "origin2" | "originindicator2" => Arrowhead::Origin2, - "small" => Arrowhead::Small, - "boxfilled" => Arrowhead::BoxFilled, - "boxblank" | "box" => Arrowhead::BoxBlank, - "datumfilled" | "datumtrianglefilled" => Arrowhead::DatumFilled, - "datumblank" | "datumtriangle" => Arrowhead::DatumBlank, - "integral" => Arrowhead::Integral, - "none" => Arrowhead::None, - _ => Arrowhead::ClosedFilled, - } - } - /// Ticks and obliques: the dimension line runs through the tip and extends by DIMDLE. - pub fn is_tick(self) -> bool { - matches!(self, Arrowhead::Oblique | Arrowhead::ArchTick | Arrowhead::Integral | Arrowhead::None) - } - /// How far back from the tip the dimension line stops (blank heads are not crossed). - pub fn inset(self, size: f64) -> f64 { - match self { - Arrowhead::ClosedBlank | Arrowhead::DatumBlank => size, - Arrowhead::DotBlank => size / 4.0, - Arrowhead::Origin | Arrowhead::Origin2 => size / 2.0, - Arrowhead::BoxBlank => size / 4.0, - _ => 0.0, - } - } -} - -/// Arrowhead geometry: open polylines and filled triangles. -#[derive(Clone, Debug, Default, PartialEq)] -pub struct ArrowGeom { - pub lines: Vec>, - pub tris: Vec, -} - -fn circle_pts(c: Vec2, r: f64) -> Vec { - (0..=24).map(|i| c + Vec2::from_angle(f64::from(i) * cadcraft_geom::TAU / 24.0) * r).collect() -} - -fn disc(c: Vec2, r: f64) -> Vec { - let mut t = Vec::with_capacity(72); - for i in 0..24 { - let a0 = f64::from(i) * cadcraft_geom::TAU / 24.0; - let a1 = f64::from(i + 1) * cadcraft_geom::TAU / 24.0; - t.extend_from_slice(&[c, c + Vec2::from_angle(a0) * r, c + Vec2::from_angle(a1) * r]); - } - t -} - -/// An arrowhead at `tip` pointing along `dir` (unit), `size` = DIMASZ × scale. -pub fn arrowhead(kind: Arrowhead, tip: Vec2, dir: Vec2, size: f64) -> ArrowGeom { - let dir = if dir == Vec2::ZERO || !dir.is_finite() { Vec2::X } else { dir }; - let size = if size.is_finite() { size.abs() } else { 0.0 }; - let n = dir.perp(); - let wings = |half_angle: f64, len: f64| { - let back = -dir; - (tip + back.rotate(half_angle) * len, tip + back.rotate(-half_angle) * len) - }; - let mut g = ArrowGeom::default(); - match kind { - Arrowhead::ClosedFilled => g.tris = arrow(tip, dir, size), - Arrowhead::Small => g.tris = arrow(tip, dir, size / 2.0), - Arrowhead::ClosedBlank | Arrowhead::Closed => { - let back = tip - dir * size; - let w = n * (size / 6.0); - g.lines.push(vec![tip, back + w, back - w, tip]); - if kind == Arrowhead::Closed { - g.lines.push(vec![back, tip]); - } - } - Arrowhead::Dot => g.tris = disc(tip, size / 4.0), - Arrowhead::DotSmall => g.tris = disc(tip, size / 8.0), - Arrowhead::DotBlank => g.lines.push(circle_pts(tip, size / 4.0)), - Arrowhead::Origin => g.lines.push(circle_pts(tip, size / 2.0)), - Arrowhead::Origin2 => { - g.lines.push(circle_pts(tip, size / 2.0)); - g.lines.push(circle_pts(tip, size / 4.0)); - } - Arrowhead::Oblique => { - let t = canonical(dir + n).normalized() * (size / 2.0); - g.lines.push(vec![tip - t, tip + t]); - } - Arrowhead::ArchTick => { - // A heavy 45° stroke: a thin filled parallelogram. - let t = canonical(dir + n).normalized() * (size / 2.0); - let w = t.perp().normalized() * (size / 16.0); - let (a, b) = (tip - t, tip + t); - g.tris = vec![a - w, b - w, b + w, a - w, b + w, a + w]; - } - Arrowhead::Integral => { - // A small integral-sign wave through the tip. - let t = canonical(dir + n).normalized(); - let pts = (-8..=8) - .map(|i| { - let f = f64::from(i) / 8.0; - tip + t * (f * size / 2.0) + t.perp() * ((f * std::f64::consts::PI).sin() * size * 0.12) - }) - .collect(); - g.lines.push(pts); - } - Arrowhead::Open => { - let (a, b) = wings((1.0f64 / 6.0).atan(), size); - g.lines.push(vec![a, tip, b]); - } - Arrowhead::Open90 => { - let (a, b) = wings(std::f64::consts::FRAC_PI_4, size / std::f64::consts::SQRT_2); - g.lines.push(vec![a, tip, b]); - } - Arrowhead::Open30 => { - let (a, b) = wings(15f64.to_radians(), size); - g.lines.push(vec![a, tip, b]); - } - Arrowhead::BoxFilled | Arrowhead::BoxBlank => { - let h = size / 4.0; - let (u, v) = (dir * h, n * h); - let q = [tip - u - v, tip + u - v, tip + u + v, tip - u + v]; - if kind == Arrowhead::BoxFilled { - g.tris = vec![q[0], q[1], q[2], q[0], q[2], q[3]]; - } else { - g.lines.push(vec![q[0], q[1], q[2], q[3], q[0]]); - } - } - Arrowhead::DatumFilled | Arrowhead::DatumBlank => { - let apex = tip - dir * size; - let w = n * (size / 2.0); - if kind == Arrowhead::DatumFilled { - g.tris = vec![tip + w, tip - w, apex]; - } else { - g.lines.push(vec![tip + w, tip - w, apex, tip + w]); - } - } - Arrowhead::None => {} - } - g -} - /// The pieces of a polyline outside the circle (`c`, `r`). fn cut_polyline(l: &[Vec2], c: Vec2, r: f64) -> Vec> { let mut out: Vec> = Vec::new(); @@ -360,146 +191,6 @@ fn cut_polyline(l: &[Vec2], c: Vec2, r: f64) -> Vec> { out } -/// A closed filled arrowhead at `tip` pointing along `dir` (unit), as one triangle. -pub fn arrow(tip: Vec2, dir: Vec2, size: f64) -> Vec { - let back = tip - dir * size; - let n = dir.perp() * (size / 6.0); - vec![tip, back + n, back - n] -} - -/// Ticks lean the same way at both ends of a dimension line whichever way they point. -fn canonical(v: Vec2) -> Vec2 { - if v.x < -1e-12 || (v.x.abs() <= 1e-12 && v.y < 0.0) { -v } else { v } -} - -/// A DIMTSZ tick: an oblique stroke centred on `at`. -fn tick(at: Vec2, dir: Vec2, half: f64) -> Vec { - let d = canonical(dir); - let t = (d + d.perp()).normalized() * half; - vec![at - t, at + t] -} - -/// Format a linear measurement per the style (plain text). -pub fn format_linear_value(v: f64, st: &DimStyle) -> String { - format_linear(v * st.linear_factor, &NumFormat::from_style(st)).0 -} - -/// The text of a linear measurement: (plain, MTEXT) with DIMPOST, tolerances/limits and -/// alternate units, then the user's override text (`<>` = the measurement). DIMRND rounds the -/// measurement (and so the limits) only: tolerances are never rounded and alternate units -/// round by DIMALTRND. -fn linear_text(meas: f64, d: &Dimension, st: &DimStyle, prefix: &str) -> String { - let v = meas * st.linear_factor; - let nf = NumFormat::from_style(st); - let main = if st.limits { - let up = format_linear(v + st.tol_plus, &nf).1; - let lo = format_linear(v - st.tol_minus, &nf).1; - format!("{{\\H{};\\S{up}^{lo};}}", tol_height_code(st)) - } else { - format!("{prefix}{}", format_linear(v, &nf).1) - }; - let mut s = post(&st.post, &main); - if st.tolerance && !st.limits { - let tf = NumFormat { decimals: st.tol_decimals, zin: st.tol_zero_suppression, round: 0.0, ..nf.clone() }; - // DIMPOST's suffix (what follows `<>`, or all of it) also goes on the tolerance values. - let suffix = st.post.split_once("<>").map_or(st.post.as_str(), |(_, after)| after); - if (st.tol_plus - st.tol_minus).abs() < 1e-12 { - s += &format!("%%p{}{suffix}", format_linear(st.tol_plus, &tf).1); - } else { - let plus = format_linear(st.tol_plus, &tf).1 + suffix; - let minus = format_linear(st.tol_minus, &tf).1 + suffix; - let sign = |x: &str, pos: char| if x.trim_start_matches(['0', '.', ',']).is_empty() { x.to_string() } else { format!("{pos}{x}") }; - let (plus, minus) = - (sign(&plus, '+'), if st.tol_minus < 0.0 { format!("+{}", minus.trim_start_matches('-')) } else { sign(&minus, '-') }); - // DIMTOLJ: bottom / middle / top (MTEXT `\A`; middle is the default). - let tolj = if st.tol_just == 1 { String::new() } else { format!("\\A{};", st.tol_just.min(2)) }; - s += &format!("{{\\H{};{tolj}\\S{plus}^{minus};}}", tol_height_code(st)); - } - } - if st.alt { - // DIMALTU 6 and 7 are architectural and fractional without stacking, 8 is decimal. - let (unit, frac) = match st.alt_unit { - u @ 1..=5 => (u, nf.frac), - 6 => (4, 2), - 7 => (5, 2), - _ => (2, nf.frac), - }; - let af = NumFormat { unit, decimals: st.alt_decimals, zin: st.alt_zero_suppression, round: st.alt_round, frac, ..nf }; - let alt = format_linear(v * st.alt_factor, &af).1; - s += &format!(" [{}]", post(&st.alt_post, &alt)); - } - inspected(apply_override(&s, d), d) -} - -/// DIMINSPECT: the label and the rate either side of the value, two spaces apart. -fn inspected(value: String, d: &Dimension) -> String { - match &d.marks.inspect { - Some(ins) => [ins.label.as_str(), value.as_str(), ins.rate.as_str()].iter().filter(|p| !p.is_empty()).copied().collect::>().join(" "), - None => value, - } -} - -/// `\H` code for stacked tolerance parts (DIMTFAC relative to the 70% stack scale). -fn tol_height_code(st: &DimStyle) -> String { - let f = if st.tol_scale.is_finite() && st.tol_scale > 0.0 { st.tol_scale } else { 1.0 }; - format!("{}x", format_decimal(f / 0.7, 4, 8, ".")) -} - -/// Apply measurement placeholders or append a dimension suffix, bounding the resulting UTF-8 text. -fn post(template: &str, meas: &str) -> String { - if template.contains("<>") { - bounded_replace(template, meas) - } else { - let mut out = String::new(); - append_dim_text(&mut out, meas); - append_dim_text(&mut out, template); - out - } -} - -/// Apply dimension text overrides: empty uses the base, whitespace suppresses text, and other text substitutes it. -fn apply_override(base: &str, d: &Dimension) -> String { - if d.text.is_empty() { - base.to_string() - } else if d.text.trim().is_empty() { - String::new() - } else { - bounded_replace(&d.text, base) - } -} - -/// Largest dimension text produced by substituting the measurement into style/override text. Two -/// modest strings can otherwise expand into gigabytes when the template holds many `<>` markers -/// (CWE-789); past this the output is truncated rather than allocated unbounded. -const MAX_DIM_TEXT: usize = 64 * 1024; - -/// Replace every `<>` in `template` with `value`, with the expanded length checked against -/// [`MAX_DIM_TEXT`] before allocating so a many-placeholder template cannot blow up memory. -fn bounded_replace(template: &str, value: &str) -> String { - let mut out = String::new(); - let mut rest = template; - while let Some(i) = rest.find("<>") { - if !append_dim_text(&mut out, &rest[..i]) || !append_dim_text(&mut out, value) { - return out; - } - rest = &rest[i + 2..]; - } - append_dim_text(&mut out, rest); - out -} - -// Returns false when truncation consumed the remaining UTF-8-safe space. -/// Append within the remaining dimension-text byte allowance without splitting a UTF-8 character. -/// Return false if any input text had to be truncated. -fn append_dim_text(out: &mut String, text: &str) -> bool { - let mut end = text.len().min(MAX_DIM_TEXT.saturating_sub(out.len())); - while !text.is_char_boundary(end) { - end -= 1; - } - out.push_str(&text[..end]); - end == text.len() -} - fn ext_line(origin: Vec2, foot: Vec2, st: &DimStyle, k: f64) -> Option> { let v = foot - origin; let len = v.len(); @@ -525,16 +216,6 @@ fn readable(a: f64) -> f64 { a } -/// Font and fixed height of the dimension text (from DIMTXSTY's text style). -#[derive(Clone, Debug, Default)] -pub struct DimText { - pub font: TextFont, - /// Fixed text height of the style (0 = use DIMTXT × scale). - pub fixed_height: f64, - pub width_factor: f64, - pub oblique: f64, -} - /// Generate the geometry of a dimension with the stroke font. pub fn dimension_geometry(d: &Dimension, st: &DimStyle, dimscale: f64) -> DimGeometry { dimension_geometry_with(d, st, dimscale, &DimText { width_factor: 1.0, ..Default::default() }) @@ -1079,437 +760,4 @@ fn decode_value(s: &str) -> String { } #[cfg(test)] -mod tests { - use super::*; - use cadcraft_geom::Vec3; - - fn lin(p1: (f64, f64), p2: (f64, f64), on: (f64, f64), rot: f64) -> Dimension { - Dimension { - kind: DimKind::Linear { rotation: rot }, - defpt: Vec3::new(on.0, on.1, 0.0), - text_mid: Vec3::ZERO, - p13: Vec3::new(p1.0, p1.1, 0.0), - p14: Vec3::new(p2.0, p2.1, 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(), - } - } - - fn st_with(f: impl Fn(&mut DimStyle)) -> DimStyle { - let mut s = DimStyle::default(); - f(&mut s); - s - } - - fn bbox(pts: &[Vec]) -> cadcraft_geom::Bounds2 { - cadcraft_geom::Bounds2::from_points(pts.iter().flatten().copied()) - } - - #[test] - fn horizontal_measures_dx() { - let g = dimension_geometry(&lin((0.0, 0.0), (10.0, 3.0), (0.0, 5.0), 0.0), &DimStyle::default(), 1.0); - assert_eq!(g.value, "10.0000"); - assert_eq!(g.measurement, 10.0); - assert!(!g.text.is_empty()); - assert_eq!(g.fills.len(), 2); - assert_eq!(g.lines_of(LineRole::Ext).count(), 2); - } - - #[test] - fn vertical_measures_dy() { - let g = dimension_geometry(&lin((0.0, 0.0), (10.0, 3.0), (15.0, 0.0), std::f64::consts::FRAC_PI_2), &DimStyle::default(), 1.0); - assert_eq!(g.value, "3.0000"); - assert!((g.measurement - 3.0).abs() < 1e-12, "{}", g.measurement); - } - - #[test] - fn override_text() { - let mut d = lin((0.0, 0.0), (10.0, 0.0), (0.0, 5.0), 0.0); - d.text = "<> TYP".into(); - let g = dimension_geometry(&d, &DimStyle::default(), 1.0); - assert_eq!(g.value, "10.0000 TYP"); - d.text = " ".into(); - assert_eq!(dimension_geometry(&d, &DimStyle::default(), 1.0).value, ""); - } - - #[test] - fn iso_suppresses_zeros() { - let g = dimension_geometry(&lin((0.0, 0.0), (12.5, 0.0), (0.0, 5.0), 0.0), &DimStyle::iso25(), 1.0); - assert_eq!(g.value, "12.5"); - } - - #[test] - fn ticks_replace_arrows_and_extend_line() { - let st = st_with(|s| { - s.tick_size = 0.1; - s.dim_line_extend = 0.2; - }); - let g = dimension_geometry(&lin((0.0, 0.0), (10.0, 0.0), (0.0, 5.0), 0.0), &st, 1.0); - assert!(g.fills.is_empty(), "no filled arrows with ticks"); - // Two 45° ticks of half-length 0.1 centred on the feet. - let ticks: Vec<_> = g.lines.iter().filter(|l| l.len() == 2 && ((l[1] - l[0]).angle().to_degrees() - 45.0).abs() < 1e-6).collect(); - assert_eq!(ticks.len(), 2); - for t in &ticks { - assert!((t[0].dist(t[1]) - 0.2).abs() < 1e-9); - assert!((t[0].mid(t[1]).y - 5.0).abs() < 1e-9); - } - // The dimension line runs past both extension lines by DIMDLE. - let b = bbox(&g.lines_of(LineRole::Dim).cloned().collect::>()); - assert!((b.min.x + 0.2).abs() < 1e-9 && (b.max.x - 10.2).abs() < 1e-9, "{b:?}"); - } - - #[test] - fn arrowhead_shapes() { - let tip = Vec2::new(1.0, 1.0); - let dir = Vec2::X; - let cf = arrowhead(Arrowhead::ClosedFilled, tip, dir, 1.0); - assert_eq!(cf.tris.len(), 3); - assert!(cf.tris.contains(&tip)); - let cb = arrowhead(Arrowhead::ClosedBlank, tip, dir, 1.0); - assert!(cb.tris.is_empty() && cb.lines.len() == 1 && cb.lines[0].len() == 4); - assert_eq!(cb.lines[0].first(), cb.lines[0].last()); - let dot = arrowhead(Arrowhead::Dot, tip, dir, 1.0); - let r = dot.tris.iter().map(|p| p.dist(tip)).fold(0.0, f64::max); - assert!((r - 0.25).abs() < 1e-9, "dot radius {r}"); - let ob = arrowhead(Arrowhead::Oblique, tip, dir, 1.0); - assert_eq!(ob.lines.len(), 1); - assert!(((ob.lines[0][1] - ob.lines[0][0]).angle().to_degrees() - 45.0).abs() < 1e-9); - let at = arrowhead(Arrowhead::ArchTick, tip, dir, 1.0); - assert_eq!(at.tris.len(), 6); - let open = arrowhead(Arrowhead::Open, tip, dir, 1.0); - assert!(open.tris.is_empty() && open.lines[0].len() == 3 && open.lines[0][1] == tip); - let o90 = arrowhead(Arrowhead::Open90, tip, dir, 1.0); - let (a, b) = (o90.lines[0][0] - tip, o90.lines[0][2] - tip); - assert!(a.dot(b).abs() < 1e-9, "open 90 wings are perpendicular"); - let origin = arrowhead(Arrowhead::Origin, tip, dir, 1.0); - assert!(origin.lines[0].iter().all(|p| (p.dist(tip) - 0.5).abs() < 1e-9)); - assert_eq!(arrowhead(Arrowhead::None, tip, dir, 1.0), ArrowGeom::default()); - assert_eq!(Arrowhead::parse("_ARCHTICK"), Arrowhead::ArchTick); - assert_eq!(Arrowhead::parse("Architectural tick"), Arrowhead::ArchTick); - assert_eq!(Arrowhead::parse("closed blank"), Arrowhead::ClosedBlank); - assert_eq!(Arrowhead::parse(""), Arrowhead::ClosedFilled); - assert_eq!(Arrowhead::parse("_ORIGIN"), Arrowhead::Origin); - assert_eq!(Arrowhead::parse("whatever"), Arrowhead::ClosedFilled); - } - - #[test] - fn dimblk_dot_and_none() { - let st = st_with(|s| s.arrow_block = "_DOT".into()); - let g = dimension_geometry(&lin((0.0, 0.0), (10.0, 0.0), (0.0, 5.0), 0.0), &st, 1.0); - assert_eq!(g.fills.len(), 48, "two 24-triangle dots"); - let st = st_with(|s| { - s.arrow_block1 = "_NONE".into(); - s.arrow_block2 = "_OPEN".into(); - }); - let g = dimension_geometry(&lin((0.0, 0.0), (10.0, 0.0), (0.0, 5.0), 0.0), &st, 1.0); - assert!(g.fills.is_empty()); - assert!(g.lines.iter().any(|l| l.len() == 3)); - } - - #[test] - fn text_above_and_centred() { - let d = lin((0.0, 0.0), (10.0, 0.0), (0.0, 5.0), 0.0); - let centred = dimension_geometry(&d, &DimStyle::default(), 1.0); - assert!((centred.text_pos.y - 5.0).abs() < 1e-9); - // Centred text breaks the dimension line in two. - assert_eq!(centred.lines_of(LineRole::Dim).count(), 2); - let above = dimension_geometry(&d, &st_with(|s| s.text_above = 1), 1.0); - assert!((above.text_pos.y - (5.0 + 0.09 + 0.18 * 0.6)).abs() < 1e-6, "{}", above.text_pos.y); - assert_eq!(above.lines_of(LineRole::Dim).count(), 1); - let below = dimension_geometry(&d, &st_with(|s| s.text_above = 4), 1.0); - assert!(below.text_pos.y < 5.0); - // Outside: away from the measured points (they are below the line here). - let outside = dimension_geometry(&d, &st_with(|s| s.text_above = 2), 1.0); - assert!(outside.text_pos.y > 5.0); - let d2 = lin((0.0, 10.0), (10.0, 10.0), (0.0, 5.0), 0.0); - let outside2 = dimension_geometry(&d2, &st_with(|s| s.text_above = 2), 1.0); - assert!(outside2.text_pos.y < 5.0); - } - - #[test] - fn dimtad_one_uses_autocad_text_clearance() { - let d = lin((0.0, 0.10), (0.0, 0.70), (0.30, 0.40), std::f64::consts::FRAC_PI_2); - let st = st_with(|s| { - s.scale = 50.0; - s.text_height = 0.0025; - s.text_gap = 0.0008; - s.arrow_size = 0.0015; - s.text_above = 1; - s.text_inside_horizontal = false; - s.decimals = 2; - }); - let g = dimension_geometry(&d, &st, 50.0); - // Half the text height (0.0625) plus DIMGAP (0.04) put the centre at 0.1025; - // AutoCAD 2026 recomputes it at about 0.115 from the line (#2004). - assert!( - (0.30 - g.text_pos.x - 0.115).abs() < 0.001, - "text x={} offset={} angle={} text={}", - g.text_pos.x, - 0.30 - g.text_pos.x, - g.text_angle, - g.value - ); - } - - #[test] - fn text_alignment_vertical_dimension() { - let d = lin((0.0, 0.0), (0.0, 10.0), (5.0, 0.0), std::f64::consts::FRAC_PI_2); - let h = dimension_geometry(&d, &DimStyle::default(), 1.0); - assert!(h.text_angle.abs() < 1e-12, "DIMTIH on: horizontal"); - let a = dimension_geometry(&d, &st_with(|s| s.text_inside_horizontal = false), 1.0); - assert!((a.text_angle - std::f64::consts::FRAC_PI_2).abs() < 1e-9, "aligned: {}", a.text_angle); - } - - #[test] - fn justification_moves_text() { - let d = lin((0.0, 0.0), (10.0, 0.0), (0.0, 5.0), 0.0); - let j1 = dimension_geometry(&d, &st_with(|s| s.text_just = 1), 1.0); - let j2 = dimension_geometry(&d, &st_with(|s| s.text_just = 2), 1.0); - assert!(j1.text_pos.x < 5.0 && j2.text_pos.x > 5.0); - let j3 = dimension_geometry(&d, &st_with(|s| s.text_just = 3), 1.0); - assert!((j3.text_angle - std::f64::consts::FRAC_PI_2).abs() < 1e-9); - assert!(j3.text_pos.y > 5.0 && j3.text_pos.x < 0.0); - } - - #[test] - fn text_moves_outside_when_it_does_not_fit() { - let d = lin((0.0, 0.0), (0.5, 0.0), (0.0, 5.0), 0.0); - let g = dimension_geometry(&d, &DimStyle::default(), 1.0); - assert!(g.text_pos.x > 0.5, "text beyond the second extension line: {}", g.text_pos.x); - } - - #[test] - fn text_that_does_not_fit_follows_dimtmove_2() { - // Vertical, measured points left of the line: the text goes over the middle of the - // line, on the side away from them, and the line is not carried out to it. - let d = lin((0.0, 0.0), (0.0, 0.5), (5.0, 0.0), std::f64::consts::FRAC_PI_2); - let g = dimension_geometry(&d, &st_with(|s| s.text_move = 2), 1.0); - assert!((g.text_pos.y - 0.25).abs() < 1e-9 && g.text_pos.x > 5.2, "{:?}", g.text_pos); - assert!(g.lines_of(LineRole::Dim).flatten().all(|p| p.y <= 0.5 + 1e-9 && p.x <= 5.0 + 1e-9), "no leader, no line out to the text"); - // Horizontal, measured points above the line: below it. - let d = lin((0.0, 10.0), (0.5, 10.0), (0.0, 5.0), 0.0); - let g = dimension_geometry(&d, &st_with(|s| s.text_move = 2), 1.0); - assert!((g.text_pos.x - 0.25).abs() < 1e-9 && g.text_pos.y < 4.8, "{:?}", g.text_pos); - assert!(g.lines_of(LineRole::Dim).flatten().all(|p| p.x <= 0.5 + 1e-9 && p.y >= 5.0 - 1e-9), "no leader, no line out to the text"); - } - - #[test] - fn text_that_does_not_fit_follows_dimtmove_1() { - // Moved off the line with a leader from the middle of the dimension line. - let d = lin((0.0, 0.0), (0.5, 0.0), (0.0, 5.0), 0.0); - let g = dimension_geometry(&d, &st_with(|s| s.text_move = 1), 1.0); - assert!(g.text_pos.y > 5.2, "{:?}", g.text_pos); - let mid = Vec2::new(0.25, 5.0); - assert!(g.lines_of(LineRole::Dim).any(|l| l.len() == 2 && l[0].dist(mid) < 1e-9 && l[1].y > 5.1), "leader"); - // Text placed with DIMTEDIT stays where it is. - let mut placed = d.clone(); - placed.user_text_pos = true; - placed.text_mid = Vec3::new(3.0, 7.0, 0.0); - let g = dimension_geometry(&placed, &st_with(|s| s.text_move = 1), 1.0); - assert!(g.text_pos.dist(Vec2::new(3.0, 7.0)) < 1e-9, "{:?}", g.text_pos); - } - - #[test] - fn tolerance_and_limits() { - let d = lin((0.0, 0.0), (10.0, 0.0), (0.0, 5.0), 0.0); - let sym = dimension_geometry( - &d, - &st_with(|s| { - s.tolerance = true; - s.tol_plus = 0.05; - s.tol_minus = 0.05; - s.decimals = 2; - s.tol_decimals = 2; - }), - 1.0, - ); - assert_eq!(sym.value, "10.00±0.05"); - let dev = dimension_geometry( - &d, - &st_with(|s| { - s.tolerance = true; - s.tol_plus = 0.1; - s.tol_minus = 0.2; - s.decimals = 2; - s.tol_decimals = 1; - }), - 1.0, - ); - assert_eq!(dev.value, "10.00+0.1/-0.2"); - assert!(dev.mtext.contains("\\S+0.1^-0.2;")); - // Stacked: the text extends above and below a single line of text. - let plain = dimension_geometry(&d, &st_with(|s| s.decimals = 2), 1.0); - assert!(bbox(&dev.text).height() > bbox(&plain.text).height() * 1.3); - let lim = dimension_geometry( - &d, - &st_with(|s| { - s.limits = true; - s.tol_plus = 0.1; - s.tol_minus = 0.2; - s.decimals = 1; - }), - 1.0, - ); - assert_eq!(lim.value, "10.1/9.8"); - } - - #[test] - fn unit_formats_and_post() { - let d = lin((0.0, 0.0), (18.5, 0.0), (0.0, 5.0), 0.0); - let arch = dimension_geometry(&d, &st_with(|s| s.linear_unit = 4), 1.0); - assert_eq!(arch.value, "1'-6 1/2\""); - let frac = dimension_geometry(&d, &st_with(|s| s.linear_unit = 5), 1.0); - assert_eq!(frac.value, "18 1/2"); - let sci = dimension_geometry( - &d, - &st_with(|s| { - s.linear_unit = 1; - s.decimals = 2; - }), - 1.0, - ); - assert_eq!(sci.value, "1.85E+01"); - let post = dimension_geometry( - &d, - &st_with(|s| { - s.post = "<> mm".into(); - s.decimals = 1; - }), - 1.0, - ); - assert_eq!(post.value, "18.5 mm"); - let alt = dimension_geometry( - &lin((0.0, 0.0), (1.0, 0.0), (0.0, 5.0), 0.0), - &st_with(|s| { - s.alt = true; - s.decimals = 2; - }), - 1.0, - ); - assert_eq!(alt.value, "1.00 [25.40]"); - } - - #[test] - fn overrides_apply_per_dimension() { - let mut d = lin((0.0, 0.0), (10.0, 0.0), (0.0, 5.0), 0.0); - d.overrides.insert("DIMDEC".into(), serde_json::json!(1)); - d.overrides.insert("textAbove".into(), serde_json::json!(1)); - let g = dimension_geometry(&d, &DimStyle::default(), 1.0); - assert_eq!(g.value, "10.0"); - assert!(g.text_pos.y > 5.0); - } - - #[test] - fn radial_and_angular_text() { - let mut d = lin((0.0, 0.0), (0.0, 0.0), (0.0, 0.0), 0.0); - d.kind = DimKind::Radius; - d.defpt = Vec3::new(0.0, 0.0, 0.0); - d.p15 = Vec3::new(3.0, 0.0, 0.0); - let g = dimension_geometry(&d, &st_with(|s| s.decimals = 1), 1.0); - assert_eq!(g.value, "R3.0"); - d.kind = DimKind::Diameter; - d.defpt = Vec3::new(-3.0, 0.0, 0.0); - let g = dimension_geometry(&d, &st_with(|s| s.decimals = 1), 1.0); - assert_eq!(g.value, "⌀6.0"); - assert_eq!(g.mtext_contents(), "%%c6.0", "the *D block's text (#1645)"); - let mut a = lin((10.0, 0.0), (0.0, 10.0), (3.0, 3.0), 0.0); - a.kind = DimKind::Angular3P; - a.p15 = Vec3::ZERO; - let g = dimension_geometry(&a, &DimStyle::default(), 1.0); - assert_eq!(g.value, "90°"); - let g = dimension_geometry( - &a, - &st_with(|s| { - s.angular_unit = 3; - s.angular_decimals = 2; - }), - 1.0, - ); - assert_eq!(g.value, "1.57r"); - } - - #[test] - fn hostile_styles_do_not_panic() { - let d = lin((0.0, 0.0), (10.0, 0.0), (0.0, 5.0), 0.0); - for st in [ - st_with(|s| s.arrow_size = f64::NAN), - st_with(|s| s.text_height = -1.0), - st_with(|s| s.tick_size = f64::INFINITY), - st_with(|s| s.text_just = 200), - st_with(|s| s.text_above = 99), - st_with(|s| s.scale = 0.0), - st_with(|s| s.decimals = 255), - ] { - let _ = dimension_geometry(&d, &st, 1.0); - } - let z = lin((0.0, 0.0), (0.0, 0.0), (0.0, 0.0), 0.0); - let _ = dimension_geometry(&z, &DimStyle::default(), 1.0); - } - - #[test] - fn jogged_radius_with_nan_radius_draws_no_lines() { - let mut d = lin((0.0, 0.0), (0.0, 0.0), (0.0, 0.0), 0.0); - d.kind = DimKind::Radius; - d.p15 = Vec3::new(10.0, 0.0, 0.0); - d.marks.jogged = Some(cadcraft_doc::JoggedRadius { center: Vec3::new(0.0, -4.0, 0.0), jog: Vec3::new(5.0, -2.0, 0.0) }); - // Finite control: the jogged line runs from the arrowhead on the arc to the override centre. - let g = dimension_geometry(&d, &DimStyle::default(), 1.0); - let jog = g.lines.iter().find(|l| l.len() == 4).expect("jogged dimension line"); - assert!(jog[0].near(Vec2::new(10.0, 0.0), 1e-9) && jog[3].near(Vec2::new(0.0, -4.0), 1e-9)); - // A NaN in the tip makes the radius NaN: nothing usable to draw, and no panic. - d.p15 = Vec3::new(f64::NAN, 0.0, 0.0); - let g = dimension_geometry(&d, &DimStyle::default(), 1.0); - assert!(g.lines.is_empty() && g.fills.is_empty(), "{:?}", g.lines); - } - - /// #1645: the MTEXT contents give back the dimension's text when decoded, with the diameter - /// sign as `%%c` (no SHX font has U+2300) and a `%` that would start a code escaped. - #[test] - fn mtext_contents_write_the_diameter_sign_as_a_code() { - let with = |v: &str| DimGeometry { value: v.into(), ..DimGeometry::default() }; - for (value, contents) in [ - ("⌀60", "%%c60"), - ("2x ⌀8.5 THRU", "2x %%c8.5 THRU"), - ("R3.0", "R3.0"), - ("36.9° ±0.1", "36.9° ±0.1"), - ("50%", "50%"), - ("5%⌀6", "5%%%%%c6"), - ("%%", "%%%%"), - ("", ""), - ] { - let g = with(value); - assert_eq!(g.mtext_contents(), contents, "{value:?}"); - assert_eq!(decode_value(&g.mtext_contents()), value, "{value:?} reads back"); - assert!(!g.mtext_contents().contains('⌀')); - } - } -} - -#[cfg(test)] -mod text_budget_tests { - use super::*; - /// A dimension suffix without placeholders still obeys the total text limit. - #[test] - fn bounds_post_without_markers() { - let text = post(&"x".repeat(MAX_DIM_TEXT * 2), "12"); - assert_eq!(text.len(), MAX_DIM_TEXT); - assert!(text.starts_with("12x")); - } - /// Oversized substituted text retains its prefix and ends at a valid UTF-8 boundary. - #[test] - fn oversized_value_is_substituted_and_truncated_at_a_character_boundary() { - let text = bounded_replace("prefix <> mm", &"é".repeat(MAX_DIM_TEXT)); - assert!(text.starts_with("prefix é")); - assert!(!text.contains("<>")); - assert!(text.len() <= MAX_DIM_TEXT && text.len() >= MAX_DIM_TEXT - 1); - } -} +mod tests; diff --git a/crates/render/src/dim/arrowheads.rs b/crates/render/src/dim/arrowheads.rs new file mode 100644 index 000000000..5940a27e9 --- /dev/null +++ b/crates/render/src/dim/arrowheads.rs @@ -0,0 +1,197 @@ +use super::*; + +/// The arrowheads drawn in code (DIMBLK names; our own geometry). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Arrowhead { + ClosedFilled, + ClosedBlank, + Closed, + Dot, + DotSmall, + DotBlank, + Oblique, + ArchTick, + Open, + Open90, + Open30, + Origin, + Origin2, + Small, + BoxFilled, + BoxBlank, + DatumFilled, + DatumBlank, + Integral, + None, +} + +impl Arrowhead { + /// Parse a DIMBLK value ("" / "_DOT" / "Architectural tick" / "closed blank"…). + pub fn parse(name: &str) -> Arrowhead { + let n: String = name.trim().trim_start_matches('_').chars().filter(char::is_ascii_alphanumeric).collect::().to_ascii_lowercase(); + match n.as_str() { + "" | "closedfilled" | "filled" => Arrowhead::ClosedFilled, + "closedblank" | "blank" => Arrowhead::ClosedBlank, + "closed" => Arrowhead::Closed, + "dot" => Arrowhead::Dot, + "dotsmall" => Arrowhead::DotSmall, + "dotblank" => Arrowhead::DotBlank, + "oblique" => Arrowhead::Oblique, + "archtick" | "architecturaltick" | "tick" => Arrowhead::ArchTick, + "open" => Arrowhead::Open, + "open90" | "rightangle" => Arrowhead::Open90, + "open30" => Arrowhead::Open30, + "origin" | "originindicator" => Arrowhead::Origin, + "origin2" | "originindicator2" => Arrowhead::Origin2, + "small" => Arrowhead::Small, + "boxfilled" => Arrowhead::BoxFilled, + "boxblank" | "box" => Arrowhead::BoxBlank, + "datumfilled" | "datumtrianglefilled" => Arrowhead::DatumFilled, + "datumblank" | "datumtriangle" => Arrowhead::DatumBlank, + "integral" => Arrowhead::Integral, + "none" => Arrowhead::None, + _ => Arrowhead::ClosedFilled, + } + } + /// Ticks and obliques: the dimension line runs through the tip and extends by DIMDLE. + pub fn is_tick(self) -> bool { + matches!(self, Arrowhead::Oblique | Arrowhead::ArchTick | Arrowhead::Integral | Arrowhead::None) + } + /// How far back from the tip the dimension line stops (blank heads are not crossed). + pub fn inset(self, size: f64) -> f64 { + match self { + Arrowhead::ClosedBlank | Arrowhead::DatumBlank => size, + Arrowhead::DotBlank => size / 4.0, + Arrowhead::Origin | Arrowhead::Origin2 => size / 2.0, + Arrowhead::BoxBlank => size / 4.0, + _ => 0.0, + } + } +} + +/// Arrowhead geometry: open polylines and filled triangles. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ArrowGeom { + pub lines: Vec>, + pub tris: Vec, +} + +fn circle_pts(c: Vec2, r: f64) -> Vec { + (0..=24).map(|i| c + Vec2::from_angle(f64::from(i) * cadcraft_geom::TAU / 24.0) * r).collect() +} + +fn disc(c: Vec2, r: f64) -> Vec { + let mut t = Vec::with_capacity(72); + for i in 0..24 { + let a0 = f64::from(i) * cadcraft_geom::TAU / 24.0; + let a1 = f64::from(i + 1) * cadcraft_geom::TAU / 24.0; + t.extend_from_slice(&[c, c + Vec2::from_angle(a0) * r, c + Vec2::from_angle(a1) * r]); + } + t +} + +/// An arrowhead at `tip` pointing along `dir` (unit), `size` = DIMASZ × scale. +pub fn arrowhead(kind: Arrowhead, tip: Vec2, dir: Vec2, size: f64) -> ArrowGeom { + let dir = if dir == Vec2::ZERO || !dir.is_finite() { Vec2::X } else { dir }; + let size = if size.is_finite() { size.abs() } else { 0.0 }; + let n = dir.perp(); + let wings = |half_angle: f64, len: f64| { + let back = -dir; + (tip + back.rotate(half_angle) * len, tip + back.rotate(-half_angle) * len) + }; + let mut g = ArrowGeom::default(); + match kind { + Arrowhead::ClosedFilled => g.tris = arrow(tip, dir, size), + Arrowhead::Small => g.tris = arrow(tip, dir, size / 2.0), + Arrowhead::ClosedBlank | Arrowhead::Closed => { + let back = tip - dir * size; + let w = n * (size / 6.0); + g.lines.push(vec![tip, back + w, back - w, tip]); + if kind == Arrowhead::Closed { + g.lines.push(vec![back, tip]); + } + } + Arrowhead::Dot => g.tris = disc(tip, size / 4.0), + Arrowhead::DotSmall => g.tris = disc(tip, size / 8.0), + Arrowhead::DotBlank => g.lines.push(circle_pts(tip, size / 4.0)), + Arrowhead::Origin => g.lines.push(circle_pts(tip, size / 2.0)), + Arrowhead::Origin2 => { + g.lines.push(circle_pts(tip, size / 2.0)); + g.lines.push(circle_pts(tip, size / 4.0)); + } + Arrowhead::Oblique => { + let t = canonical(dir + n).normalized() * (size / 2.0); + g.lines.push(vec![tip - t, tip + t]); + } + Arrowhead::ArchTick => { + // A heavy 45° stroke: a thin filled parallelogram. + let t = canonical(dir + n).normalized() * (size / 2.0); + let w = t.perp().normalized() * (size / 16.0); + let (a, b) = (tip - t, tip + t); + g.tris = vec![a - w, b - w, b + w, a - w, b + w, a + w]; + } + Arrowhead::Integral => { + // A small integral-sign wave through the tip. + let t = canonical(dir + n).normalized(); + let pts = (-8..=8) + .map(|i| { + let f = f64::from(i) / 8.0; + tip + t * (f * size / 2.0) + t.perp() * ((f * std::f64::consts::PI).sin() * size * 0.12) + }) + .collect(); + g.lines.push(pts); + } + Arrowhead::Open => { + let (a, b) = wings((1.0f64 / 6.0).atan(), size); + g.lines.push(vec![a, tip, b]); + } + Arrowhead::Open90 => { + let (a, b) = wings(std::f64::consts::FRAC_PI_4, size / std::f64::consts::SQRT_2); + g.lines.push(vec![a, tip, b]); + } + Arrowhead::Open30 => { + let (a, b) = wings(15f64.to_radians(), size); + g.lines.push(vec![a, tip, b]); + } + Arrowhead::BoxFilled | Arrowhead::BoxBlank => { + let h = size / 4.0; + let (u, v) = (dir * h, n * h); + let q = [tip - u - v, tip + u - v, tip + u + v, tip - u + v]; + if kind == Arrowhead::BoxFilled { + g.tris = vec![q[0], q[1], q[2], q[0], q[2], q[3]]; + } else { + g.lines.push(vec![q[0], q[1], q[2], q[3], q[0]]); + } + } + Arrowhead::DatumFilled | Arrowhead::DatumBlank => { + let apex = tip - dir * size; + let w = n * (size / 2.0); + if kind == Arrowhead::DatumFilled { + g.tris = vec![tip + w, tip - w, apex]; + } else { + g.lines.push(vec![tip + w, tip - w, apex, tip + w]); + } + } + Arrowhead::None => {} + } + g +} + +/// A closed filled arrowhead at `tip` pointing along `dir` (unit), as one triangle. +pub fn arrow(tip: Vec2, dir: Vec2, size: f64) -> Vec { + let back = tip - dir * size; + let n = dir.perp() * (size / 6.0); + vec![tip, back + n, back - n] +} + +/// Ticks lean the same way at both ends of a dimension line whichever way they point. +fn canonical(v: Vec2) -> Vec2 { + if v.x < -1e-12 || (v.x.abs() <= 1e-12 && v.y < 0.0) { -v } else { v } +} + +/// A DIMTSZ tick: an oblique stroke centred on `at`. +pub(super) fn tick(at: Vec2, dir: Vec2, half: f64) -> Vec { + let d = canonical(dir); + let t = (d + d.perp()).normalized() * half; + vec![at - t, at + t] +} diff --git a/crates/render/src/dim/tests.rs b/crates/render/src/dim/tests.rs new file mode 100644 index 000000000..4f9143874 --- /dev/null +++ b/crates/render/src/dim/tests.rs @@ -0,0 +1,412 @@ +use super::*; +use cadcraft_geom::Vec3; + +fn lin(p1: (f64, f64), p2: (f64, f64), on: (f64, f64), rot: f64) -> Dimension { + Dimension { + kind: DimKind::Linear { rotation: rot }, + defpt: Vec3::new(on.0, on.1, 0.0), + text_mid: Vec3::ZERO, + p13: Vec3::new(p1.0, p1.1, 0.0), + p14: Vec3::new(p2.0, p2.1, 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(), + } +} + +fn st_with(f: impl Fn(&mut DimStyle)) -> DimStyle { + let mut s = DimStyle::default(); + f(&mut s); + s +} + +fn bbox(pts: &[Vec]) -> cadcraft_geom::Bounds2 { + cadcraft_geom::Bounds2::from_points(pts.iter().flatten().copied()) +} + +#[test] +fn horizontal_measures_dx() { + let g = dimension_geometry(&lin((0.0, 0.0), (10.0, 3.0), (0.0, 5.0), 0.0), &DimStyle::default(), 1.0); + assert_eq!(g.value, "10.0000"); + assert_eq!(g.measurement, 10.0); + assert!(!g.text.is_empty()); + assert_eq!(g.fills.len(), 2); + assert_eq!(g.lines_of(LineRole::Ext).count(), 2); +} + +#[test] +fn vertical_measures_dy() { + let g = dimension_geometry(&lin((0.0, 0.0), (10.0, 3.0), (15.0, 0.0), std::f64::consts::FRAC_PI_2), &DimStyle::default(), 1.0); + assert_eq!(g.value, "3.0000"); + assert!((g.measurement - 3.0).abs() < 1e-12, "{}", g.measurement); +} + +#[test] +fn override_text() { + let mut d = lin((0.0, 0.0), (10.0, 0.0), (0.0, 5.0), 0.0); + d.text = "<> TYP".into(); + let g = dimension_geometry(&d, &DimStyle::default(), 1.0); + assert_eq!(g.value, "10.0000 TYP"); + d.text = " ".into(); + assert_eq!(dimension_geometry(&d, &DimStyle::default(), 1.0).value, ""); +} + +#[test] +fn iso_suppresses_zeros() { + let g = dimension_geometry(&lin((0.0, 0.0), (12.5, 0.0), (0.0, 5.0), 0.0), &DimStyle::iso25(), 1.0); + assert_eq!(g.value, "12.5"); +} + +#[test] +fn ticks_replace_arrows_and_extend_line() { + let st = st_with(|s| { + s.tick_size = 0.1; + s.dim_line_extend = 0.2; + }); + let g = dimension_geometry(&lin((0.0, 0.0), (10.0, 0.0), (0.0, 5.0), 0.0), &st, 1.0); + assert!(g.fills.is_empty(), "no filled arrows with ticks"); + // Two 45° ticks of half-length 0.1 centred on the feet. + let ticks: Vec<_> = g.lines.iter().filter(|l| l.len() == 2 && ((l[1] - l[0]).angle().to_degrees() - 45.0).abs() < 1e-6).collect(); + assert_eq!(ticks.len(), 2); + for t in &ticks { + assert!((t[0].dist(t[1]) - 0.2).abs() < 1e-9); + assert!((t[0].mid(t[1]).y - 5.0).abs() < 1e-9); + } + // The dimension line runs past both extension lines by DIMDLE. + let b = bbox(&g.lines_of(LineRole::Dim).cloned().collect::>()); + assert!((b.min.x + 0.2).abs() < 1e-9 && (b.max.x - 10.2).abs() < 1e-9, "{b:?}"); +} + +#[test] +fn arrowhead_shapes() { + let tip = Vec2::new(1.0, 1.0); + let dir = Vec2::X; + let cf = arrowhead(Arrowhead::ClosedFilled, tip, dir, 1.0); + assert_eq!(cf.tris.len(), 3); + assert!(cf.tris.contains(&tip)); + let cb = arrowhead(Arrowhead::ClosedBlank, tip, dir, 1.0); + assert!(cb.tris.is_empty() && cb.lines.len() == 1 && cb.lines[0].len() == 4); + assert_eq!(cb.lines[0].first(), cb.lines[0].last()); + let dot = arrowhead(Arrowhead::Dot, tip, dir, 1.0); + let r = dot.tris.iter().map(|p| p.dist(tip)).fold(0.0, f64::max); + assert!((r - 0.25).abs() < 1e-9, "dot radius {r}"); + let ob = arrowhead(Arrowhead::Oblique, tip, dir, 1.0); + assert_eq!(ob.lines.len(), 1); + assert!(((ob.lines[0][1] - ob.lines[0][0]).angle().to_degrees() - 45.0).abs() < 1e-9); + let at = arrowhead(Arrowhead::ArchTick, tip, dir, 1.0); + assert_eq!(at.tris.len(), 6); + let open = arrowhead(Arrowhead::Open, tip, dir, 1.0); + assert!(open.tris.is_empty() && open.lines[0].len() == 3 && open.lines[0][1] == tip); + let o90 = arrowhead(Arrowhead::Open90, tip, dir, 1.0); + let (a, b) = (o90.lines[0][0] - tip, o90.lines[0][2] - tip); + assert!(a.dot(b).abs() < 1e-9, "open 90 wings are perpendicular"); + let origin = arrowhead(Arrowhead::Origin, tip, dir, 1.0); + assert!(origin.lines[0].iter().all(|p| (p.dist(tip) - 0.5).abs() < 1e-9)); + assert_eq!(arrowhead(Arrowhead::None, tip, dir, 1.0), ArrowGeom::default()); + assert_eq!(Arrowhead::parse("_ARCHTICK"), Arrowhead::ArchTick); + assert_eq!(Arrowhead::parse("Architectural tick"), Arrowhead::ArchTick); + assert_eq!(Arrowhead::parse("closed blank"), Arrowhead::ClosedBlank); + assert_eq!(Arrowhead::parse(""), Arrowhead::ClosedFilled); + assert_eq!(Arrowhead::parse("_ORIGIN"), Arrowhead::Origin); + assert_eq!(Arrowhead::parse("whatever"), Arrowhead::ClosedFilled); +} + +#[test] +fn dimblk_dot_and_none() { + let st = st_with(|s| s.arrow_block = "_DOT".into()); + let g = dimension_geometry(&lin((0.0, 0.0), (10.0, 0.0), (0.0, 5.0), 0.0), &st, 1.0); + assert_eq!(g.fills.len(), 48, "two 24-triangle dots"); + let st = st_with(|s| { + s.arrow_block1 = "_NONE".into(); + s.arrow_block2 = "_OPEN".into(); + }); + let g = dimension_geometry(&lin((0.0, 0.0), (10.0, 0.0), (0.0, 5.0), 0.0), &st, 1.0); + assert!(g.fills.is_empty()); + assert!(g.lines.iter().any(|l| l.len() == 3)); +} + +#[test] +fn text_above_and_centred() { + let d = lin((0.0, 0.0), (10.0, 0.0), (0.0, 5.0), 0.0); + let centred = dimension_geometry(&d, &DimStyle::default(), 1.0); + assert!((centred.text_pos.y - 5.0).abs() < 1e-9); + // Centred text breaks the dimension line in two. + assert_eq!(centred.lines_of(LineRole::Dim).count(), 2); + let above = dimension_geometry(&d, &st_with(|s| s.text_above = 1), 1.0); + assert!((above.text_pos.y - (5.0 + 0.09 + 0.18 * 0.6)).abs() < 1e-6, "{}", above.text_pos.y); + assert_eq!(above.lines_of(LineRole::Dim).count(), 1); + let below = dimension_geometry(&d, &st_with(|s| s.text_above = 4), 1.0); + assert!(below.text_pos.y < 5.0); + // Outside: away from the measured points (they are below the line here). + let outside = dimension_geometry(&d, &st_with(|s| s.text_above = 2), 1.0); + assert!(outside.text_pos.y > 5.0); + let d2 = lin((0.0, 10.0), (10.0, 10.0), (0.0, 5.0), 0.0); + let outside2 = dimension_geometry(&d2, &st_with(|s| s.text_above = 2), 1.0); + assert!(outside2.text_pos.y < 5.0); +} + +#[test] +fn dimtad_one_uses_autocad_text_clearance() { + let d = lin((0.0, 0.10), (0.0, 0.70), (0.30, 0.40), std::f64::consts::FRAC_PI_2); + let st = st_with(|s| { + s.scale = 50.0; + s.text_height = 0.0025; + s.text_gap = 0.0008; + s.arrow_size = 0.0015; + s.text_above = 1; + s.text_inside_horizontal = false; + s.decimals = 2; + }); + let g = dimension_geometry(&d, &st, 50.0); + // Half the text height (0.0625) plus DIMGAP (0.04) put the centre at 0.1025; + // AutoCAD 2026 recomputes it at about 0.115 from the line (#2004). + assert!( + (0.30 - g.text_pos.x - 0.115).abs() < 0.001, + "text x={} offset={} angle={} text={}", + g.text_pos.x, + 0.30 - g.text_pos.x, + g.text_angle, + g.value + ); +} + +#[test] +fn text_alignment_vertical_dimension() { + let d = lin((0.0, 0.0), (0.0, 10.0), (5.0, 0.0), std::f64::consts::FRAC_PI_2); + let h = dimension_geometry(&d, &DimStyle::default(), 1.0); + assert!(h.text_angle.abs() < 1e-12, "DIMTIH on: horizontal"); + let a = dimension_geometry(&d, &st_with(|s| s.text_inside_horizontal = false), 1.0); + assert!((a.text_angle - std::f64::consts::FRAC_PI_2).abs() < 1e-9, "aligned: {}", a.text_angle); +} + +#[test] +fn justification_moves_text() { + let d = lin((0.0, 0.0), (10.0, 0.0), (0.0, 5.0), 0.0); + let j1 = dimension_geometry(&d, &st_with(|s| s.text_just = 1), 1.0); + let j2 = dimension_geometry(&d, &st_with(|s| s.text_just = 2), 1.0); + assert!(j1.text_pos.x < 5.0 && j2.text_pos.x > 5.0); + let j3 = dimension_geometry(&d, &st_with(|s| s.text_just = 3), 1.0); + assert!((j3.text_angle - std::f64::consts::FRAC_PI_2).abs() < 1e-9); + assert!(j3.text_pos.y > 5.0 && j3.text_pos.x < 0.0); +} + +#[test] +fn text_moves_outside_when_it_does_not_fit() { + let d = lin((0.0, 0.0), (0.5, 0.0), (0.0, 5.0), 0.0); + let g = dimension_geometry(&d, &DimStyle::default(), 1.0); + assert!(g.text_pos.x > 0.5, "text beyond the second extension line: {}", g.text_pos.x); +} + +#[test] +fn text_that_does_not_fit_follows_dimtmove_2() { + // Vertical, measured points left of the line: the text goes over the middle of the + // line, on the side away from them, and the line is not carried out to it. + let d = lin((0.0, 0.0), (0.0, 0.5), (5.0, 0.0), std::f64::consts::FRAC_PI_2); + let g = dimension_geometry(&d, &st_with(|s| s.text_move = 2), 1.0); + assert!((g.text_pos.y - 0.25).abs() < 1e-9 && g.text_pos.x > 5.2, "{:?}", g.text_pos); + assert!(g.lines_of(LineRole::Dim).flatten().all(|p| p.y <= 0.5 + 1e-9 && p.x <= 5.0 + 1e-9), "no leader, no line out to the text"); + // Horizontal, measured points above the line: below it. + let d = lin((0.0, 10.0), (0.5, 10.0), (0.0, 5.0), 0.0); + let g = dimension_geometry(&d, &st_with(|s| s.text_move = 2), 1.0); + assert!((g.text_pos.x - 0.25).abs() < 1e-9 && g.text_pos.y < 4.8, "{:?}", g.text_pos); + assert!(g.lines_of(LineRole::Dim).flatten().all(|p| p.x <= 0.5 + 1e-9 && p.y >= 5.0 - 1e-9), "no leader, no line out to the text"); +} + +#[test] +fn text_that_does_not_fit_follows_dimtmove_1() { + // Moved off the line with a leader from the middle of the dimension line. + let d = lin((0.0, 0.0), (0.5, 0.0), (0.0, 5.0), 0.0); + let g = dimension_geometry(&d, &st_with(|s| s.text_move = 1), 1.0); + assert!(g.text_pos.y > 5.2, "{:?}", g.text_pos); + let mid = Vec2::new(0.25, 5.0); + assert!(g.lines_of(LineRole::Dim).any(|l| l.len() == 2 && l[0].dist(mid) < 1e-9 && l[1].y > 5.1), "leader"); + // Text placed with DIMTEDIT stays where it is. + let mut placed = d.clone(); + placed.user_text_pos = true; + placed.text_mid = Vec3::new(3.0, 7.0, 0.0); + let g = dimension_geometry(&placed, &st_with(|s| s.text_move = 1), 1.0); + assert!(g.text_pos.dist(Vec2::new(3.0, 7.0)) < 1e-9, "{:?}", g.text_pos); +} + +#[test] +fn tolerance_and_limits() { + let d = lin((0.0, 0.0), (10.0, 0.0), (0.0, 5.0), 0.0); + let sym = dimension_geometry( + &d, + &st_with(|s| { + s.tolerance = true; + s.tol_plus = 0.05; + s.tol_minus = 0.05; + s.decimals = 2; + s.tol_decimals = 2; + }), + 1.0, + ); + assert_eq!(sym.value, "10.00±0.05"); + let dev = dimension_geometry( + &d, + &st_with(|s| { + s.tolerance = true; + s.tol_plus = 0.1; + s.tol_minus = 0.2; + s.decimals = 2; + s.tol_decimals = 1; + }), + 1.0, + ); + assert_eq!(dev.value, "10.00+0.1/-0.2"); + assert!(dev.mtext.contains("\\S+0.1^-0.2;")); + // Stacked: the text extends above and below a single line of text. + let plain = dimension_geometry(&d, &st_with(|s| s.decimals = 2), 1.0); + assert!(bbox(&dev.text).height() > bbox(&plain.text).height() * 1.3); + let lim = dimension_geometry( + &d, + &st_with(|s| { + s.limits = true; + s.tol_plus = 0.1; + s.tol_minus = 0.2; + s.decimals = 1; + }), + 1.0, + ); + assert_eq!(lim.value, "10.1/9.8"); +} + +#[test] +fn unit_formats_and_post() { + let d = lin((0.0, 0.0), (18.5, 0.0), (0.0, 5.0), 0.0); + let arch = dimension_geometry(&d, &st_with(|s| s.linear_unit = 4), 1.0); + assert_eq!(arch.value, "1'-6 1/2\""); + let frac = dimension_geometry(&d, &st_with(|s| s.linear_unit = 5), 1.0); + assert_eq!(frac.value, "18 1/2"); + let sci = dimension_geometry( + &d, + &st_with(|s| { + s.linear_unit = 1; + s.decimals = 2; + }), + 1.0, + ); + assert_eq!(sci.value, "1.85E+01"); + let post = dimension_geometry( + &d, + &st_with(|s| { + s.post = "<> mm".into(); + s.decimals = 1; + }), + 1.0, + ); + assert_eq!(post.value, "18.5 mm"); + let alt = dimension_geometry( + &lin((0.0, 0.0), (1.0, 0.0), (0.0, 5.0), 0.0), + &st_with(|s| { + s.alt = true; + s.decimals = 2; + }), + 1.0, + ); + assert_eq!(alt.value, "1.00 [25.40]"); +} + +#[test] +fn overrides_apply_per_dimension() { + let mut d = lin((0.0, 0.0), (10.0, 0.0), (0.0, 5.0), 0.0); + d.overrides.insert("DIMDEC".into(), serde_json::json!(1)); + d.overrides.insert("textAbove".into(), serde_json::json!(1)); + let g = dimension_geometry(&d, &DimStyle::default(), 1.0); + assert_eq!(g.value, "10.0"); + assert!(g.text_pos.y > 5.0); +} + +#[test] +fn radial_and_angular_text() { + let mut d = lin((0.0, 0.0), (0.0, 0.0), (0.0, 0.0), 0.0); + d.kind = DimKind::Radius; + d.defpt = Vec3::new(0.0, 0.0, 0.0); + d.p15 = Vec3::new(3.0, 0.0, 0.0); + let g = dimension_geometry(&d, &st_with(|s| s.decimals = 1), 1.0); + assert_eq!(g.value, "R3.0"); + d.kind = DimKind::Diameter; + d.defpt = Vec3::new(-3.0, 0.0, 0.0); + let g = dimension_geometry(&d, &st_with(|s| s.decimals = 1), 1.0); + assert_eq!(g.value, "⌀6.0"); + assert_eq!(g.mtext_contents(), "%%c6.0", "the *D block's text (#1645)"); + let mut a = lin((10.0, 0.0), (0.0, 10.0), (3.0, 3.0), 0.0); + a.kind = DimKind::Angular3P; + a.p15 = Vec3::ZERO; + let g = dimension_geometry(&a, &DimStyle::default(), 1.0); + assert_eq!(g.value, "90°"); + let g = dimension_geometry( + &a, + &st_with(|s| { + s.angular_unit = 3; + s.angular_decimals = 2; + }), + 1.0, + ); + assert_eq!(g.value, "1.57r"); +} + +#[test] +fn hostile_styles_do_not_panic() { + let d = lin((0.0, 0.0), (10.0, 0.0), (0.0, 5.0), 0.0); + for st in [ + st_with(|s| s.arrow_size = f64::NAN), + st_with(|s| s.text_height = -1.0), + st_with(|s| s.tick_size = f64::INFINITY), + st_with(|s| s.text_just = 200), + st_with(|s| s.text_above = 99), + st_with(|s| s.scale = 0.0), + st_with(|s| s.decimals = 255), + ] { + let _ = dimension_geometry(&d, &st, 1.0); + } + let z = lin((0.0, 0.0), (0.0, 0.0), (0.0, 0.0), 0.0); + let _ = dimension_geometry(&z, &DimStyle::default(), 1.0); +} + +#[test] +fn jogged_radius_with_nan_radius_draws_no_lines() { + let mut d = lin((0.0, 0.0), (0.0, 0.0), (0.0, 0.0), 0.0); + d.kind = DimKind::Radius; + d.p15 = Vec3::new(10.0, 0.0, 0.0); + d.marks.jogged = Some(cadcraft_doc::JoggedRadius { center: Vec3::new(0.0, -4.0, 0.0), jog: Vec3::new(5.0, -2.0, 0.0) }); + // Finite control: the jogged line runs from the arrowhead on the arc to the override centre. + let g = dimension_geometry(&d, &DimStyle::default(), 1.0); + let jog = g.lines.iter().find(|l| l.len() == 4).expect("jogged dimension line"); + assert!(jog[0].near(Vec2::new(10.0, 0.0), 1e-9) && jog[3].near(Vec2::new(0.0, -4.0), 1e-9)); + // A NaN in the tip makes the radius NaN: nothing usable to draw, and no panic. + d.p15 = Vec3::new(f64::NAN, 0.0, 0.0); + let g = dimension_geometry(&d, &DimStyle::default(), 1.0); + assert!(g.lines.is_empty() && g.fills.is_empty(), "{:?}", g.lines); +} + +/// #1645: the MTEXT contents give back the dimension's text when decoded, with the diameter +/// sign as `%%c` (no SHX font has U+2300) and a `%` that would start a code escaped. +#[test] +fn mtext_contents_write_the_diameter_sign_as_a_code() { + let with = |v: &str| DimGeometry { value: v.into(), ..DimGeometry::default() }; + for (value, contents) in [ + ("⌀60", "%%c60"), + ("2x ⌀8.5 THRU", "2x %%c8.5 THRU"), + ("R3.0", "R3.0"), + ("36.9° ±0.1", "36.9° ±0.1"), + ("50%", "50%"), + ("5%⌀6", "5%%%%%c6"), + ("%%", "%%%%"), + ("", ""), + ] { + let g = with(value); + assert_eq!(g.mtext_contents(), contents, "{value:?}"); + assert_eq!(decode_value(&g.mtext_contents()), value, "{value:?} reads back"); + assert!(!g.mtext_contents().contains('⌀')); + } +} diff --git a/crates/render/src/dim/text.rs b/crates/render/src/dim/text.rs new file mode 100644 index 000000000..fb3d6eda7 --- /dev/null +++ b/crates/render/src/dim/text.rs @@ -0,0 +1,152 @@ +use super::*; + +/// Format a linear measurement per the style (plain text). +pub fn format_linear_value(v: f64, st: &DimStyle) -> String { + format_linear(v * st.linear_factor, &NumFormat::from_style(st)).0 +} + +/// The text of a linear measurement: (plain, MTEXT) with DIMPOST, tolerances/limits and +/// alternate units, then the user's override text (`<>` = the measurement). DIMRND rounds the +/// measurement (and so the limits) only: tolerances are never rounded and alternate units +/// round by DIMALTRND. +pub(super) fn linear_text(meas: f64, d: &Dimension, st: &DimStyle, prefix: &str) -> String { + let v = meas * st.linear_factor; + let nf = NumFormat::from_style(st); + let main = if st.limits { + let up = format_linear(v + st.tol_plus, &nf).1; + let lo = format_linear(v - st.tol_minus, &nf).1; + format!("{{\\H{};\\S{up}^{lo};}}", tol_height_code(st)) + } else { + format!("{prefix}{}", format_linear(v, &nf).1) + }; + let mut s = post(&st.post, &main); + if st.tolerance && !st.limits { + let tf = NumFormat { decimals: st.tol_decimals, zin: st.tol_zero_suppression, round: 0.0, ..nf.clone() }; + // DIMPOST's suffix (what follows `<>`, or all of it) also goes on the tolerance values. + let suffix = st.post.split_once("<>").map_or(st.post.as_str(), |(_, after)| after); + if (st.tol_plus - st.tol_minus).abs() < 1e-12 { + s += &format!("%%p{}{suffix}", format_linear(st.tol_plus, &tf).1); + } else { + let plus = format_linear(st.tol_plus, &tf).1 + suffix; + let minus = format_linear(st.tol_minus, &tf).1 + suffix; + let sign = |x: &str, pos: char| if x.trim_start_matches(['0', '.', ',']).is_empty() { x.to_string() } else { format!("{pos}{x}") }; + let (plus, minus) = + (sign(&plus, '+'), if st.tol_minus < 0.0 { format!("+{}", minus.trim_start_matches('-')) } else { sign(&minus, '-') }); + // DIMTOLJ: bottom / middle / top (MTEXT `\A`; middle is the default). + let tolj = if st.tol_just == 1 { String::new() } else { format!("\\A{};", st.tol_just.min(2)) }; + s += &format!("{{\\H{};{tolj}\\S{plus}^{minus};}}", tol_height_code(st)); + } + } + if st.alt { + // DIMALTU 6 and 7 are architectural and fractional without stacking, 8 is decimal. + let (unit, frac) = match st.alt_unit { + u @ 1..=5 => (u, nf.frac), + 6 => (4, 2), + 7 => (5, 2), + _ => (2, nf.frac), + }; + let af = NumFormat { unit, decimals: st.alt_decimals, zin: st.alt_zero_suppression, round: st.alt_round, frac, ..nf }; + let alt = format_linear(v * st.alt_factor, &af).1; + s += &format!(" [{}]", post(&st.alt_post, &alt)); + } + inspected(apply_override(&s, d), d) +} + +/// DIMINSPECT: the label and the rate either side of the value, two spaces apart. +pub(super) fn inspected(value: String, d: &Dimension) -> String { + match &d.marks.inspect { + Some(ins) => [ins.label.as_str(), value.as_str(), ins.rate.as_str()].iter().filter(|p| !p.is_empty()).copied().collect::>().join(" "), + None => value, + } +} + +/// `\H` code for stacked tolerance parts (DIMTFAC relative to the 70% stack scale). +fn tol_height_code(st: &DimStyle) -> String { + let f = if st.tol_scale.is_finite() && st.tol_scale > 0.0 { st.tol_scale } else { 1.0 }; + format!("{}x", format_decimal(f / 0.7, 4, 8, ".")) +} + +/// Apply measurement placeholders or append a dimension suffix, bounding the resulting UTF-8 text. +fn post(template: &str, meas: &str) -> String { + if template.contains("<>") { + bounded_replace(template, meas) + } else { + let mut out = String::new(); + append_dim_text(&mut out, meas); + append_dim_text(&mut out, template); + out + } +} + +/// Apply dimension text overrides: empty uses the base, whitespace suppresses text, and other text substitutes it. +pub(super) fn apply_override(base: &str, d: &Dimension) -> String { + if d.text.is_empty() { + base.to_string() + } else if d.text.trim().is_empty() { + String::new() + } else { + bounded_replace(&d.text, base) + } +} + +/// Largest dimension text produced by substituting the measurement into style/override text. Two +/// modest strings can otherwise expand into gigabytes when the template holds many `<>` markers +/// (CWE-789); past this the output is truncated rather than allocated unbounded. +const MAX_DIM_TEXT: usize = 64 * 1024; + +/// Replace every `<>` in `template` with `value`, with the expanded length checked against +/// [`MAX_DIM_TEXT`] before allocating so a many-placeholder template cannot blow up memory. +fn bounded_replace(template: &str, value: &str) -> String { + let mut out = String::new(); + let mut rest = template; + while let Some(i) = rest.find("<>") { + if !append_dim_text(&mut out, &rest[..i]) || !append_dim_text(&mut out, value) { + return out; + } + rest = &rest[i + 2..]; + } + append_dim_text(&mut out, rest); + out +} + +// Returns false when truncation consumed the remaining UTF-8-safe space. +/// Append within the remaining dimension-text byte allowance without splitting a UTF-8 character. +/// Return false if any input text had to be truncated. +fn append_dim_text(out: &mut String, text: &str) -> bool { + let mut end = text.len().min(MAX_DIM_TEXT.saturating_sub(out.len())); + while !text.is_char_boundary(end) { + end -= 1; + } + out.push_str(&text[..end]); + end == text.len() +} + +/// Font and fixed height of the dimension text (from DIMTXSTY's text style). +#[derive(Clone, Debug, Default)] +pub struct DimText { + pub font: TextFont, + /// Fixed text height of the style (0 = use DIMTXT × scale). + pub fixed_height: f64, + pub width_factor: f64, + pub oblique: f64, +} + +#[cfg(test)] +mod text_budget_tests { + use super::*; + /// A dimension suffix without placeholders still obeys the total text limit. + #[test] + fn bounds_post_without_markers() { + let text = post(&"x".repeat(MAX_DIM_TEXT * 2), "12"); + assert_eq!(text.len(), MAX_DIM_TEXT); + assert!(text.starts_with("12x")); + } + /// Oversized substituted text retains its prefix and ends at a valid UTF-8 boundary. + #[test] + fn oversized_value_is_substituted_and_truncated_at_a_character_boundary() { + let text = bounded_replace("prefix <> mm", &"é".repeat(MAX_DIM_TEXT)); + assert!(text.starts_with("prefix é")); + assert!(!text.contains("<>")); + assert!(text.len() <= MAX_DIM_TEXT && text.len() >= MAX_DIM_TEXT - 1); + } +} diff --git a/crates/render/src/dim_draw.rs b/crates/render/src/dim_draw.rs new file mode 100644 index 000000000..b31ff13b4 --- /dev/null +++ b/crates/render/src/dim_draw.rs @@ -0,0 +1,188 @@ +use super::display::Kind; +use super::draw::stroke_with; +use super::props::default_lineweight; +use super::text_entity::{layout_mtext_entity, place_text_entity, text_style}; +use super::*; + +/// The font settings of a dimension style's text style. +pub fn dim_text(d: &Drawing, st: &cadcraft_doc::DimStyle) -> DimText { + let ts = text_style(d, &st.text_style); + DimText { font: cadcraft_fonts::TextFont::resolve(&ts.font), fixed_height: ts.height, width_factor: ts.width_factor, oblique: ts.oblique } +} + +/// Geometry of a dimension in a drawing: its style, overrides and text font. +pub fn dimension_in(d: &Drawing, dm: &cadcraft_doc::Dimension) -> DimGeometry { + let style = d.dim_style(&dm.style).cloned().unwrap_or_default(); + let st = style.with_overrides(&dm.overrides); + dim::dimension_geometry_with(dm, &style, d.header.f64("DIMSCALE", 1.0), &dim_text(d, &st)) +} + +/// `lw` / `lt` are the dimension's own lineweight and linetype (ByBlock in DIMLWD, DIMLWE, +/// DIMLTYPE, DIMLTEX1 and DIMLTEX2). +pub(crate) fn dimension( + b: &mut Builder, + ctx: &Ctx, + e: &Entity, + dm: &cadcraft_doc::Dimension, + lw: f32, + lt: Option<&cadcraft_doc::Linetype>, + ltscale: f64, +) { + let d = ctx.d; + let style = d.dim_style(&dm.style).cloned().unwrap_or_default().with_overrides(&dm.overrides); + let g = dimension_in(d, dm); + // DIMCLRD / DIMCLRE / DIMCLRT: ByBlock = the dimension's own colour. + 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 dim_rgb = ink(style.dim_line_color, layer_color, own); + let ext_rgb = ink(style.ext_line_color, layer_color, own); + let txt_rgb = ink(style.text_color, layer_color, own); + let layer = d.layer(layer_name); + let weight = |v: i16| -> f32 { + if !b.opts.lineweights { + return 0.0; + } + match Lineweight::from_dxf(v) { + Lineweight::ByBlock => lw, + Lineweight::Mm100(x) => f32::from(x) / 100.0, + Lineweight::ByLayer => match layer.map(|l| l.lineweight) { + Some(Lineweight::Mm100(x)) => f32::from(x) / 100.0, + _ => default_lineweight(d), + }, + Lineweight::Default => default_lineweight(d), + } + }; + let ltype = |name: &str| -> Option { + match name.trim().to_ascii_lowercase().as_str() { + "" | "byblock" => lt.cloned(), + "bylayer" => layer.and_then(|l| d.linetype(&l.linetype)).filter(|l| !l.pattern.is_empty()).cloned(), + _ => d.linetype(name.trim()).filter(|l| !l.pattern.is_empty()).cloned(), + } + }; + 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)); + 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). +pub(crate) 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. +pub(crate) 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. +pub(crate) 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. +pub(crate) const MAX_FILL_TEXTS: usize = 64; + +pub(crate) 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). +pub(crate) 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)) +} diff --git a/crates/render/src/display.rs b/crates/render/src/display.rs new file mode 100644 index 000000000..b260daf8a --- /dev/null +++ b/crates/render/src/display.rs @@ -0,0 +1,154 @@ +use super::*; + +/// Handle carried by model-space geometry drawn inside a paper-space viewport (it belongs to no +/// single paper-space entity, so selection highlighting never matches it). +pub const VIEWPORT_CONTENT: Handle = Handle(0); + +/// [`DisplayList::prim_layers`] of a primitive whose layer isn't in the layer table. +pub const NO_LAYER: u32 = u32::MAX; + +/// What a display-list primitive draws. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Kind { + /// `verts[start..start+len]` as a connected polyline. + Polyline, + /// `tris[start..start+len]`, three vertices per triangle. + Tris, + /// A wipeout: triangles laid out like [`Kind::Tris`], filled with the background colour + /// (paper white when plotting, the canvas colour on screen) so they hide what was drawn + /// before them. + Mask, + /// A single point marker at `verts[start]`. + Point, + /// An infinite line or ray: `verts[start]` = base, `verts[start+1]` = unit direction. + Infinite { ray: bool }, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct DPrim { + pub handle: Handle, + pub color: Rgb, + /// Colour 7 (directly, by layer or by block): drawn white on a dark background and black on + /// a light one, see [`DPrim::display_rgb`]. Other colours keep their RGB. + pub aci7: bool, + /// Lineweight in mm (0 = thinnest). + pub lw: f32, + /// Transparency in percent (0 = opaque, at most 90): the object's own, its layer's when + /// ByLayer, the block reference's when ByBlock. Always 0 unless [`Options::transparency`]. + pub transparency: u8, + pub kind: Kind, + pub start: u32, + pub len: u32, +} + +#[derive(Clone, Debug, Default)] +pub struct DisplayList { + pub prims: Vec, + pub verts: Vec, + pub tris: Vec, + /// Plots ([`build_plot`]): for each primitive, the index in [`Drawing::layers`] of the layer + /// it is drawn on (block contents on layer 0: the block reference's; [`NO_LAYER`] when the + /// layer isn't in the table), for PDF layers. Empty for other lists. + pub prim_layers: Vec, + /// Plots: outline-font glyphs that a plotter can write as text instead of drawing the + /// filled outline primitive they stand for ([`PlotText::prim`]), in drawing order. + pub texts: Vec, + pub bounds: Bounds2, + /// The paper sheet when the list was built for a layout (paper space); the UI draws the + /// white sheet, its shadow and the printable-area outline from it. + pub sheet: Option, + /// Block contents expanded while building (see [`MAX_BLOCK_EXPANSION`]). + pub expanded: usize, + /// Plot style end, join and fill style per primitive (parallel to `prims`; missing entries + /// are "use object"), turned into geometry by [`pen::apply`]. + pub pens: Vec, +} + +/// A glyph of plotted text: the font glyph and, in world coordinates, where it sits. It is +/// drawn by primitive [`PlotText::prim`] (a filled outline in the primitive's colour); output +/// with real text writes the glyph instead of that primitive. +#[derive(Clone, Debug, PartialEq)] +pub struct PlotText { + /// The filled outline primitive (index in [`DisplayList::prims`]); [`PlotText::NONE`] when + /// the glyph has none in the list (clipped by a viewport). + pub prim: u32, + /// Triangle vertices of the outline when it was drawn, and their bounding box (a clipped + /// outline has others). + pub verts: u32, + pub bounds: Bounds2, + /// The glyph; its origin and em axes are in world coordinates. + pub glyph: cadcraft_fonts::TextGlyph, +} + +impl PlotText { + pub const NONE: u32 = u32::MAX; + /// [`PlotText::prim`] of a glyph cut by a viewport edge. + pub const CLIPPED: u32 = u32::MAX - 1; +} + +impl DPrim { + /// The colour to draw with on `background`. + pub fn display_rgb(&self, background: Rgb) -> Rgb { + cadcraft_color::display_rgb(self.color, self.aci7, background) + } + /// The opacity to draw with: 255 when opaque, down to 26 at 90 % transparency. + pub fn alpha(&self) -> u8 { + alpha(self.transparency) + } +} + +/// The opacity (0..=255) of a transparency percentage, which is clamped to 0..=90 first. +pub fn alpha(transparency: u8) -> u8 { + let t = u32::from(transparency.min(MAX_TRANSPARENCY)); + u8::try_from((255 * (100 - t) + 50) / 100).unwrap_or(u8::MAX) +} + +/// The most transparent an object can be, in percent. +pub const MAX_TRANSPARENCY: u8 = 90; + +impl DisplayList { + pub fn segment_count(&self) -> usize { + self.prims.iter().filter(|p| p.kind == Kind::Polyline).map(|p| p.len.saturating_sub(1) as usize).sum() + } + /// The polyline points of a primitive. + pub fn points(&self, p: &DPrim) -> &[Vec2] { + match p.kind { + Kind::Tris | Kind::Mask => self.tris.get(p.start as usize..(p.start + p.len) as usize).unwrap_or(&[]), + _ => self.verts.get(p.start as usize..(p.start + p.len) as usize).unwrap_or(&[]), + } + } + /// The boundary of a filled primitive ([`Kind::Tris`], [`Kind::Mask`]) as closed polylines, + /// for highlighting it; empty for other kinds. + pub fn fill_outlines(&self, p: &DPrim) -> Vec> { + match p.kind { + Kind::Tris | Kind::Mask => outline::fill_outlines(self.points(p)), + _ => Vec::new(), + } + } +} + +/// Append `src` to `dst`: its primitives index into `dst`'s vertex and triangle arrays. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn append(dst: &mut DisplayList, src: DisplayList) { + let (verts, tris, prims) = (dst.verts.len() as u32, dst.tris.len() as u32, dst.prims.len() as u32); + // Pens are parallel to the primitives, with missing entries meaning "use object". + if !src.pens.is_empty() { + dst.pens.resize(dst.prims.len(), Pen::default()); + } + dst.prims.extend(src.prims.into_iter().map(|mut p| { + p.start += if matches!(p.kind, Kind::Tris | Kind::Mask) { tris } else { verts }; + p + })); + dst.verts.extend(src.verts); + dst.tris.extend(src.tris); + dst.prim_layers.extend(src.prim_layers); + dst.texts.extend(src.texts.into_iter().map(|mut t| { + if t.prim < PlotText::CLIPPED { + t.prim += prims; + } + t + })); + dst.pens.extend(src.pens); + dst.bounds = dst.bounds.union(&src.bounds); + dst.expanded = dst.expanded.saturating_add(src.expanded); +} diff --git a/crates/render/src/draw.rs b/crates/render/src/draw.rs new file mode 100644 index 000000000..5ce0bd6c0 --- /dev/null +++ b/crates/render/src/draw.rs @@ -0,0 +1,366 @@ +use super::blocks::{insert, sub_ctx}; +use super::dim_draw::{MAX_FILL_SOLIDS, MAX_FILL_TEXTS, block_text_box, dim_text_fill, dimension, dimension_in, solid_covers, starts_text_marks}; +use super::display::{Kind, NO_LAYER, VIEWPORT_CONTENT}; +use super::props::{resolve, transparency}; +use super::text_entity::{mtext, place_text_entity}; +use super::*; + +/// The layer `e` is drawn on, as an index in the layer table: layer 0 inside a block is the +/// block reference's layer. +fn entity_layer(ctx: &Ctx, e: &Entity) -> u32 { + let name = if e.common.layer == "0" { ctx.block_layer.as_deref().unwrap_or("0") } else { e.common.layer.as_str() }; + ctx.d.layers.iter().position(|l| l.name.eq_ignore_ascii_case(name)).and_then(|i| u32::try_from(i).ok()).unwrap_or(NO_LAYER) +} + +pub(crate) fn entity(b: &mut Builder, ctx: &Ctx, e: &Entity) { + // Hidden by ISOLATEOBJECTS / HIDEOBJECTS (and so in viewports and plots too). A cancelled + // build stops here (block contents come through here too). + if ctx.d.is_hidden_object(e.handle) || b.opts.cancelled() { + return; + } + // The entity's named plot style and transparency (set by `entity_parts`), for everything it + // draws (block contents set their own). + let (saved, saved_transparency) = (b.named, b.transparency); + b.named = b.styles.and_then(|s| s.named_index(ctx, &e.common)); + entity_parts(b, ctx, e); + b.named = saved; + b.transparency = saved_transparency; + if b.plotting && b.list.prim_layers.len() < b.list.prims.len() { + // What it drew that its block contents didn't claim is on its own layer. + let layer = entity_layer(ctx, e); + let from = b.list.prim_layers.len(); + b.list.prim_layers.resize(b.list.prims.len(), layer); + if ctx.top == VIEWPORT_CONTENT { + // Model space seen through a viewport is clipped into the layout's list; the layer + // travels in the handle until then (see [`take_viewport_layers`]). + for p in b.list.prims.iter_mut().skip(from) { + p.handle = Handle(u64::from(layer)); + } + } + } +} + +fn entity_parts(b: &mut Builder, ctx: &Ctx, e: &Entity) { + // A top-level annotative object draws at the view's annotation scale: a viewport's scale for + // model space seen through it, else the drawing's (CANNOSCALE in model space, 1:1 on paper). + if ctx.depth == 0 && cadcraft_doc::is_annotative(e) { + let mode = if ctx.top == VIEWPORT_CONTENT { + cadcraft_doc::anno_draw(e, 1.0 / ctx.xf.scale_factor().max(1e-12), ctx.d.anno_all_visible()) + } else { + ctx.d.anno_draw(e) + }; + match mode { + cadcraft_doc::AnnoDraw::Hidden => return, + cadcraft_doc::AnnoDraw::Scaled(f) => { + let mut scaled = cadcraft_doc::anno_scaled(e, f); + scaled.common.anno_scales.clear(); + return entity_parts(b, ctx, &scaled); + } + cadcraft_doc::AnnoDraw::Plain => {} + } + } + let (rgb, lw, lt, ltscale, visible, layer_transparency) = resolve(ctx, e, b.plotting); + if !visible { + return; + } + if b.opts.transparency { + b.transparency = transparency(e.common.transparency, layer_transparency, ctx.block_transparency); + } + // A plot style's linetype replaces the object's. + let lt = match b.styles.and_then(|s| s.linetype(rgb, b.named)) { + Some(over) => over, + None => lt, + }; + let tol = b.opts.tolerance / ctx.xf.scale_factor().max(1e-12); + let lw = if b.opts.lineweights { lw } else { 0.0 }; + // Stroke a polyline with the entity's linetype. + let stroke = |b: &mut Builder, pts: &[Vec2]| match < { + Some(lt) => { + let min = b.opts.min_dash / ctx.xf.scale_factor().max(1e-12); + for dash in linetype::apply(pts, lt, ltscale, min) { + if dash.len() == 1 { + if let Some(p) = dash.first() { + b.point(ctx, rgb, *p); + } + } else { + b.polyline(ctx, rgb, lw, &dash); + } + } + // Text embedded in the linetype (shapes need SHX files and are not drawn). + if b.opts.text { + for sh in linetype_text::texts(ctx.d, pts, lt, ltscale, min) { + b.shaped(ctx, rgb, lw, &sh); + } + } + } + None => b.polyline(ctx, rgb, lw, pts), + }; + match &e.kind { + EntityKind::Text(t) => { + if !b.opts.text { + return; + } + let (sh, _) = place_text_entity(ctx.d, t, &t.value); + b.shaped(ctx, rgb, lw, &sh); + } + EntityKind::AttDef(a) => { + let (sh, _) = place_text_entity(ctx.d, &a.text, &a.tag); + b.shaped(ctx, rgb, lw, &sh); + } + EntityKind::MText(t) => { + if !b.opts.text { + return; + } + mtext(b, ctx, t, rgb, lw); + } + EntityKind::Insert(ins) => insert(b, ctx, e, ins, rgb), + EntityKind::Dimension(dm) => { + if let Some(blk) = dm.block.as_ref().and_then(|n| ctx.d.block(n)) + && 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 { + dimension(b, ctx, e, dm, lw, lt.as_ref(), ltscale); + } + } + EntityKind::Hatch(h) => hatch::draw(b, ctx, e, h, rgb, lw, tol), + EntityKind::MLine(m) => mline::draw(b, ctx, e, m, rgb, lw, tol), + EntityKind::LwPolyline(p) if p.const_width > 0.0 || p.vertices.iter().any(|v| v.start_width > 0.0 || v.end_width > 0.0) => { + if b.opts.fill && ctx.d.header.i64("FILLMODE", 1) != 0 { + // The GPU canvas has no MSAA: a band narrower than a pixel lights only where it + // covers pixel centres, so whole sides vanish at some zooms (#1434). On screen the + // centre line is drawn too, as a hairline (wide bands are drawn without lineweight), + // so a thin band still shows as a one-pixel line. Always, not below a threshold: + // the list serves a whole band of zooms. Plots and exports draw the band alone, and + // a transparent band gets none (its alpha would add up along the centre). + let hairline = !b.plotting && b.opts.min_dash > 0.0 && b.transparency == 0; + match < { + // Dashed: each dash a filled piece of the band; dots a line across it. + Some(lt) => { + let min = b.opts.min_dash / ctx.xf.scale_factor().max(1e-12); + let (tris, ticks, centres) = wide::dashed(p, tol, lt, ltscale, min); + b.tris(ctx, rgb, &tris); + if hairline { + for c in ¢res { + b.hairline(ctx, rgb, c); + } + } + for t in &ticks { + match t.as_slice() { + [q] => b.point(ctx, rgb, *q), + _ => b.polyline(ctx, rgb, lw, t), + } + } + } + None => { + b.tris(ctx, rgb, &fill::wide_polyline(p, tol)); + if hairline { + let pl = Polyline { vertices: p.vertices.clone(), closed: p.closed }; + b.hairline(ctx, rgb, &pl.tessellate(tol)); + } + } + } + } else { + let pl = Polyline { vertices: p.vertices.clone(), closed: p.closed }; + stroke(b, &pl.tessellate(tol)); + } + } + EntityKind::LwPolyline(p) => { + let pl = Polyline { vertices: p.vertices.clone(), closed: p.closed }; + if p.plinegen || lt.is_none() { + stroke(b, &pl.tessellate(tol)); + } else { + for s in pl.segments() { + let mut pts = Vec::new(); + s.tessellate(tol, &mut pts); + stroke(b, &pts); + } + } + } + EntityKind::Solid(s) | EntityKind::Trace(s) => { + let c = &s.corners; + let q = [c[0].xy(), c[1].xy(), c[3].xy(), c[2].xy()]; + if b.opts.fill && ctx.d.header.i64("FILLMODE", 1) != 0 { + b.tris(ctx, rgb, &[q[0], q[1], q[2], q[0], q[2], q[3]]); + } else { + b.polyline(ctx, rgb, lw, &[q[0], q[1], q[2], q[3], q[0]]); + } + } + EntityKind::Wipeout(w) => { + // Hide what was drawn before it (draw order), then the frame per WIPEOUTFRAME: + // 0 = none, 1 = shown and plotted, 2 = shown but not plotted. + if b.opts.fill { + let n = b.list.prims.len(); + b.tris(ctx, rgb, &fill::triangulate_evenodd(std::slice::from_ref(&w.boundary))); + if let Some(p) = b.list.prims.get_mut(n) { + p.kind = Kind::Mask; + } + } + let frame = match ctx.d.header.i64("WIPEOUTFRAME", 1) { + 0 => false, + 2 => !b.plotting, + _ => true, + }; + if frame { + let mut pts = w.boundary.clone(); + if let Some(f) = pts.first().copied() { + pts.push(f); + } + b.polyline(ctx, rgb, lw, &pts); + } + } + EntityKind::Image(i) => { + let o = i.insert.xy(); + let u = i.u.xy() * i.size.x; + let v = i.v.xy() * i.size.y; + b.polyline(ctx, rgb, lw, &[o, o + u, o + u + v, o + v, o]); + b.polyline(ctx, rgb, lw, &[o, o + u + v]); + b.polyline(ctx, rgb, lw, &[o + u, o + v]); + } + EntityKind::Table(t) => table(b, ctx, t, rgb, lw), + EntityKind::Tolerance(t) => tolerance::draw(b, ctx, e, t, lw, tol), + EntityKind::Leader(l) => { + let pts: Vec = l.vertices.iter().map(|v| v.xy()).collect(); + stroke(b, &pts); + if l.arrow + && let (Some(a), Some(n)) = (pts.first(), pts.get(1)) + { + let st = ctx.d.dim_style(&l.style); + // DIMASZ × DIMSCALE of the leader's style (the drawing's DIMSCALE when it is 0). + let size = st.map(|s| s.arrow_size).unwrap_or(0.18) * leader_scale(ctx.d, st); + // DIMLDRBLK ("" = closed filled). + let kind = dim::Arrowhead::parse(st.map(|s| s.leader_arrow_block.as_str()).unwrap_or("")); + let g = dim::arrowhead(kind, *a, (*a - *n).normalized(), size); + for l in &g.lines { + b.polyline(ctx, rgb, lw, l); + } + b.tris(ctx, rgb, &g.tris); + } + } + EntityKind::MLeader(m) => { + // Straight or spline leader lines; block content draws like a block reference. + for pts in m.leader_paths() { + b.polyline(ctx, rgb, lw, &pts); + if let (Some(a), Some(n)) = (pts.first(), pts.get(1)) { + b.tris(ctx, rgb, &dim::arrow(*a, (*a - *n).normalized(), m.arrow_size)); + } + } + if let Some(t) = &m.text { + let land = m.landing.xy(); + let dir = if t.insert.x >= land.x { 1.0 } else { -1.0 }; + b.polyline(ctx, rgb, lw, &[land, land + Vec2::new(m.dogleg * dir, 0.0)]); + mtext(b, ctx, t, rgb, lw); + } + if let Some(ins) = &m.block { + if m.dogleg > 0.0 && !m.leaders.is_empty() { + let land = m.landing.xy(); + let dir = if ins.insert.x >= land.x { 1.0 } else { -1.0 }; + b.polyline(ctx, rgb, lw, &[land, land + Vec2::new(m.dogleg * dir, 0.0)]); + } + insert(b, ctx, e, ins, rgb); + } + } + EntityKind::Point(p) => { + // Relative sizes follow the view: undo the block / viewport scale. + let view = b.opts.view_height / ctx.xf.scale_factor().max(1e-12); + let size = point::size(ctx.d.header.f64("PDSIZE", 0.0), view); + let fig = point::figure(ctx.d.header.i64("PDMODE", 0), size, p.p.xy(), p.angle, tol); + if fig.dot { + b.point(ctx, rgb, p.p.xy()); + } + for l in &fig.lines { + b.polyline(ctx, rgb, lw, l); + } + } + kind => { + for prim in kind.prims() { + match prim { + Prim::Seg(s) => { + let mut pts = Vec::new(); + s.tessellate(tol, &mut pts); + stroke(b, &pts); + } + Prim::Circle(c) => { + let mut pts = Vec::new(); + c.tessellate(tol, &mut pts); + stroke(b, &pts); + } + Prim::Ellipse(el) => { + let mut pts = Vec::new(); + el.tessellate(tol, &mut pts); + stroke(b, &pts); + } + Prim::Spline(s) => stroke(b, &s.tessellate(tol)), + Prim::Point(p) => b.point(ctx, rgb, p), + Prim::Infinite { base, dir, ray } => b.infinite(ctx, rgb, base, dir, ray), + Prim::Fill(pts) => { + let tris = fill::triangulate_evenodd(&[pts]); + b.tris(ctx, rgb, &tris); + } + } + } + } + } +} + +/// Draw a polyline with a linetype (None: continuous). +pub(crate) fn stroke_with(b: &mut Builder, ctx: &Ctx, rgb: Ink, lw: f32, lt: Option<&cadcraft_doc::Linetype>, ltscale: f64, pts: &[Vec2]) { + match lt { + Some(lt) => { + let min = b.opts.min_dash / ctx.xf.scale_factor().max(1e-12); + for dash in linetype::apply(pts, lt, ltscale, min) { + match dash.as_slice() { + [p] => b.point(ctx, rgb, *p), + _ => b.polyline(ctx, rgb, lw, &dash), + } + } + } + None => b.polyline(ctx, rgb, lw, pts), + } +} + +fn table(b: &mut Builder, ctx: &Ctx, t: &cadcraft_doc::Table, rgb: Ink, lw: f32) { + table::draw(b, ctx, t, rgb, lw); +} + +/// The overall scale of a leader drawn with dimension style `st`: its DIMSCALE, else the +/// drawing's. +fn leader_scale(d: &Drawing, st: Option<&cadcraft_doc::DimStyle>) -> f64 { + let dimscale = d.header.f64("DIMSCALE", 1.0); + st.map(|s| s.effective_scale(dimscale)).unwrap_or_else(|| cadcraft_doc::DimStyle::default().effective_scale(dimscale)) +} diff --git a/crates/render/src/hatch.rs b/crates/render/src/hatch.rs index f48b6344e..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 }; @@ -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 cc7da1fa6..5b9dc83a1 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -36,112 +36,6 @@ pub use hatch::{fill_loops as hatch_fill_loops, pattern_swatch}; pub use paper::{PAPER_SIZES, PaperSize, Sheet, paper_size, sheet}; pub use tolerance::{gdt_points, tolerance_in}; -/// Handle carried by model-space geometry drawn inside a paper-space viewport (it belongs to no -/// single paper-space entity, so selection highlighting never matches it). -pub const VIEWPORT_CONTENT: Handle = Handle(0); - -/// [`DisplayList::prim_layers`] of a primitive whose layer isn't in the layer table. -pub const NO_LAYER: u32 = u32::MAX; - -/// What a display-list primitive draws. -#[derive(Clone, Copy, Debug, PartialEq)] -pub enum Kind { - /// `verts[start..start+len]` as a connected polyline. - Polyline, - /// `tris[start..start+len]`, three vertices per triangle. - Tris, - /// A wipeout: triangles laid out like [`Kind::Tris`], filled with the background colour - /// (paper white when plotting, the canvas colour on screen) so they hide what was drawn - /// before them. - Mask, - /// A single point marker at `verts[start]`. - Point, - /// An infinite line or ray: `verts[start]` = base, `verts[start+1]` = unit direction. - Infinite { ray: bool }, -} - -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct DPrim { - pub handle: Handle, - pub color: Rgb, - /// Colour 7 (directly, by layer or by block): drawn white on a dark background and black on - /// a light one, see [`DPrim::display_rgb`]. Other colours keep their RGB. - pub aci7: bool, - /// Lineweight in mm (0 = thinnest). - pub lw: f32, - /// Transparency in percent (0 = opaque, at most 90): the object's own, its layer's when - /// ByLayer, the block reference's when ByBlock. Always 0 unless [`Options::transparency`]. - pub transparency: u8, - pub kind: Kind, - pub start: u32, - pub len: u32, -} - -#[derive(Clone, Debug, Default)] -pub struct DisplayList { - pub prims: Vec, - pub verts: Vec, - pub tris: Vec, - /// Plots ([`build_plot`]): for each primitive, the index in [`Drawing::layers`] of the layer - /// it is drawn on (block contents on layer 0: the block reference's; [`NO_LAYER`] when the - /// layer isn't in the table), for PDF layers. Empty for other lists. - pub prim_layers: Vec, - /// Plots: outline-font glyphs that a plotter can write as text instead of drawing the - /// filled outline primitive they stand for ([`PlotText::prim`]), in drawing order. - pub texts: Vec, - pub bounds: Bounds2, - /// The paper sheet when the list was built for a layout (paper space); the UI draws the - /// white sheet, its shadow and the printable-area outline from it. - pub sheet: Option, - /// Block contents expanded while building (see [`MAX_BLOCK_EXPANSION`]). - pub expanded: usize, - /// Plot style end, join and fill style per primitive (parallel to `prims`; missing entries - /// are "use object"), turned into geometry by [`pen::apply`]. - pub pens: Vec, -} - -/// A glyph of plotted text: the font glyph and, in world coordinates, where it sits. It is -/// drawn by primitive [`PlotText::prim`] (a filled outline in the primitive's colour); output -/// with real text writes the glyph instead of that primitive. -#[derive(Clone, Debug, PartialEq)] -pub struct PlotText { - /// The filled outline primitive (index in [`DisplayList::prims`]); [`PlotText::NONE`] when - /// the glyph has none in the list (clipped by a viewport). - pub prim: u32, - /// Triangle vertices of the outline when it was drawn, and their bounding box (a clipped - /// outline has others). - pub verts: u32, - pub bounds: Bounds2, - /// The glyph; its origin and em axes are in world coordinates. - pub glyph: cadcraft_fonts::TextGlyph, -} - -impl PlotText { - pub const NONE: u32 = u32::MAX; - /// [`PlotText::prim`] of a glyph cut by a viewport edge. - pub const CLIPPED: u32 = u32::MAX - 1; -} - -impl DPrim { - /// The colour to draw with on `background`. - pub fn display_rgb(&self, background: Rgb) -> Rgb { - cadcraft_color::display_rgb(self.color, self.aci7, background) - } - /// The opacity to draw with: 255 when opaque, down to 26 at 90 % transparency. - pub fn alpha(&self) -> u8 { - alpha(self.transparency) - } -} - -/// The opacity (0..=255) of a transparency percentage, which is clamped to 0..=90 first. -pub fn alpha(transparency: u8) -> u8 { - let t = u32::from(transparency.min(MAX_TRANSPARENCY)); - u8::try_from((255 * (100 - t) + 50) / 100).unwrap_or(u8::MAX) -} - -/// The most transparent an object can be, in percent. -pub const MAX_TRANSPARENCY: u8 = 90; - /// A resolved colour: its RGB, whether it is colour 7, and its index colour (0 = a true /// colour), which picks the style of a colour-dependent plot style table. #[derive(Clone, Copy, Debug, PartialEq)] @@ -173,86 +67,6 @@ fn ink(c: Color, layer: Color, block: Color) -> Ink { Ink { rgb: c.resolve(layer, block), aci7, aci } } -impl DisplayList { - pub fn segment_count(&self) -> usize { - self.prims.iter().filter(|p| p.kind == Kind::Polyline).map(|p| p.len.saturating_sub(1) as usize).sum() - } - /// The polyline points of a primitive. - pub fn points(&self, p: &DPrim) -> &[Vec2] { - match p.kind { - Kind::Tris | Kind::Mask => self.tris.get(p.start as usize..(p.start + p.len) as usize).unwrap_or(&[]), - _ => self.verts.get(p.start as usize..(p.start + p.len) as usize).unwrap_or(&[]), - } - } - /// The boundary of a filled primitive ([`Kind::Tris`], [`Kind::Mask`]) as closed polylines, - /// for highlighting it; empty for other kinds. - pub fn fill_outlines(&self, p: &DPrim) -> Vec> { - match p.kind { - Kind::Tris | Kind::Mask => outline::fill_outlines(self.points(p)), - _ => Vec::new(), - } - } -} - -/// Build options. -#[derive(Clone, Debug)] -pub struct Options { - /// Chord tolerance in world units (about half a pixel at the current zoom). - pub tolerance: f64, - /// Two pixels in world units: shorter dashes are continuous and filled font text uses - /// screen-size detail. Zero keeps full detail (plots and image/vector exports). - pub min_dash: f64, - /// Draw text (off for very coarse previews). - pub text: bool, - /// Highlighted (selected) handles are not special here; the canvas overlays them. - pub fill: bool, - pub lineweights: bool, - /// Height of the visible area in world units of the space being built. Relative point - /// sizes (`PDSIZE` <= 0) are a percentage of it; 0 = the height of the space's extents. - pub view_height: f64, - /// Plot style table to draw with. When `None`, [`build`] of a layout whose page setup has - /// "Display plot styles" on uses the layout's table; [`build_plot`] uses none. - pub plot_style_table: Option>, - /// The area being looked at (world units of the space being built), for on-screen builds: - /// hatch patterns are only generated inside it. `None` = everywhere (plots, exports, picks). - pub view: Option, - /// Draw objects with their transparency ([`DPrim::transparency`]). Off by default: the screen - /// follows TRANSPARENCYDISPLAY, plots the page setup's "Plot transparency". - pub transparency: bool, - /// A layout viewport whose model-space contents are left out (its border is still drawn): - /// the canvas draws the active viewport's contents itself, see [`viewport_content`]. - pub skip_viewport: Option, - /// Set from another thread to stop the build early (the canvas cancels a background - /// rebuild the view no longer needs). A cancelled build returns a partial list, which the - /// caller must discard; see [`Options::cancelled`]. - pub cancel: Option>, -} - -impl Options { - /// Whether [`Options::cancel`] has been set. - pub fn cancelled(&self) -> bool { - self.cancel.as_ref().is_some_and(|c| c.load(std::sync::atomic::Ordering::Relaxed)) - } -} - -impl Default for Options { - fn default() -> Self { - Options { - tolerance: 0.001, - min_dash: 0.0, - text: true, - fill: true, - lineweights: false, - view_height: 0.0, - plot_style_table: None, - view: None, - skip_viewport: None, - transparency: false, - cancel: None, - } - } -} - /// The largest axis scale of a transform's linear part (its largest singular value): a length /// `l` in the transform's own units measures at most `l * max_scale` after it, in any direction. /// A uniform scale gives its factor, where [`Mat3::scale_factor`] gives the mean of the axes. @@ -484,1283 +298,25 @@ impl Builder<'_> { } } -/// Build the display list for a space. For a layout (paper space) this includes the model-space -/// geometry seen through each viewport, clipped to the viewport, and [`DisplayList::sheet`]. -pub fn build(d: &Drawing, space: &Space, opts: &Options) -> DisplayList { - build_space(d, space, opts, false) -} - -/// Like [`build`] but for plotting: layers marked "do not plot" (such as `Defpoints`) are left out. -pub fn build_plot(d: &Drawing, space: &Space, opts: &Options) -> DisplayList { - build_space(d, space, opts, true) -} - -fn top_ctx<'a>(d: &'a Drawing, xf: Mat3, top: Handle, frozen: &'a [String], vp_colors: &'a [(String, Color)]) -> Ctx<'a> { - Ctx { - d, - xf, - block_color: Color::Index(7), - block_layer: None, - block_lw: Lineweight::Default, - block_ltype: "Continuous".into(), - depth: 0, - top, - frozen, - vp_colors, - lt_factor: 1.0, - block_pstyle: None, - block_transparency: 0, - } -} - -fn build_space(d: &Drawing, space: &Space, opts: &Options, plotting: bool) -> DisplayList { - let opts = &with_point_view(d, space, opts); - let styler = style_table(d, space, opts, plotting).map(|t| plotstyle::Styler::new(d, t)); - let mut b = Builder { list: DisplayList::default(), opts, plotting, styles: styler.as_ref(), named: None, shared_budget: None, transparency: 0 }; - if let Space::Paper(name) = space { - b.list.sheet = paper::sheet(d, name); - } - if let Some(store) = d.space(space) { - let paper = matches!(space, Space::Paper(_)); - #[cfg(not(target_arch = "wasm32"))] - if !paper && !plotting && b.styles.is_none() && store.len() >= 2 * PARALLEL_CHUNK { - build_parallel(&mut b, d, store); - return b.list; - } - let mut viewports = 0usize; - for e in store.iter() { - if paper && let EntityKind::Viewport(vp) = &e.kind { - if viewports < MAX_VIEWPORTS { - viewports += 1; - viewport(&mut b, d, e, vp); - if plotting { - take_viewport_layers(&mut b.list); - } - } - continue; - } - entity(&mut b, &top_ctx(d, Mat3::IDENTITY, e.handle, &[], &[]), e); - } - } - // A layout is drawn in paper units, so its pens become geometry now. - if let Some(sheet) = b.list.sheet { - pen::apply(&mut b.list, sheet.unit_mm); - } - b.list -} - -/// Top-level entities per task when a display list is built on all cores. -#[cfg(not(target_arch = "wasm32"))] -const PARALLEL_CHUNK: usize = 256; - -/// Build model space for the screen on all cores: the entities are split into chunks built -/// side by side, then joined in drawing order, so the list is the same as a one-thread build. -/// The canvas rebuilds the whole list at every 2x zoom step (in the background since zooming -/// stopped blocking the UI) and after every edit; one core made both slow on real drawings. Plots, layouts and plot styles keep the one-thread -/// path (they run once, and their pens and per-primitive layers are built along the way). -#[cfg(not(target_arch = "wasm32"))] -fn build_parallel(b: &mut Builder, d: &Drawing, store: &cadcraft_doc::EntityStore) { - use rayon::prelude::*; - let ents: Vec<&std::sync::Arc> = store.iter().collect(); - let budget = std::sync::atomic::AtomicUsize::new(0); - let (opts, plotting) = (b.opts, b.plotting); - let parts: Vec = ents - .par_chunks(PARALLEL_CHUNK) - .map(|chunk| { - let mut part = - Builder { list: DisplayList::default(), opts, plotting, styles: None, named: None, shared_budget: Some(&budget), transparency: 0 }; - for e in chunk { - entity(&mut part, &top_ctx(d, Mat3::IDENTITY, e.handle, &[], &[]), e); - } - part.list - }) - .collect(); - // One allocation per array for the joined list. - b.list.prims.reserve(parts.iter().map(|p| p.prims.len()).sum()); - b.list.verts.reserve(parts.iter().map(|p| p.verts.len()).sum()); - b.list.tris.reserve(parts.iter().map(|p| p.tris.len()).sum()); - for part in parts { - append(&mut b.list, part); - } -} - -/// Append `src` to `dst`: its primitives index into `dst`'s vertex and triangle arrays. -#[cfg(not(target_arch = "wasm32"))] -fn append(dst: &mut DisplayList, src: DisplayList) { - let (verts, tris, prims) = (dst.verts.len() as u32, dst.tris.len() as u32, dst.prims.len() as u32); - // Pens are parallel to the primitives, with missing entries meaning "use object". - if !src.pens.is_empty() { - dst.pens.resize(dst.prims.len(), Pen::default()); - } - dst.prims.extend(src.prims.into_iter().map(|mut p| { - p.start += if matches!(p.kind, Kind::Tris | Kind::Mask) { tris } else { verts }; - p - })); - dst.verts.extend(src.verts); - dst.tris.extend(src.tris); - dst.prim_layers.extend(src.prim_layers); - dst.texts.extend(src.texts.into_iter().map(|mut t| { - if t.prim < PlotText::CLIPPED { - t.prim += prims; - } - t - })); - dst.pens.extend(src.pens); - dst.bounds = dst.bounds.union(&src.bounds); - dst.expanded = dst.expanded.saturating_add(src.expanded); -} - -/// The plot style table a build draws with: the one in `opts`, else (on screen) the layout's -/// when its page setup has "Display plot styles" on. -fn style_table(d: &Drawing, space: &Space, opts: &Options, plotting: bool) -> Option> { - opts.plot_style_table.clone().or_else(|| match space { - Space::Paper(n) if !plotting => d - .layout(n) - .filter(|l| l.page.show_plot_styles) - .and_then(|l| cadcraft_doc::plot_style_table(d, &l.page.plot_style_table)) - .map(std::sync::Arc::new), - _ => None, - }) -} - -/// `opts` with a view height for relative point sizes: the extents height when none is given -/// and points draw as figures sized relative to the view. -fn with_point_view(d: &Drawing, space: &Space, opts: &Options) -> Options { - let mut o = opts.clone(); - let relative = d.header.f64("PDSIZE", 0.0) <= 0.0 && point::sized(d.header.i64("PDMODE", 0)); - if !(o.view_height.is_finite() && o.view_height > 0.0) && relative { - let ext = d.extents(space); - o.view_height = if ext.is_empty() { 0.0 } else { ext.height().max(ext.width() * 1e-3) }; - } - o -} - -/// Upper bound on viewports drawn per layout (hostile files). -const MAX_VIEWPORTS: usize = 256; - -/// A paper-space viewport: its border (as an ordinary entity) and model space seen through it. -/// Viewport id 1 is the paper-space view itself and is not drawn. A clipped viewport shows -/// model space through its clip boundary's outline (the boundary entity draws itself, so the -/// rectangle is not drawn); a viewport turned off shows only its border. -fn viewport(b: &mut Builder, d: &Drawing, e: &Entity, vp: &cadcraft_doc::Viewport) { - if !viewport_shown(d, e, vp) { - return; - } - let poly = viewport_clip(d, e.handle, vp); - // Border (on the viewport's layer; layer off hides only the border). - if poly.is_none() { - entity(b, &top_ctx(d, Mat3::IDENTITY, e.handle, &[], &[]), e); - } - if b.opts.skip_viewport == Some(e.handle) { - return; - } - let unit_mm = paper::unit_mm_of(d, e.handle); - match &poly { - Some(p) => contents(b, d, vp, p.bounds(), &Clip::Poly(p), unit_mm), - None => { - if let Some(rect) = viewport_rect(vp) { - contents(b, d, vp, rect, &Clip::Rect(rect), unit_mm); - } - } - } -} - -/// Whether a viewport draws anything: id 1 is the paper-space view itself, and a hidden viewport -/// or one on a frozen layer shows neither its border nor its contents. -fn viewport_shown(d: &Drawing, e: &Entity, vp: &cadcraft_doc::Viewport) -> bool { - vp.id != 1 && e.common.visible && !d.is_hidden_object(e.handle) && !d.layer(&e.common.layer).is_some_and(|l| l.frozen) -} - -/// A viewport's rectangle on paper; `None` for an unusable size or centre. -fn viewport_rect(vp: &cadcraft_doc::Viewport) -> Option { - let center = vp.center.xy(); - let ok = |v: f64| v.is_finite() && v > 0.0; - (ok(vp.width) && ok(vp.height) && center.is_finite()).then(|| { - let half = Vec2::new(vp.width / 2.0, vp.height / 2.0); - Bounds2::new(center - half, center + half) - }) -} - -/// Model space seen through the layout viewport `handle` of `space`, in paper units, clipped to -/// `area` (paper units) instead of to the viewport: what [`build`] draws inside that viewport, -/// over an area the caller chooses. The canvas builds the active viewport this way for an area -/// around it and draws it clipped to the viewport, so panning and zooming inside it moves the -/// geometry instead of rebuilding the layout (see [`Options::skip_viewport`]). Empty when the -/// viewport shows nothing. -pub fn viewport_content(d: &Drawing, space: &Space, handle: Handle, area: Bounds2, opts: &Options) -> DisplayList { - let Some(e) = d.space(space).and_then(|s| s.get(handle)) else { return DisplayList::default() }; - let EntityKind::Viewport(vp) = &e.kind else { return DisplayList::default() }; - if !viewport_shown(d, e, vp) || area.is_empty() || !(area.min.is_finite() && area.max.is_finite()) { - return DisplayList::default(); - } - let opts = &with_point_view(d, space, opts); - let styler = style_table(d, space, opts, false).map(|t| plotstyle::Styler::new(d, t)); - let mut b = - Builder { list: DisplayList::default(), opts, plotting: false, styles: styler.as_ref(), named: None, shared_budget: None, transparency: 0 }; - contents(&mut b, d, vp, area, &Clip::Rect(area), paper::unit_mm_of(d, handle)); - b.list -} - -/// Model space seen through `vp`, in paper units, appended to `b`'s list clipped to `clip`; -/// `area` (the clip's bounds on paper) picks the model objects to draw. -fn contents(b: &mut Builder, d: &Drawing, vp: &cadcraft_doc::Viewport, area: Bounds2, clip: &Clip, unit_mm: f64) { - if b.list.expanded >= MAX_BLOCK_EXPANSION || !vp.on { - return; - } - let center = vp.center.xy(); - let ok = |v: f64| v.is_finite() && v > 0.0; - if !(ok(vp.width) && ok(vp.height) && ok(vp.view_height) && center.is_finite() && vp.view_center.is_finite()) { - return; - } - let s = vp.height / vp.view_height; - if !ok(s) { - return; - } - let xf = Mat3::translate(center).then_before(Mat3::scale(s, s)).then_before(Mat3::translate(-vp.view_center)); - let mhalf = Vec2::new(area.width(), area.height()) / (2.0 * s); - let mcenter = vp.view_center + (area.center() - center) / s; - // Model window with slack: entity bounds are approximate (text, dimensions). - let win = Bounds2::new(mcenter - mhalf, mcenter + mhalf).expand(mhalf.x.max(mhalf.y) * 0.1); - // PSLTSCALE on: model-space dashes are scaled so they measure the same on paper as in - // paper space, whatever the viewport scale. - let lt_factor = if d.header.i64("PSLTSCALE", 1) != 0 { 1.0 / s } else { 1.0 }; - let ctx = || Ctx { lt_factor, ..top_ctx(d, xf, VIEWPORT_CONTENT, &vp.frozen_layers, &vp.layer_colors) }; - let seen = |me: &Entity| { - if matches!(me.kind, EntityKind::Viewport(_)) { - return false; - } - if matches!(me.kind, EntityKind::Ray(_) | EntityKind::XLine(_)) { - return true; - } - let eb = cadcraft_doc::entity_bounds(d, me, 0); - eb.is_empty() || eb.intersects(&win) - }; - // One expansion budget for the whole build: seed it with what the enclosing list has already - // spent so a viewport's contents cannot start over with a fresh allowance and exceed the - // documented limit (CWE-400). Both the serial sub-builder and the parallel chunks share it. - let budget = std::sync::atomic::AtomicUsize::new(b.list.expanded); - let mut sub = Builder { - list: DisplayList::default(), - opts: b.opts, - plotting: b.plotting, - styles: b.styles, - named: None, - shared_budget: Some(&budget), - transparency: 0, - }; - #[cfg(not(target_arch = "wasm32"))] - let parallel = !b.plotting && b.styles.is_none() && d.model.len() >= 2 * PARALLEL_CHUNK; - #[cfg(target_arch = "wasm32")] - let parallel = false; - if parallel { - #[cfg(not(target_arch = "wasm32"))] - { - // As `build_parallel`: chunks built side by side, joined in drawing order. - use rayon::prelude::*; - let ents: Vec<&std::sync::Arc> = d.model.iter().collect(); - let opts = b.opts; - let parts: Vec = ents - .par_chunks(PARALLEL_CHUNK) - .map(|chunk| { - let mut part = Builder { - list: DisplayList::default(), - opts, - plotting: false, - styles: None, - named: None, - shared_budget: Some(&budget), - transparency: 0, - }; - let c = ctx(); - for me in chunk.iter().filter(|me| seen(me)) { - entity(&mut part, &c, me); - } - part.list - }) - .collect(); - for part in parts { - append(&mut sub.list, part); - } - } - } else { - let c = ctx(); - for me in d.model.iter().filter(|me| seen(me)) { - entity(&mut sub, &c, me); - } - } - // Plotted text glyphs move to the layout's list; their outlines carry the glyph's index there - // in their handle's high half until clipped (see `take_viewport_layers`). - for t in std::mem::take(&mut sub.list.texts) { - let k = b.list.texts.len() as u64 + 1; - if let Some(p) = sub.list.prims.get_mut(t.prim as usize) - && k < u64::from(u32::MAX) - { - p.handle = Handle(p.handle.0 | (k << 32)); - b.list.texts.push(PlotText { prim: PlotText::NONE, ..t }); - } - } - // The contents are in paper units now: draw their pens before clipping. - pen::apply(&mut sub.list, unit_mm); - b.list.expanded = b.list.expanded.saturating_add(sub.list.expanded); - append_clipped(&mut b.list, &sub.list, clip); -} - -/// The clip region of a clipped viewport: the outline of its clip boundary entity, when that -/// is a closed shape in the drawing. `None` for a rectangular viewport (or a dangling, -/// unusable or self-referencing boundary, which falls back to the rectangle). -pub fn viewport_clip(d: &Drawing, handle: Handle, vp: &cadcraft_doc::Viewport) -> Option { - let h = vp.clip.filter(|h| *h != handle)?; - let be = d.entity(h)?; - clip::PolyClip::new(&clip::boundary_polygon(&be.kind)?) -} - -/// What viewport contents are clipped to. -enum Clip<'a> { - /// The viewport rectangle (the fast path). - Rect(Bounds2), - /// A clip boundary's outline. - Poly(&'a clip::PolyClip), -} - -/// Append `src` to `dst`, clipped to `clip`. -fn append_clipped(dst: &mut DisplayList, src: &DisplayList, clip: &Clip) { - for p in &src.prims { - let pts = src.points(p); - match (p.kind, clip) { - (Kind::Polyline, Clip::Rect(rect)) => { - for piece in clip::clip_polyline(pts, rect) { - push_raw(dst, p, Kind::Polyline, &piece); - } - } - (Kind::Polyline, Clip::Poly(c)) => { - for piece in c.clip_polyline(pts) { - push_raw(dst, p, Kind::Polyline, &piece); - } - } - (Kind::Tris | Kind::Mask, Clip::Rect(rect)) => { - let t = clip::clip_triangles(pts, rect); - push_raw(dst, p, p.kind, &t); - } - (Kind::Tris | Kind::Mask, Clip::Poly(c)) => { - let t = c.clip_triangles(pts); - push_raw(dst, p, p.kind, &t); - } - (Kind::Point, _) => { - let inside = |q: Vec2| match clip { - Clip::Rect(rect) => rect.contains(q), - Clip::Poly(c) => c.contains(q), - }; - if let Some(q) = pts.first() - && inside(*q) - { - push_raw(dst, p, Kind::Point, &[*q]); - } - } - (Kind::Infinite { ray }, Clip::Rect(rect)) => { - if let (Some(base), Some(dir)) = (pts.first(), pts.get(1)) - && let Some((a, c)) = clip::clip_infinite(*base, *dir, ray, rect) - { - push_raw(dst, p, Kind::Polyline, &[a, c]); - } - } - (Kind::Infinite { ray }, Clip::Poly(c)) => { - if let (Some(base), Some(dir)) = (pts.first(), pts.get(1)) { - for piece in c.clip_infinite(*base, *dir, ray) { - push_raw(dst, p, Kind::Polyline, &piece); - } - } - } - } - } -} - -fn push_raw(dst: &mut DisplayList, p: &DPrim, kind: Kind, pts: &[Vec2]) { - let min = match kind { - Kind::Polyline => 2, - Kind::Tris | Kind::Mask => 3, - _ => 1, - }; - if pts.len() < min { - return; - } - let tris = matches!(kind, Kind::Tris | Kind::Mask); - let start = if tris { dst.tris.len() } else { dst.verts.len() } as u32; - for q in pts { - dst.bounds.add(*q); - } - if tris { - dst.tris.extend_from_slice(pts); - } else { - dst.verts.extend_from_slice(pts); - } - dst.prims.push(DPrim { - handle: p.handle, - color: p.color, - aci7: p.aci7, - lw: p.lw, - transparency: p.transparency, - kind, - start, - len: pts.len() as u32, - }); -} - -/// Build the display list for a set of loose entities (previews, rubber bands). -pub fn build_entities<'a, I: IntoIterator>(d: &Drawing, ents: I, opts: &Options) -> DisplayList { - let ents: Vec<&Entity> = ents.into_iter().collect(); - // Only previews holding points need the model extents for relative point sizes. - let with_view; - let opts = if ents.iter().any(|e| matches!(e.kind, EntityKind::Point(_))) { - with_view = with_point_view(d, &Space::Model, opts); - &with_view - } else { - opts - }; - let mut b = Builder { list: DisplayList::default(), opts, plotting: false, styles: None, named: None, shared_budget: None, transparency: 0 }; - for e in ents { - entity(&mut b, &top_ctx(d, Mat3::IDENTITY, e.handle, &[], &[]), e); - } - b.list -} - -/// The layer `e` is drawn on, as an index in the layer table: layer 0 inside a block is the -/// block reference's layer. -fn entity_layer(ctx: &Ctx, e: &Entity) -> u32 { - let name = if e.common.layer == "0" { ctx.block_layer.as_deref().unwrap_or("0") } else { e.common.layer.as_str() }; - ctx.d.layers.iter().position(|l| l.name.eq_ignore_ascii_case(name)).and_then(|i| u32::try_from(i).ok()).unwrap_or(NO_LAYER) -} - -/// After a viewport is appended to a plot: the layers its contents carry in their handles -/// (see [`entity`]) go to [`DisplayList::prim_layers`], and the handles back to -/// [`VIEWPORT_CONTENT`]. The viewport's border was tagged when it was drawn. -/// -/// Text glyphs seen through the viewport (marked in the handles' high half) keep their outline -/// primitive when it came through the clip whole; a glyph cut by the viewport edge stays -/// geometry. -fn take_viewport_layers(list: &mut DisplayList) { - let start = list.prim_layers.len(); - for i in start..list.prims.len() { - let Some(p) = list.prims.get(i).copied() else { break }; - list.prim_layers.push(u32::try_from(p.handle.0 & 0xFFFF_FFFF).unwrap_or(NO_LAYER)); - let bounds = Bounds2::from_points(list.points(&p).iter().copied()); - if let Some(t) = (p.handle.0 >> 32).checked_sub(1).and_then(|k| list.texts.get_mut(k as usize)) { - let same = |a: Vec2, b: Vec2| a.dist(b) <= 1e-9 * (1.0 + a.len()); - let whole = t.prim == PlotText::NONE - && p.kind == Kind::Tris - && p.len == t.verts - && same(bounds.min, t.bounds.min) - && same(bounds.max, t.bounds.max); - // A second piece (or a cut one) means the clip changed the glyph. - t.prim = if whole { u32::try_from(i).unwrap_or(PlotText::NONE) } else { PlotText::CLIPPED }; - } - if let Some(p) = list.prims.get_mut(i) { - p.handle = VIEWPORT_CONTENT; - } - } -} - -/// An object's colour, lineweight (mm), linetype, linetype scale, visibility and the transparency -/// of the layer it is drawn on (for ByLayer, see [`transparency`]). -type Resolved = (Ink, f32, Option, f64, bool, u8); - -fn resolve(ctx: &Ctx, e: &Entity, plotting: bool) -> Resolved { - resolve_common(ctx, &e.common, plotting) -} - -/// [`resolve`] for an object's properties `c`. -fn resolve_common(ctx: &Ctx, c: &cadcraft_doc::Common, plotting: bool) -> Resolved { - let d = ctx.d; - // Layer "0" inside a block takes the insert's layer. - let layer_name = if c.layer == "0" { ctx.block_layer.as_deref().unwrap_or("0") } else { c.layer.as_str() }; - let layer = d.layer(layer_name); - let visible = - c.visible && layer.is_none_or(|l| l.visible() && (!plotting || l.plot)) && !ctx.frozen.iter().any(|f| f.eq_ignore_ascii_case(layer_name)); - let layer_color = ctx.layer_color(layer_name).unwrap_or(Color::Index(7)); - let rgb = ink(c.color, layer_color, ctx.block_color); - let lw = match c.lineweight { - Lineweight::ByLayer => layer.map(|l| l.lineweight).unwrap_or(Lineweight::Default), - Lineweight::ByBlock => ctx.block_lw, - x => x, - }; - let lw_mm = match lw { - Lineweight::Mm100(v) => f32::from(v) / 100.0, - _ => default_lineweight(d), - }; - let lt_name = match c.linetype.to_ascii_lowercase().as_str() { - "bylayer" => layer.map(|l| l.linetype.clone()).unwrap_or_else(|| "Continuous".into()), - "byblock" => ctx.block_ltype.clone(), - _ => c.linetype.clone(), - }; - let lt = d.linetype(<_name).filter(|l| !l.pattern.is_empty()).cloned(); - let scale = d.header.f64("LTSCALE", 1.0) * c.ltscale * ctx.lt_factor; - (rgb, lw_mm, lt, scale, visible, layer.map_or(0, |l| l.transparency)) -} - -/// The transparency (percent, 0..=90) of an object whose own setting is `t`: the percentage, its -/// layer's `layer` when ByLayer (layer 0 inside a block: the block reference's layer, as -/// [`resolve_common`] finds it), the block reference's `block` when ByBlock (opaque outside -/// blocks). -fn transparency(t: cadcraft_doc::Transparency, layer: u8, block: u8) -> u8 { - let t = match t { - cadcraft_doc::Transparency::Percent(p) => p, - cadcraft_doc::Transparency::ByBlock => block, - cadcraft_doc::Transparency::ByLayer => layer, - }; - t.min(MAX_TRANSPARENCY) -} - -/// The width in mm that the "Default" lineweight stands for: `LWDEFAULT` (hundredths of a mm, -/// 0 to 211), 0.25 mm when it is unset or out of range. -fn default_lineweight(d: &Drawing) -> f32 { - match d.header.i64("LWDEFAULT", 25) { - v @ 0..=211 => v as f32 / 100.0, - _ => 0.25, - } -} - -fn entity(b: &mut Builder, ctx: &Ctx, e: &Entity) { - // Hidden by ISOLATEOBJECTS / HIDEOBJECTS (and so in viewports and plots too). A cancelled - // build stops here (block contents come through here too). - if ctx.d.is_hidden_object(e.handle) || b.opts.cancelled() { - return; - } - // The entity's named plot style and transparency (set by `entity_parts`), for everything it - // draws (block contents set their own). - let (saved, saved_transparency) = (b.named, b.transparency); - b.named = b.styles.and_then(|s| s.named_index(ctx, &e.common)); - entity_parts(b, ctx, e); - b.named = saved; - b.transparency = saved_transparency; - if b.plotting && b.list.prim_layers.len() < b.list.prims.len() { - // What it drew that its block contents didn't claim is on its own layer. - let layer = entity_layer(ctx, e); - let from = b.list.prim_layers.len(); - b.list.prim_layers.resize(b.list.prims.len(), layer); - if ctx.top == VIEWPORT_CONTENT { - // Model space seen through a viewport is clipped into the layout's list; the layer - // travels in the handle until then (see [`take_viewport_layers`]). - for p in b.list.prims.iter_mut().skip(from) { - p.handle = Handle(u64::from(layer)); - } - } - } -} - -fn entity_parts(b: &mut Builder, ctx: &Ctx, e: &Entity) { - // A top-level annotative object draws at the view's annotation scale: a viewport's scale for - // model space seen through it, else the drawing's (CANNOSCALE in model space, 1:1 on paper). - if ctx.depth == 0 && cadcraft_doc::is_annotative(e) { - let mode = if ctx.top == VIEWPORT_CONTENT { - cadcraft_doc::anno_draw(e, 1.0 / ctx.xf.scale_factor().max(1e-12), ctx.d.anno_all_visible()) - } else { - ctx.d.anno_draw(e) - }; - match mode { - cadcraft_doc::AnnoDraw::Hidden => return, - cadcraft_doc::AnnoDraw::Scaled(f) => { - let mut scaled = cadcraft_doc::anno_scaled(e, f); - scaled.common.anno_scales.clear(); - return entity_parts(b, ctx, &scaled); - } - cadcraft_doc::AnnoDraw::Plain => {} - } - } - let (rgb, lw, lt, ltscale, visible, layer_transparency) = resolve(ctx, e, b.plotting); - if !visible { - return; - } - if b.opts.transparency { - b.transparency = transparency(e.common.transparency, layer_transparency, ctx.block_transparency); - } - // A plot style's linetype replaces the object's. - let lt = match b.styles.and_then(|s| s.linetype(rgb, b.named)) { - Some(over) => over, - None => lt, - }; - let tol = b.opts.tolerance / ctx.xf.scale_factor().max(1e-12); - let lw = if b.opts.lineweights { lw } else { 0.0 }; - // Stroke a polyline with the entity's linetype. - let stroke = |b: &mut Builder, pts: &[Vec2]| match < { - Some(lt) => { - let min = b.opts.min_dash / ctx.xf.scale_factor().max(1e-12); - for dash in linetype::apply(pts, lt, ltscale, min) { - if dash.len() == 1 { - if let Some(p) = dash.first() { - b.point(ctx, rgb, *p); - } - } else { - b.polyline(ctx, rgb, lw, &dash); - } - } - // Text embedded in the linetype (shapes need SHX files and are not drawn). - if b.opts.text { - for sh in linetype_text::texts(ctx.d, pts, lt, ltscale, min) { - b.shaped(ctx, rgb, lw, &sh); - } - } - } - None => b.polyline(ctx, rgb, lw, pts), - }; - match &e.kind { - EntityKind::Text(t) => { - if !b.opts.text { - return; - } - let (sh, _) = place_text_entity(ctx.d, t, &t.value); - b.shaped(ctx, rgb, lw, &sh); - } - EntityKind::AttDef(a) => { - let (sh, _) = place_text_entity(ctx.d, &a.text, &a.tag); - b.shaped(ctx, rgb, lw, &sh); - } - EntityKind::MText(t) => { - if !b.opts.text { - return; - } - mtext(b, ctx, t, rgb, lw); - } - EntityKind::Insert(ins) => insert(b, ctx, e, ins, rgb), - EntityKind::Dimension(dm) => { - if let Some(blk) = dm.block.as_ref().and_then(|n| ctx.d.block(n)) - && 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 { - dimension(b, ctx, e, dm, lw, lt.as_ref(), ltscale); - } - } - EntityKind::Hatch(h) => hatch::draw(b, ctx, e, h, rgb, lw, tol), - EntityKind::MLine(m) => mline::draw(b, ctx, e, m, rgb, lw, tol), - EntityKind::LwPolyline(p) if p.const_width > 0.0 || p.vertices.iter().any(|v| v.start_width > 0.0 || v.end_width > 0.0) => { - if b.opts.fill && ctx.d.header.i64("FILLMODE", 1) != 0 { - // The GPU canvas has no MSAA: a band narrower than a pixel lights only where it - // covers pixel centres, so whole sides vanish at some zooms (#1434). On screen the - // centre line is drawn too, as a hairline (wide bands are drawn without lineweight), - // so a thin band still shows as a one-pixel line. Always, not below a threshold: - // the list serves a whole band of zooms. Plots and exports draw the band alone, and - // a transparent band gets none (its alpha would add up along the centre). - let hairline = !b.plotting && b.opts.min_dash > 0.0 && b.transparency == 0; - match < { - // Dashed: each dash a filled piece of the band; dots a line across it. - Some(lt) => { - let min = b.opts.min_dash / ctx.xf.scale_factor().max(1e-12); - let (tris, ticks, centres) = wide::dashed(p, tol, lt, ltscale, min); - b.tris(ctx, rgb, &tris); - if hairline { - for c in ¢res { - b.hairline(ctx, rgb, c); - } - } - for t in &ticks { - match t.as_slice() { - [q] => b.point(ctx, rgb, *q), - _ => b.polyline(ctx, rgb, lw, t), - } - } - } - None => { - b.tris(ctx, rgb, &fill::wide_polyline(p, tol)); - if hairline { - let pl = Polyline { vertices: p.vertices.clone(), closed: p.closed }; - b.hairline(ctx, rgb, &pl.tessellate(tol)); - } - } - } - } else { - let pl = Polyline { vertices: p.vertices.clone(), closed: p.closed }; - stroke(b, &pl.tessellate(tol)); - } - } - EntityKind::LwPolyline(p) => { - let pl = Polyline { vertices: p.vertices.clone(), closed: p.closed }; - if p.plinegen || lt.is_none() { - stroke(b, &pl.tessellate(tol)); - } else { - for s in pl.segments() { - let mut pts = Vec::new(); - s.tessellate(tol, &mut pts); - stroke(b, &pts); - } - } - } - EntityKind::Solid(s) | EntityKind::Trace(s) => { - let c = &s.corners; - let q = [c[0].xy(), c[1].xy(), c[3].xy(), c[2].xy()]; - if b.opts.fill && ctx.d.header.i64("FILLMODE", 1) != 0 { - b.tris(ctx, rgb, &[q[0], q[1], q[2], q[0], q[2], q[3]]); - } else { - b.polyline(ctx, rgb, lw, &[q[0], q[1], q[2], q[3], q[0]]); - } - } - EntityKind::Wipeout(w) => { - // Hide what was drawn before it (draw order), then the frame per WIPEOUTFRAME: - // 0 = none, 1 = shown and plotted, 2 = shown but not plotted. - if b.opts.fill { - let n = b.list.prims.len(); - b.tris(ctx, rgb, &fill::triangulate_evenodd(std::slice::from_ref(&w.boundary))); - if let Some(p) = b.list.prims.get_mut(n) { - p.kind = Kind::Mask; - } - } - let frame = match ctx.d.header.i64("WIPEOUTFRAME", 1) { - 0 => false, - 2 => !b.plotting, - _ => true, - }; - if frame { - let mut pts = w.boundary.clone(); - if let Some(f) = pts.first().copied() { - pts.push(f); - } - b.polyline(ctx, rgb, lw, &pts); - } - } - EntityKind::Image(i) => { - let o = i.insert.xy(); - let u = i.u.xy() * i.size.x; - let v = i.v.xy() * i.size.y; - b.polyline(ctx, rgb, lw, &[o, o + u, o + u + v, o + v, o]); - b.polyline(ctx, rgb, lw, &[o, o + u + v]); - b.polyline(ctx, rgb, lw, &[o + u, o + v]); - } - EntityKind::Table(t) => table(b, ctx, t, rgb, lw), - EntityKind::Tolerance(t) => tolerance::draw(b, ctx, e, t, lw, tol), - EntityKind::Leader(l) => { - let pts: Vec = l.vertices.iter().map(|v| v.xy()).collect(); - stroke(b, &pts); - if l.arrow - && let (Some(a), Some(n)) = (pts.first(), pts.get(1)) - { - let st = ctx.d.dim_style(&l.style); - // DIMASZ × DIMSCALE of the leader's style (the drawing's DIMSCALE when it is 0). - let size = st.map(|s| s.arrow_size).unwrap_or(0.18) * leader_scale(ctx.d, st); - // DIMLDRBLK ("" = closed filled). - let kind = dim::Arrowhead::parse(st.map(|s| s.leader_arrow_block.as_str()).unwrap_or("")); - let g = dim::arrowhead(kind, *a, (*a - *n).normalized(), size); - for l in &g.lines { - b.polyline(ctx, rgb, lw, l); - } - b.tris(ctx, rgb, &g.tris); - } - } - EntityKind::MLeader(m) => { - // Straight or spline leader lines; block content draws like a block reference. - for pts in m.leader_paths() { - b.polyline(ctx, rgb, lw, &pts); - if let (Some(a), Some(n)) = (pts.first(), pts.get(1)) { - b.tris(ctx, rgb, &dim::arrow(*a, (*a - *n).normalized(), m.arrow_size)); - } - } - if let Some(t) = &m.text { - let land = m.landing.xy(); - let dir = if t.insert.x >= land.x { 1.0 } else { -1.0 }; - b.polyline(ctx, rgb, lw, &[land, land + Vec2::new(m.dogleg * dir, 0.0)]); - mtext(b, ctx, t, rgb, lw); - } - if let Some(ins) = &m.block { - if m.dogleg > 0.0 && !m.leaders.is_empty() { - let land = m.landing.xy(); - let dir = if ins.insert.x >= land.x { 1.0 } else { -1.0 }; - b.polyline(ctx, rgb, lw, &[land, land + Vec2::new(m.dogleg * dir, 0.0)]); - } - insert(b, ctx, e, ins, rgb); - } - } - EntityKind::Point(p) => { - // Relative sizes follow the view: undo the block / viewport scale. - let view = b.opts.view_height / ctx.xf.scale_factor().max(1e-12); - let size = point::size(ctx.d.header.f64("PDSIZE", 0.0), view); - let fig = point::figure(ctx.d.header.i64("PDMODE", 0), size, p.p.xy(), p.angle, tol); - if fig.dot { - b.point(ctx, rgb, p.p.xy()); - } - for l in &fig.lines { - b.polyline(ctx, rgb, lw, l); - } - } - kind => { - for prim in kind.prims() { - match prim { - Prim::Seg(s) => { - let mut pts = Vec::new(); - s.tessellate(tol, &mut pts); - stroke(b, &pts); - } - Prim::Circle(c) => { - let mut pts = Vec::new(); - c.tessellate(tol, &mut pts); - stroke(b, &pts); - } - Prim::Ellipse(el) => { - let mut pts = Vec::new(); - el.tessellate(tol, &mut pts); - stroke(b, &pts); - } - Prim::Spline(s) => stroke(b, &s.tessellate(tol)), - Prim::Point(p) => b.point(ctx, rgb, p), - Prim::Infinite { base, dir, ray } => b.infinite(ctx, rgb, base, dir, ray), - Prim::Fill(pts) => { - let tris = fill::triangulate_evenodd(&[pts]); - b.tris(ctx, rgb, &tris); - } - } - } - } - } -} - -/// The text style of an entity (Standard defaults when missing). -fn text_style(d: &Drawing, name: &str) -> cadcraft_doc::TextStyle { - d.text_style(name).cloned().unwrap_or_default() -} - -/// Shape a TEXT / ATTRIB / ATTDEF with its style's font and generation flags. -pub fn place_text_entity(d: &Drawing, t: &cadcraft_doc::Text, value: &str) -> (cadcraft_fonts::Shaped, Bounds2) { - let ts = text_style(d, &t.style); - let font = cadcraft_fonts::TextFont::resolve(&ts.font); - let (h, v) = align(t.halign, t.valign); - let p = cadcraft_fonts::TextParams { - insert: t.insert.xy(), - align_pt: t.align_pt.map(|p| p.xy()), - height: t.height, - rotation: t.rotation, - width_factor: t.width_factor, - oblique: t.oblique, - h, - v, - backwards: ts.backwards, - upside_down: ts.upside_down, - }; - cadcraft_fonts::place(&font, value, &p) -} - -/// Lay out an MTEXT with its style's font. -pub fn layout_mtext_entity(d: &Drawing, t: &cadcraft_doc::MText) -> cadcraft_fonts::MTextLayout { - let ts = text_style(d, &t.style); - let params = cadcraft_fonts::MTextParams { - insert: t.insert.xy(), - height: t.height, - width: t.width, - attach: t.attach, - rotation: t.rotation, - line_spacing: t.line_spacing, - line_spacing_exact: t.line_spacing_exact, - font: cadcraft_fonts::TextFont::resolve(&ts.font), - width_factor: ts.width_factor, - oblique: ts.oblique, - columns: t.columns.as_ref().map(|c| cadcraft_fonts::MTextColumnSpec { - dynamic: c.kind == cadcraft_doc::MTextColumnType::Dynamic, - count: usize::from(c.count.max(1)), - gutter: c.gutter, - height: c.height, - heights: if c.kind == cadcraft_doc::MTextColumnType::Dynamic && !c.auto_height { c.heights.clone() } else { Vec::new() }, - reversed: c.flow_reversed, - }), - }; - cadcraft_fonts::layout_mtext_with(&t.contents, ¶ms) -} - -fn mtext_color(c: Option, rgb: Ink) -> Ink { - match c { - Some(cadcraft_fonts::MTextColor::Aci(i)) => { - u8::try_from(i).ok().filter(|i| *i > 0).map(|i| ink(Color::Index(i), Color::Index(7), Color::Index(7))).unwrap_or(rgb) - } - Some(cadcraft_fonts::MTextColor::Rgb(v)) => { - Ink { rgb: Rgb(((v >> 16) & 0xFF) as u8, ((v >> 8) & 0xFF) as u8, (v & 0xFF) as u8), aci7: false, aci: 0 } - } - None => rgb, - } -} - -fn mtext(b: &mut Builder, ctx: &Ctx, t: &cadcraft_doc::MText, rgb: Ink, lw: f32) { - let l = layout_mtext_entity(ctx.d, t); - for p in &l.pieces { - b.shaped(ctx, mtext_color(p.color, rgb), lw, &p.shaped); - } -} - -/// The font settings of a dimension style's text style. -pub fn dim_text(d: &Drawing, st: &cadcraft_doc::DimStyle) -> DimText { - let ts = text_style(d, &st.text_style); - DimText { font: cadcraft_fonts::TextFont::resolve(&ts.font), fixed_height: ts.height, width_factor: ts.width_factor, oblique: ts.oblique } -} - -/// Geometry of a dimension in a drawing: its style, overrides and text font. -pub fn dimension_in(d: &Drawing, dm: &cadcraft_doc::Dimension) -> DimGeometry { - let style = d.dim_style(&dm.style).cloned().unwrap_or_default(); - let st = style.with_overrides(&dm.overrides); - dim::dimension_geometry_with(dm, &style, d.header.f64("DIMSCALE", 1.0), &dim_text(d, &st)) -} - -/// Draw a polyline with a linetype (None: continuous). -fn stroke_with(b: &mut Builder, ctx: &Ctx, rgb: Ink, lw: f32, lt: Option<&cadcraft_doc::Linetype>, ltscale: f64, pts: &[Vec2]) { - match lt { - Some(lt) => { - let min = b.opts.min_dash / ctx.xf.scale_factor().max(1e-12); - for dash in linetype::apply(pts, lt, ltscale, min) { - match dash.as_slice() { - [p] => b.point(ctx, rgb, *p), - _ => b.polyline(ctx, rgb, lw, &dash), - } - } - } - None => b.polyline(ctx, rgb, lw, pts), - } -} - -/// `lw` / `lt` are the dimension's own lineweight and linetype (ByBlock in DIMLWD, DIMLWE, -/// DIMLTYPE, DIMLTEX1 and DIMLTEX2). -fn dimension(b: &mut Builder, ctx: &Ctx, e: &Entity, dm: &cadcraft_doc::Dimension, lw: f32, lt: Option<&cadcraft_doc::Linetype>, ltscale: f64) { - let d = ctx.d; - let style = d.dim_style(&dm.style).cloned().unwrap_or_default().with_overrides(&dm.overrides); - let g = dimension_in(d, dm); - // DIMCLRD / DIMCLRE / DIMCLRT: ByBlock = the dimension's own colour. - 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 dim_rgb = ink(style.dim_line_color, layer_color, own); - let ext_rgb = ink(style.ext_line_color, layer_color, own); - let txt_rgb = ink(style.text_color, layer_color, own); - let layer = d.layer(layer_name); - let weight = |v: i16| -> f32 { - if !b.opts.lineweights { - return 0.0; - } - match Lineweight::from_dxf(v) { - Lineweight::ByBlock => lw, - Lineweight::Mm100(x) => f32::from(x) / 100.0, - Lineweight::ByLayer => match layer.map(|l| l.lineweight) { - Some(Lineweight::Mm100(x)) => f32::from(x) / 100.0, - _ => default_lineweight(d), - }, - Lineweight::Default => default_lineweight(d), - } - }; - let ltype = |name: &str| -> Option { - match name.trim().to_ascii_lowercase().as_str() { - "" | "byblock" => lt.cloned(), - "bylayer" => layer.and_then(|l| d.linetype(&l.linetype)).filter(|l| !l.pattern.is_empty()).cloned(), - _ => d.linetype(name.trim()).filter(|l| !l.pattern.is_empty()).cloned(), - } - }; - 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)); - 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}; -fn table(b: &mut Builder, ctx: &Ctx, t: &cadcraft_doc::Table, rgb: Ink, lw: f32) { - table::draw(b, ctx, t, rgb, lw); -} +mod blocks; +mod dim_draw; +mod display; +mod draw; +mod pipeline; +mod props; +mod text_entity; +mod viewports; -/// A TEXT's justification as the fonts' alignment (used where text is placed outside a build). -pub fn text_align(h: cadcraft_doc::HAlign, v: cadcraft_doc::VAlign) -> (cadcraft_fonts::Align, cadcraft_fonts::VAlign) { - align(h, v) -} - -fn align(h: cadcraft_doc::HAlign, v: cadcraft_doc::VAlign) -> (cadcraft_fonts::Align, cadcraft_fonts::VAlign) { - use cadcraft_fonts::{Align as A, VAlign as V}; - let ha = match h { - cadcraft_doc::HAlign::Left => A::Left, - cadcraft_doc::HAlign::Center => A::Center, - cadcraft_doc::HAlign::Right => A::Right, - cadcraft_doc::HAlign::Aligned => A::Aligned, - cadcraft_doc::HAlign::Middle => A::Middle, - cadcraft_doc::HAlign::Fit => A::Fit, - }; - let va = match v { - cadcraft_doc::VAlign::Baseline => V::Baseline, - cadcraft_doc::VAlign::Bottom => V::Bottom, - cadcraft_doc::VAlign::Middle => V::Middle, - cadcraft_doc::VAlign::Top => V::Top, - }; - (ha, va) -} - -fn sub_ctx<'a>(ctx: &Ctx<'a>, e: &Entity, m: Mat3) -> Ctx<'a> { - let layer = if e.common.layer == "0" { ctx.block_layer.clone() } else { Some(e.common.layer.clone()) }; - let color = match e.common.color { - Color::ByBlock => ctx.block_color, - Color::ByLayer => ctx.layer_color(layer.as_deref().unwrap_or("0")).unwrap_or(Color::Index(7)), - c => c, - }; - let lw = match e.common.lineweight { - Lineweight::ByBlock => ctx.block_lw, - Lineweight::ByLayer => ctx.d.layer(layer.as_deref().unwrap_or("0")).map(|l| l.lineweight).unwrap_or(Lineweight::Default), - x => x, - }; - let lt = match e.common.linetype.to_ascii_lowercase().as_str() { - "byblock" => ctx.block_ltype.clone(), - "bylayer" => ctx.d.layer(layer.as_deref().unwrap_or("0")).map(|l| l.linetype.clone()).unwrap_or_else(|| "Continuous".into()), - _ => e.common.linetype.clone(), - }; - Ctx { - d: ctx.d, - xf: ctx.xf.then_before(m), - block_color: color, - block_layer: layer, - block_lw: lw, - block_ltype: lt, - depth: ctx.depth + 1, - top: ctx.top, - frozen: ctx.frozen, - vp_colors: ctx.vp_colors, - // The reference's own linetype scale (group 48) multiplies that of the objects inside, - // as LTSCALE does for the drawing (tests/block_ltscale.rs). - lt_factor: ctx.lt_factor * if e.common.ltscale.is_finite() && e.common.ltscale > 0.0 { e.common.ltscale } else { 1.0 }, - block_pstyle: ctx.block_pstyle, - // The reference's own, set by the caller (`Builder::transparency` while it draws). - block_transparency: ctx.block_transparency, - } -} - -fn insert(b: &mut Builder, ctx: &Ctx, e: &Entity, ins: &cadcraft_doc::Insert, rgb: Ink) { - if ctx.depth >= cadcraft_doc::MAX_BLOCK_DEPTH { - return; - } - if let Some(blk) = ctx.d.block(&ins.block) { - let cols = ins.cols.clamp(1, 10_000); - let rows = ins.rows.clamp(1, 10_000); - 'copies: for r in 0..rows { - for c in 0..cols { - if !b.expand() { - break 'copies; - } - let off = Vec2::new(ins.col_spacing * f64::from(c), ins.row_spacing * f64::from(r)).rotate(ins.rotation); - let m = Mat3::translate(off).then_before(ins.transform(blk.base.xy())); - let sub = Ctx { block_pstyle: b.named, block_transparency: b.transparency, ..sub_ctx(ctx, e, m) }; - for be in blk.entities.iter() { - // Constant/visible attribute definitions inside blocks are not drawn; attribs are. - if matches!(be.kind, EntityKind::AttDef(_)) { - continue; - } - if !b.expand() { - break 'copies; - } - entity(b, &sub, be); - } - } - } - } - if b.opts.text { - // ATTMODE (ATTDISP): 0 shows no attributes, 1 the visible ones, 2 all of them. - let attmode = ctx.d.header.i64("ATTMODE", 1); - // The reference's transparency; each attribute draws with its own. - let reference = b.transparency; - for a in &ins.attribs { - if attmode == 0 || (a.invisible && attmode != 2) { - continue; - } - // A multiline attribute is drawn as its multiline text. - let multiline = a.multiline_text(); - let sh = if multiline.is_none() { Some(place_text_entity(ctx.d, &a.text, &a.text.value).0) } else { None }; - // Properties of the attribute's definition (as AutoCAD copies them to the attribute - // on insertion), except those set on the attribute itself: ByBlock = the reference's, - // layer 0 = the reference's layer. - let def = ctx.d.block(&ins.block).and_then(|blk| blk.entities.iter().find(|be| attdef_tag(be, &a.tag))); - let (ink, lw, shown, t) = match (def, a.props.is_empty()) { - (None, true) => (rgb, resolve(ctx, e, b.plotting).1, true, reference), - (def, _) => { - let c = a.props.over(&def.map(|d| d.common.clone()).unwrap_or_else(by_block)); - let (ink, lw, _, _, shown, layer_t) = resolve_common(&sub_ctx(ctx, e, Mat3::IDENTITY), &c, b.plotting); - let t = if b.opts.transparency { transparency(c.transparency, layer_t, reference) } else { 0 }; - (ink, lw, shown, t) - } - }; - if shown { - b.transparency = t; - let lw = if b.opts.lineweights { lw } else { 0.0 }; - match (&multiline, &sh) { - (Some(m), _) => mtext(b, ctx, m, ink, lw), - (None, Some(sh)) => b.shaped(ctx, ink, lw, sh), - (None, None) => {} - } - } - } - b.transparency = reference; - } -} - -/// Properties that follow the block reference's (an attribute without a definition). -fn by_block() -> cadcraft_doc::Common { - cadcraft_doc::Common { - color: Color::ByBlock, - linetype: "ByBlock".into(), - lineweight: Lineweight::ByBlock, - transparency: cadcraft_doc::Transparency::ByBlock, - ..cadcraft_doc::Common::default() - } -} - -/// The attribute definition with tag `tag` (any case). -fn attdef_tag(e: &Entity, tag: &str) -> bool { - matches!(&e.kind, EntityKind::AttDef(d) if d.tag.eq_ignore_ascii_case(tag)) -} - -/// The overall scale of a leader drawn with dimension style `st`: its DIMSCALE, else the -/// drawing's. -fn leader_scale(d: &Drawing, st: Option<&cadcraft_doc::DimStyle>) -> f64 { - let dimscale = d.header.f64("DIMSCALE", 1.0); - st.map(|s| s.effective_scale(dimscale)).unwrap_or_else(|| cadcraft_doc::DimStyle::default().effective_scale(dimscale)) -} +pub use dim_draw::{dim_text, dimension_in}; +pub use display::{DPrim, DisplayList, Kind, MAX_TRANSPARENCY, NO_LAYER, PlotText, VIEWPORT_CONTENT, alpha}; +pub use pipeline::{Options, build, build_entities, build_plot}; +use props::{default_lineweight, resolve_common}; +use text_entity::text_style; +pub use text_entity::{layout_mtext_entity, place_text_entity, text_align}; +pub use viewports::{viewport_clip, viewport_content}; #[cfg(test)] mod tests; diff --git a/crates/render/src/pipeline.rs b/crates/render/src/pipeline.rs new file mode 100644 index 000000000..3427c61a4 --- /dev/null +++ b/crates/render/src/pipeline.rs @@ -0,0 +1,237 @@ +#[cfg(not(target_arch = "wasm32"))] +use super::display::append; +use super::display::{DisplayList, Kind, NO_LAYER, PlotText, VIEWPORT_CONTENT}; +use super::draw::entity; +use super::viewports::{MAX_VIEWPORTS, viewport}; +use super::*; + +/// Build options. +#[derive(Clone, Debug)] +pub struct Options { + /// Chord tolerance in world units (about half a pixel at the current zoom). + pub tolerance: f64, + /// Two pixels in world units: shorter dashes are continuous and filled font text uses + /// screen-size detail. Zero keeps full detail (plots and image/vector exports). + pub min_dash: f64, + /// Draw text (off for very coarse previews). + pub text: bool, + /// Highlighted (selected) handles are not special here; the canvas overlays them. + pub fill: bool, + pub lineweights: bool, + /// Height of the visible area in world units of the space being built. Relative point + /// sizes (`PDSIZE` <= 0) are a percentage of it; 0 = the height of the space's extents. + pub view_height: f64, + /// Plot style table to draw with. When `None`, [`build`] of a layout whose page setup has + /// "Display plot styles" on uses the layout's table; [`build_plot`] uses none. + pub plot_style_table: Option>, + /// The area being looked at (world units of the space being built), for on-screen builds: + /// hatch patterns are only generated inside it. `None` = everywhere (plots, exports, picks). + pub view: Option, + /// Draw objects with their transparency ([`DPrim::transparency`]). Off by default: the screen + /// follows TRANSPARENCYDISPLAY, plots the page setup's "Plot transparency". + pub transparency: bool, + /// A layout viewport whose model-space contents are left out (its border is still drawn): + /// the canvas draws the active viewport's contents itself, see [`viewport_content`]. + pub skip_viewport: Option, + /// Set from another thread to stop the build early (the canvas cancels a background + /// rebuild the view no longer needs). A cancelled build returns a partial list, which the + /// caller must discard; see [`Options::cancelled`]. + pub cancel: Option>, +} + +impl Options { + /// Whether [`Options::cancel`] has been set. + pub fn cancelled(&self) -> bool { + self.cancel.as_ref().is_some_and(|c| c.load(std::sync::atomic::Ordering::Relaxed)) + } +} + +impl Default for Options { + fn default() -> Self { + Options { + tolerance: 0.001, + min_dash: 0.0, + text: true, + fill: true, + lineweights: false, + view_height: 0.0, + plot_style_table: None, + view: None, + skip_viewport: None, + transparency: false, + cancel: None, + } + } +} + +/// Build the display list for a space. For a layout (paper space) this includes the model-space +/// geometry seen through each viewport, clipped to the viewport, and [`DisplayList::sheet`]. +pub fn build(d: &Drawing, space: &Space, opts: &Options) -> DisplayList { + build_space(d, space, opts, false) +} + +/// Like [`build`] but for plotting: layers marked "do not plot" (such as `Defpoints`) are left out. +pub fn build_plot(d: &Drawing, space: &Space, opts: &Options) -> DisplayList { + build_space(d, space, opts, true) +} + +pub(crate) fn top_ctx<'a>(d: &'a Drawing, xf: Mat3, top: Handle, frozen: &'a [String], vp_colors: &'a [(String, Color)]) -> Ctx<'a> { + Ctx { + d, + xf, + block_color: Color::Index(7), + block_layer: None, + block_lw: Lineweight::Default, + block_ltype: "Continuous".into(), + depth: 0, + top, + frozen, + vp_colors, + lt_factor: 1.0, + block_pstyle: None, + block_transparency: 0, + } +} + +fn build_space(d: &Drawing, space: &Space, opts: &Options, plotting: bool) -> DisplayList { + let opts = &with_point_view(d, space, opts); + let styler = style_table(d, space, opts, plotting).map(|t| plotstyle::Styler::new(d, t)); + let mut b = Builder { list: DisplayList::default(), opts, plotting, styles: styler.as_ref(), named: None, shared_budget: None, transparency: 0 }; + if let Space::Paper(name) = space { + b.list.sheet = paper::sheet(d, name); + } + if let Some(store) = d.space(space) { + let paper = matches!(space, Space::Paper(_)); + #[cfg(not(target_arch = "wasm32"))] + if !paper && !plotting && b.styles.is_none() && store.len() >= 2 * PARALLEL_CHUNK { + build_parallel(&mut b, d, store); + return b.list; + } + let mut viewports = 0usize; + for e in store.iter() { + if paper && let EntityKind::Viewport(vp) = &e.kind { + if viewports < MAX_VIEWPORTS { + viewports += 1; + viewport(&mut b, d, e, vp); + if plotting { + take_viewport_layers(&mut b.list); + } + } + continue; + } + entity(&mut b, &top_ctx(d, Mat3::IDENTITY, e.handle, &[], &[]), e); + } + } + // A layout is drawn in paper units, so its pens become geometry now. + if let Some(sheet) = b.list.sheet { + pen::apply(&mut b.list, sheet.unit_mm); + } + b.list +} + +/// Top-level entities per task when a display list is built on all cores. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) const PARALLEL_CHUNK: usize = 256; + +/// Build model space for the screen on all cores: the entities are split into chunks built +/// side by side, then joined in drawing order, so the list is the same as a one-thread build. +/// The canvas rebuilds the whole list at every 2x zoom step (in the background since zooming +/// stopped blocking the UI) and after every edit; one core made both slow on real drawings. Plots, layouts and plot styles keep the one-thread +/// path (they run once, and their pens and per-primitive layers are built along the way). +#[cfg(not(target_arch = "wasm32"))] +fn build_parallel(b: &mut Builder, d: &Drawing, store: &cadcraft_doc::EntityStore) { + use rayon::prelude::*; + let ents: Vec<&std::sync::Arc> = store.iter().collect(); + let budget = std::sync::atomic::AtomicUsize::new(0); + let (opts, plotting) = (b.opts, b.plotting); + let parts: Vec = ents + .par_chunks(PARALLEL_CHUNK) + .map(|chunk| { + let mut part = + Builder { list: DisplayList::default(), opts, plotting, styles: None, named: None, shared_budget: Some(&budget), transparency: 0 }; + for e in chunk { + entity(&mut part, &top_ctx(d, Mat3::IDENTITY, e.handle, &[], &[]), e); + } + part.list + }) + .collect(); + // One allocation per array for the joined list. + b.list.prims.reserve(parts.iter().map(|p| p.prims.len()).sum()); + b.list.verts.reserve(parts.iter().map(|p| p.verts.len()).sum()); + b.list.tris.reserve(parts.iter().map(|p| p.tris.len()).sum()); + for part in parts { + append(&mut b.list, part); + } +} + +/// The plot style table a build draws with: the one in `opts`, else (on screen) the layout's +/// when its page setup has "Display plot styles" on. +pub(crate) fn style_table(d: &Drawing, space: &Space, opts: &Options, plotting: bool) -> Option> { + opts.plot_style_table.clone().or_else(|| match space { + Space::Paper(n) if !plotting => d + .layout(n) + .filter(|l| l.page.show_plot_styles) + .and_then(|l| cadcraft_doc::plot_style_table(d, &l.page.plot_style_table)) + .map(std::sync::Arc::new), + _ => None, + }) +} + +/// `opts` with a view height for relative point sizes: the extents height when none is given +/// and points draw as figures sized relative to the view. +pub(crate) fn with_point_view(d: &Drawing, space: &Space, opts: &Options) -> Options { + let mut o = opts.clone(); + let relative = d.header.f64("PDSIZE", 0.0) <= 0.0 && point::sized(d.header.i64("PDMODE", 0)); + if !(o.view_height.is_finite() && o.view_height > 0.0) && relative { + let ext = d.extents(space); + o.view_height = if ext.is_empty() { 0.0 } else { ext.height().max(ext.width() * 1e-3) }; + } + o +} + +/// Build the display list for a set of loose entities (previews, rubber bands). +pub fn build_entities<'a, I: IntoIterator>(d: &Drawing, ents: I, opts: &Options) -> DisplayList { + let ents: Vec<&Entity> = ents.into_iter().collect(); + // Only previews holding points need the model extents for relative point sizes. + let with_view; + let opts = if ents.iter().any(|e| matches!(e.kind, EntityKind::Point(_))) { + with_view = with_point_view(d, &Space::Model, opts); + &with_view + } else { + opts + }; + let mut b = Builder { list: DisplayList::default(), opts, plotting: false, styles: None, named: None, shared_budget: None, transparency: 0 }; + for e in ents { + entity(&mut b, &top_ctx(d, Mat3::IDENTITY, e.handle, &[], &[]), e); + } + b.list +} + +/// After a viewport is appended to a plot: the layers its contents carry in their handles +/// (see [`entity`]) go to [`DisplayList::prim_layers`], and the handles back to +/// [`VIEWPORT_CONTENT`]. The viewport's border was tagged when it was drawn. +/// +/// Text glyphs seen through the viewport (marked in the handles' high half) keep their outline +/// primitive when it came through the clip whole; a glyph cut by the viewport edge stays +/// geometry. +fn take_viewport_layers(list: &mut DisplayList) { + let start = list.prim_layers.len(); + for i in start..list.prims.len() { + let Some(p) = list.prims.get(i).copied() else { break }; + list.prim_layers.push(u32::try_from(p.handle.0 & 0xFFFF_FFFF).unwrap_or(NO_LAYER)); + let bounds = Bounds2::from_points(list.points(&p).iter().copied()); + if let Some(t) = (p.handle.0 >> 32).checked_sub(1).and_then(|k| list.texts.get_mut(k as usize)) { + let same = |a: Vec2, b: Vec2| a.dist(b) <= 1e-9 * (1.0 + a.len()); + let whole = t.prim == PlotText::NONE + && p.kind == Kind::Tris + && p.len == t.verts + && same(bounds.min, t.bounds.min) + && same(bounds.max, t.bounds.max); + // A second piece (or a cut one) means the clip changed the glyph. + t.prim = if whole { u32::try_from(i).unwrap_or(PlotText::NONE) } else { PlotText::CLIPPED }; + } + if let Some(p) = list.prims.get_mut(i) { + p.handle = VIEWPORT_CONTENT; + } + } +} diff --git a/crates/render/src/props.rs b/crates/render/src/props.rs new file mode 100644 index 000000000..91419b0cb --- /dev/null +++ b/crates/render/src/props.rs @@ -0,0 +1,61 @@ +use super::display::MAX_TRANSPARENCY; +use super::*; + +/// An object's colour, lineweight (mm), linetype, linetype scale, visibility and the transparency +/// of the layer it is drawn on (for ByLayer, see [`transparency`]). +type Resolved = (Ink, f32, Option, f64, bool, u8); + +pub(crate) fn resolve(ctx: &Ctx, e: &Entity, plotting: bool) -> Resolved { + resolve_common(ctx, &e.common, plotting) +} + +/// [`resolve`] for an object's properties `c`. +pub(crate) fn resolve_common(ctx: &Ctx, c: &cadcraft_doc::Common, plotting: bool) -> Resolved { + let d = ctx.d; + // Layer "0" inside a block takes the insert's layer. + let layer_name = if c.layer == "0" { ctx.block_layer.as_deref().unwrap_or("0") } else { c.layer.as_str() }; + let layer = d.layer(layer_name); + let visible = + c.visible && layer.is_none_or(|l| l.visible() && (!plotting || l.plot)) && !ctx.frozen.iter().any(|f| f.eq_ignore_ascii_case(layer_name)); + let layer_color = ctx.layer_color(layer_name).unwrap_or(Color::Index(7)); + let rgb = ink(c.color, layer_color, ctx.block_color); + let lw = match c.lineweight { + Lineweight::ByLayer => layer.map(|l| l.lineweight).unwrap_or(Lineweight::Default), + Lineweight::ByBlock => ctx.block_lw, + x => x, + }; + let lw_mm = match lw { + Lineweight::Mm100(v) => f32::from(v) / 100.0, + _ => default_lineweight(d), + }; + let lt_name = match c.linetype.to_ascii_lowercase().as_str() { + "bylayer" => layer.map(|l| l.linetype.clone()).unwrap_or_else(|| "Continuous".into()), + "byblock" => ctx.block_ltype.clone(), + _ => c.linetype.clone(), + }; + let lt = d.linetype(<_name).filter(|l| !l.pattern.is_empty()).cloned(); + let scale = d.header.f64("LTSCALE", 1.0) * c.ltscale * ctx.lt_factor; + (rgb, lw_mm, lt, scale, visible, layer.map_or(0, |l| l.transparency)) +} + +/// The transparency (percent, 0..=90) of an object whose own setting is `t`: the percentage, its +/// layer's `layer` when ByLayer (layer 0 inside a block: the block reference's layer, as +/// [`resolve_common`] finds it), the block reference's `block` when ByBlock (opaque outside +/// blocks). +pub(crate) fn transparency(t: cadcraft_doc::Transparency, layer: u8, block: u8) -> u8 { + let t = match t { + cadcraft_doc::Transparency::Percent(p) => p, + cadcraft_doc::Transparency::ByBlock => block, + cadcraft_doc::Transparency::ByLayer => layer, + }; + t.min(MAX_TRANSPARENCY) +} + +/// The width in mm that the "Default" lineweight stands for: `LWDEFAULT` (hundredths of a mm, +/// 0 to 211), 0.25 mm when it is unset or out of range. +pub(crate) fn default_lineweight(d: &Drawing) -> f32 { + match d.header.i64("LWDEFAULT", 25) { + v @ 0..=211 => v as f32 / 100.0, + _ => 0.25, + } +} diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs index 379c083e6..72ac821d0 100644 --- a/crates/render/src/raster.rs +++ b/crates/render/src/raster.rs @@ -78,6 +78,8 @@ pub fn render(list: &DisplayList, view: &View, o: &RasterOptions) -> Option = Vec::new(); for p in &list.prims { let c = p.display_rgb(o.background); let mut paint = Paint::default(); @@ -85,23 +87,32 @@ pub fn render(list: &DisplayList, view: &View, o: &RasterOptions) -> Option { let bb = Bounds2::from_points(pts.iter().copied()); if !bb.intersects(&vis) { continue; } + let w = if o.px_per_mm > 0.0 && p.lw > 0.0 { (p.lw * o.px_per_mm).max(o.hairline) } else { o.hairline }; + screen.clear(); + screen.extend(pts.iter().map(|q| view.to_screen(*q))); + if is_hairline(w, o.antialias) && screen.len() >= 2 && screen.iter().all(|&(x, y)| batchable(x) && batchable(y)) { + hairlines.add(&mut pm, [c.0, c.1, c.2, p.alpha()], w, &screen, o.antialias); + continue; + } + hairlines.flush(&mut pm, o.antialias); let mut pb = PathBuilder::new(); - for (i, q) in pts.iter().enumerate() { - let (x, y) = view.to_screen(*q); + for (i, (x, y)) in screen.iter().enumerate() { if i == 0 { - pb.move_to(x, y); + pb.move_to(*x, *y); } else { - pb.line_to(x, y); + pb.line_to(*x, *y); } } if let Some(path) = pb.finish() { - let w = if o.px_per_mm > 0.0 && p.lw > 0.0 { (p.lw * o.px_per_mm).max(o.hairline) } else { o.hairline }; let stroke = Stroke { width: w, line_cap: LineCap::Round, line_join: LineJoin::Round, ..Default::default() }; pm.stroke_path(&path, &paint, &stroke, Transform::identity(), None); } @@ -152,9 +163,65 @@ pub fn render(list: &DisplayList, view: &View, o: &RasterOptions) -> Option bool { + width == 0.0 || (antialias && width <= 1.0) +} + +/// Largest screen coordinate (pixels) of a polyline stroked together with others. Beyond it a +/// path's integer bounds could overflow, so such a polyline is stroked on its own as before. +const MAX_BATCH_COORD: f32 = 1.0e8; + +fn batchable(v: f32) -> bool { + v.is_finite() && v.abs() <= MAX_BATCH_COORD +} + +/// Consecutive hairline polylines with the same colour, alpha and width, stroked as one path. +/// +/// tiny-skia draws a hairline segment by segment straight onto the pixmap (each segment with +/// its own caps, a polyline's first and last segment extended for round caps), so one path of +/// several polylines paints exactly the pixels, in the same order, as stroking them one by one; +/// it saves building a paint pipeline and a path per polyline. Thicker strokes are filled +/// outlines, where overlaps would cover once instead of twice, and are never batched. +#[derive(Default)] +struct Hairlines { + key: Option<([u8; 4], f32)>, + pb: PathBuilder, +} + +impl Hairlines { + fn add(&mut self, pm: &mut Pixmap, rgba: [u8; 4], width: f32, pts: &[(f32, f32)], antialias: bool) { + if self.key.is_some_and(|k| k != (rgba, width)) { + self.flush(pm, antialias); + } + self.key = Some((rgba, width)); + for (i, (x, y)) in pts.iter().enumerate() { + if i == 0 { + self.pb.move_to(*x, *y); + } else { + self.pb.line_to(*x, *y); + } + } + } + + fn flush(&mut self, pm: &mut Pixmap, antialias: bool) { + let Some(([r, g, b, a], width)) = self.key.take() else { return }; + let pb = std::mem::take(&mut self.pb); + let Some(path) = pb.finish() else { return }; + let mut paint = Paint::default(); + paint.set_color_rgba8(r, g, b, a); + paint.anti_alias = antialias; + let stroke = Stroke { width, line_cap: LineCap::Round, line_join: LineJoin::Round, ..Default::default() }; + pm.stroke_path(&path, &paint, &stroke, Transform::identity(), None); + self.pb = path.clear(); + } +} + /// Render and encode as PNG. pub fn render_png(list: &DisplayList, view: &View, o: &RasterOptions) -> Option> { render(list, view, o)?.encode_png().ok() diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index fc4b99156..ae35f27f9 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -999,3 +999,126 @@ fn wide_polyline_centre_line_is_drawn_on_screen_only() { assert_eq!(lines(&see_through), 0, "{lt}: a transparent band gets no hairline"); } } + +/// Each primitive stroked or filled on its own, as `render` did before it batched hairlines. +fn render_one_by_one(list: &DisplayList, view: &View, o: &RasterOptions) -> tiny_skia::Pixmap { + use tiny_skia::{FillRule, LineCap, LineJoin, Paint, PathBuilder, Pixmap, Stroke, Transform}; + let mut pm = Pixmap::new(view.width, view.height).unwrap(); + pm.fill(tiny_skia::Color::from_rgba8(o.background.0, o.background.1, o.background.2, 255)); + for p in &list.prims { + let c = p.display_rgb(o.background); + let mut paint = Paint::default(); + paint.set_color_rgba8(c.0, c.1, c.2, p.alpha()); + paint.anti_alias = o.antialias; + let mut pb = PathBuilder::new(); + match p.kind { + Kind::Polyline => { + for (i, q) in list.points(p).iter().enumerate() { + let (x, y) = view.to_screen(*q); + if i == 0 { + pb.move_to(x, y); + } else { + pb.line_to(x, y); + } + } + if let Some(path) = pb.finish() { + let w = if o.px_per_mm > 0.0 && p.lw > 0.0 { (p.lw * o.px_per_mm).max(o.hairline) } else { o.hairline }; + let stroke = Stroke { width: w, line_cap: LineCap::Round, line_join: LineJoin::Round, ..Default::default() }; + pm.stroke_path(&path, &paint, &stroke, Transform::identity(), None); + } + } + Kind::Tris => { + for t in list.points(p).chunks(3) { + let [a, b, c] = t else { continue }; + let ((ax, ay), (bx, by), (cx, cy)) = (view.to_screen(*a), view.to_screen(*b), view.to_screen(*c)); + pb.move_to(ax, ay); + pb.line_to(bx, by); + pb.line_to(cx, cy); + pb.close(); + } + if let Some(path) = pb.finish() { + pm.fill_path(&path, &paint, FillRule::Winding, Transform::identity(), None); + } + } + _ => panic!("not drawn by this reference"), + } + } + pm +} + +fn push_prim(l: &mut DisplayList, kind: Kind, color: Rgb, transparency: u8, lw: f32, pts: &[Vec2]) { + let arr = if kind == Kind::Tris { &mut l.tris } else { &mut l.verts }; + let start = arr.len() as u32; + arr.extend_from_slice(pts); + for p in pts.iter().filter(|p| p.is_finite() && p.x.abs() < 1e6) { + l.bounds.add(*p); + } + l.prims.push(DPrim { handle: VIEWPORT_CONTENT, color, aci7: false, lw, transparency, kind, start, len: pts.len() as u32 }); +} + +/// Hairline polylines with the same paint are stroked as one path; the pixels must be exactly +/// those of stroking each on its own, whatever breaks a run (colour, transparency, lineweight, +/// fills, unusable coordinates) and with overlaps, caps and dots. +#[test] +fn raster_hairline_batches_match_one_by_one() { + let red = Rgb(255, 0, 0); + let blue = Rgb(0, 0, 255); + let mut l = DisplayList::default(); + // A hatch-like run of crossing, overlapping and touching lines in one colour. + for i in 0..40 { + let x = f64::from(i) * 0.37; + push_prim(&mut l, Kind::Polyline, red, 0, 0.0, &[Vec2::new(x, 0.0), Vec2::new(x + 6.0, 9.0)]); + push_prim(&mut l, Kind::Polyline, red, 0, 0.0, &[Vec2::new(x + 3.0, 0.0), Vec2::new(x - 2.0, 9.0), Vec2::new(x + 1.0, 4.0)]); + } + // Zero-length (a dot), single-point (nothing), and repeated lines. + push_prim(&mut l, Kind::Polyline, red, 0, 0.0, &[Vec2::new(2.0, 2.0), Vec2::new(2.0, 2.0)]); + push_prim(&mut l, Kind::Polyline, red, 0, 0.0, &[Vec2::new(3.0, 3.0)]); + push_prim(&mut l, Kind::Polyline, red, 0, 0.0, &[Vec2::new(1.0, 1.0), Vec2::new(8.0, 2.0)]); + push_prim(&mut l, Kind::Polyline, red, 0, 0.0, &[Vec2::new(1.0, 1.0), Vec2::new(8.0, 2.0)]); + // Transparent lines build up where they overlap, within a run and across runs. + for i in 0..10 { + let y = f64::from(i) * 0.2; + push_prim(&mut l, Kind::Polyline, blue, 50, 0.0, &[Vec2::new(0.0, y), Vec2::new(12.0, 8.0 - y)]); + } + push_prim(&mut l, Kind::Polyline, blue, 20, 0.0, &[Vec2::new(0.0, 5.0), Vec2::new(12.0, 5.0)]); + // A lineweight (a filled outline when lineweights are shown), a fill, then the run again. + push_prim(&mut l, Kind::Polyline, red, 0, 0.5, &[Vec2::new(0.0, 6.0), Vec2::new(12.0, 7.0), Vec2::new(5.0, 2.0)]); + push_prim(&mut l, Kind::Tris, blue, 30, 0.0, &[Vec2::new(4.0, 4.0), Vec2::new(9.0, 4.0), Vec2::new(6.0, 8.0)]); + push_prim(&mut l, Kind::Polyline, red, 0, 0.0, &[Vec2::new(0.0, 8.0), Vec2::new(12.0, 1.0)]); + // Unusable or far-out coordinates are stroked on their own, as before. + push_prim(&mut l, Kind::Polyline, red, 0, 0.0, &[Vec2::new(1.0, 1.0), Vec2::new(f64::NAN, 3.0), Vec2::new(9.0, 9.0)]); + push_prim(&mut l, Kind::Polyline, red, 0, 0.0, &[Vec2::new(-1e12, 4.0), Vec2::new(1e12, 4.5)]); + push_prim(&mut l, Kind::Polyline, red, 0, 0.0, &[Vec2::new(5.0, -1e7), Vec2::new(5.5, 1e7)]); + push_prim(&mut l, Kind::Polyline, red, 0, 0.0, &[Vec2::new(0.0, 0.5), Vec2::new(12.0, 0.5)]); + // Partly and wholly off screen. + push_prim(&mut l, Kind::Polyline, red, 0, 0.0, &[Vec2::new(-30.0, -30.0), Vec2::new(40.0, 30.0)]); + push_prim(&mut l, Kind::Polyline, red, 0, 0.0, &[Vec2::new(100.0, 100.0), Vec2::new(120.0, 101.0)]); + let view = View::fit(&l.bounds, 160, 120, 0.05); + let bg = Rgb(33, 40, 48); + for o in [ + RasterOptions::default(), + RasterOptions { antialias: false, ..Default::default() }, + RasterOptions { hairline: 0.5, ..Default::default() }, + RasterOptions { hairline: 0.0, antialias: false, ..Default::default() }, + RasterOptions { px_per_mm: 4.0, background: Rgb(255, 255, 255), ..Default::default() }, + RasterOptions { hairline: 1.5, background: bg, ..Default::default() }, + ] { + let fast = render(&l, &view, &o).unwrap(); + let reference = render_one_by_one(&l, &view, &o); + assert!(fast.data() == reference.data(), "pixels differ with {o:?}"); + } +} + +/// A real hatch pattern (many parallel lines in one colour) renders exactly as stroked line by +/// line. +#[test] +fn raster_hatch_pattern_matches_one_by_one() { + let mut d = sample(); + d.add(&Space::Model, Common::default(), EntityKind::Hatch(pattern_hatch("ANSI31", 0.2, (0.0, 0.0, 10.0, 10.0)))).unwrap(); + let l = build(&d, &Space::Model, &Options::default()); + assert!(l.prims.len() > 20); + let view = View::fit(&l.bounds, 300, 200, 0.05); + for o in [RasterOptions::default(), RasterOptions { background: Rgb(255, 255, 255), ..Default::default() }] { + assert!(render(&l, &view, &o).unwrap().data() == render_one_by_one(&l, &view, &o).data()); + } +} diff --git a/crates/render/src/text_entity.rs b/crates/render/src/text_entity.rs new file mode 100644 index 000000000..54b556139 --- /dev/null +++ b/crates/render/src/text_entity.rs @@ -0,0 +1,95 @@ +use super::*; + +/// The text style of an entity (Standard defaults when missing). +pub(crate) fn text_style(d: &Drawing, name: &str) -> cadcraft_doc::TextStyle { + d.text_style(name).cloned().unwrap_or_default() +} + +/// Shape a TEXT / ATTRIB / ATTDEF with its style's font and generation flags. +pub fn place_text_entity(d: &Drawing, t: &cadcraft_doc::Text, value: &str) -> (cadcraft_fonts::Shaped, Bounds2) { + let ts = text_style(d, &t.style); + let font = cadcraft_fonts::TextFont::resolve(&ts.font); + let (h, v) = align(t.halign, t.valign); + let p = cadcraft_fonts::TextParams { + insert: t.insert.xy(), + align_pt: t.align_pt.map(|p| p.xy()), + height: t.height, + rotation: t.rotation, + width_factor: t.width_factor, + oblique: t.oblique, + h, + v, + backwards: ts.backwards, + upside_down: ts.upside_down, + }; + cadcraft_fonts::place(&font, value, &p) +} + +/// Lay out an MTEXT with its style's font. +pub fn layout_mtext_entity(d: &Drawing, t: &cadcraft_doc::MText) -> cadcraft_fonts::MTextLayout { + let ts = text_style(d, &t.style); + let params = cadcraft_fonts::MTextParams { + insert: t.insert.xy(), + height: t.height, + width: t.width, + attach: t.attach, + rotation: t.rotation, + line_spacing: t.line_spacing, + line_spacing_exact: t.line_spacing_exact, + font: cadcraft_fonts::TextFont::resolve(&ts.font), + width_factor: ts.width_factor, + oblique: ts.oblique, + columns: t.columns.as_ref().map(|c| cadcraft_fonts::MTextColumnSpec { + dynamic: c.kind == cadcraft_doc::MTextColumnType::Dynamic, + count: usize::from(c.count.max(1)), + gutter: c.gutter, + height: c.height, + heights: if c.kind == cadcraft_doc::MTextColumnType::Dynamic && !c.auto_height { c.heights.clone() } else { Vec::new() }, + reversed: c.flow_reversed, + }), + }; + cadcraft_fonts::layout_mtext_with(&t.contents, ¶ms) +} + +fn mtext_color(c: Option, rgb: Ink) -> Ink { + match c { + Some(cadcraft_fonts::MTextColor::Aci(i)) => { + u8::try_from(i).ok().filter(|i| *i > 0).map(|i| ink(Color::Index(i), Color::Index(7), Color::Index(7))).unwrap_or(rgb) + } + Some(cadcraft_fonts::MTextColor::Rgb(v)) => { + Ink { rgb: Rgb(((v >> 16) & 0xFF) as u8, ((v >> 8) & 0xFF) as u8, (v & 0xFF) as u8), aci7: false, aci: 0 } + } + None => rgb, + } +} + +pub(crate) fn mtext(b: &mut Builder, ctx: &Ctx, t: &cadcraft_doc::MText, rgb: Ink, lw: f32) { + let l = layout_mtext_entity(ctx.d, t); + for p in &l.pieces { + b.shaped(ctx, mtext_color(p.color, rgb), lw, &p.shaped); + } +} + +/// A TEXT's justification as the fonts' alignment (used where text is placed outside a build). +pub fn text_align(h: cadcraft_doc::HAlign, v: cadcraft_doc::VAlign) -> (cadcraft_fonts::Align, cadcraft_fonts::VAlign) { + align(h, v) +} + +fn align(h: cadcraft_doc::HAlign, v: cadcraft_doc::VAlign) -> (cadcraft_fonts::Align, cadcraft_fonts::VAlign) { + use cadcraft_fonts::{Align as A, VAlign as V}; + let ha = match h { + cadcraft_doc::HAlign::Left => A::Left, + cadcraft_doc::HAlign::Center => A::Center, + cadcraft_doc::HAlign::Right => A::Right, + cadcraft_doc::HAlign::Aligned => A::Aligned, + cadcraft_doc::HAlign::Middle => A::Middle, + cadcraft_doc::HAlign::Fit => A::Fit, + }; + let va = match v { + cadcraft_doc::VAlign::Baseline => V::Baseline, + cadcraft_doc::VAlign::Bottom => V::Bottom, + cadcraft_doc::VAlign::Middle => V::Middle, + cadcraft_doc::VAlign::Top => V::Top, + }; + (ha, va) +} diff --git a/crates/render/src/viewports.rs b/crates/render/src/viewports.rs new file mode 100644 index 000000000..e78106fa1 --- /dev/null +++ b/crates/render/src/viewports.rs @@ -0,0 +1,278 @@ +#[cfg(not(target_arch = "wasm32"))] +use super::display::append; +use super::display::{DPrim, DisplayList, Kind, PlotText, VIEWPORT_CONTENT}; +use super::draw::entity; +#[cfg(not(target_arch = "wasm32"))] +use super::pipeline::PARALLEL_CHUNK; +use super::pipeline::{Options, style_table, top_ctx, with_point_view}; +use super::*; + +/// Upper bound on viewports drawn per layout (hostile files). +pub(crate) const MAX_VIEWPORTS: usize = 256; + +/// A paper-space viewport: its border (as an ordinary entity) and model space seen through it. +/// Viewport id 1 is the paper-space view itself and is not drawn. A clipped viewport shows +/// model space through its clip boundary's outline (the boundary entity draws itself, so the +/// rectangle is not drawn); a viewport turned off shows only its border. +pub(crate) fn viewport(b: &mut Builder, d: &Drawing, e: &Entity, vp: &cadcraft_doc::Viewport) { + if !viewport_shown(d, e, vp) { + return; + } + let poly = viewport_clip(d, e.handle, vp); + // Border (on the viewport's layer; layer off hides only the border). + if poly.is_none() { + entity(b, &top_ctx(d, Mat3::IDENTITY, e.handle, &[], &[]), e); + } + if b.opts.skip_viewport == Some(e.handle) { + return; + } + let unit_mm = paper::unit_mm_of(d, e.handle); + match &poly { + Some(p) => contents(b, d, vp, p.bounds(), &Clip::Poly(p), unit_mm), + None => { + if let Some(rect) = viewport_rect(vp) { + contents(b, d, vp, rect, &Clip::Rect(rect), unit_mm); + } + } + } +} + +/// Whether a viewport draws anything: id 1 is the paper-space view itself, and a hidden viewport +/// or one on a frozen layer shows neither its border nor its contents. +fn viewport_shown(d: &Drawing, e: &Entity, vp: &cadcraft_doc::Viewport) -> bool { + vp.id != 1 && e.common.visible && !d.is_hidden_object(e.handle) && !d.layer(&e.common.layer).is_some_and(|l| l.frozen) +} + +/// A viewport's rectangle on paper; `None` for an unusable size or centre. +fn viewport_rect(vp: &cadcraft_doc::Viewport) -> Option { + let center = vp.center.xy(); + let ok = |v: f64| v.is_finite() && v > 0.0; + (ok(vp.width) && ok(vp.height) && center.is_finite()).then(|| { + let half = Vec2::new(vp.width / 2.0, vp.height / 2.0); + Bounds2::new(center - half, center + half) + }) +} + +/// Model space seen through the layout viewport `handle` of `space`, in paper units, clipped to +/// `area` (paper units) instead of to the viewport: what [`build`] draws inside that viewport, +/// over an area the caller chooses. The canvas builds the active viewport this way for an area +/// around it and draws it clipped to the viewport, so panning and zooming inside it moves the +/// geometry instead of rebuilding the layout (see [`Options::skip_viewport`]). Empty when the +/// viewport shows nothing. +pub fn viewport_content(d: &Drawing, space: &Space, handle: Handle, area: Bounds2, opts: &Options) -> DisplayList { + let Some(e) = d.space(space).and_then(|s| s.get(handle)) else { return DisplayList::default() }; + let EntityKind::Viewport(vp) = &e.kind else { return DisplayList::default() }; + if !viewport_shown(d, e, vp) || area.is_empty() || !(area.min.is_finite() && area.max.is_finite()) { + return DisplayList::default(); + } + let opts = &with_point_view(d, space, opts); + let styler = style_table(d, space, opts, false).map(|t| plotstyle::Styler::new(d, t)); + let mut b = + Builder { list: DisplayList::default(), opts, plotting: false, styles: styler.as_ref(), named: None, shared_budget: None, transparency: 0 }; + contents(&mut b, d, vp, area, &Clip::Rect(area), paper::unit_mm_of(d, handle)); + b.list +} + +/// Model space seen through `vp`, in paper units, appended to `b`'s list clipped to `clip`; +/// `area` (the clip's bounds on paper) picks the model objects to draw. +fn contents(b: &mut Builder, d: &Drawing, vp: &cadcraft_doc::Viewport, area: Bounds2, clip: &Clip, unit_mm: f64) { + if b.list.expanded >= MAX_BLOCK_EXPANSION || !vp.on { + return; + } + let center = vp.center.xy(); + let ok = |v: f64| v.is_finite() && v > 0.0; + if !(ok(vp.width) && ok(vp.height) && ok(vp.view_height) && center.is_finite() && vp.view_center.is_finite()) { + return; + } + let s = vp.height / vp.view_height; + if !ok(s) { + return; + } + let xf = Mat3::translate(center).then_before(Mat3::scale(s, s)).then_before(Mat3::translate(-vp.view_center)); + let mhalf = Vec2::new(area.width(), area.height()) / (2.0 * s); + let mcenter = vp.view_center + (area.center() - center) / s; + // Model window with slack: entity bounds are approximate (text, dimensions). + let win = Bounds2::new(mcenter - mhalf, mcenter + mhalf).expand(mhalf.x.max(mhalf.y) * 0.1); + // PSLTSCALE on: model-space dashes are scaled so they measure the same on paper as in + // paper space, whatever the viewport scale. + let lt_factor = if d.header.i64("PSLTSCALE", 1) != 0 { 1.0 / s } else { 1.0 }; + let ctx = || Ctx { lt_factor, ..top_ctx(d, xf, VIEWPORT_CONTENT, &vp.frozen_layers, &vp.layer_colors) }; + let seen = |me: &Entity| { + if matches!(me.kind, EntityKind::Viewport(_)) { + return false; + } + if matches!(me.kind, EntityKind::Ray(_) | EntityKind::XLine(_)) { + return true; + } + let eb = cadcraft_doc::entity_bounds(d, me, 0); + eb.is_empty() || eb.intersects(&win) + }; + // One expansion budget for the whole build: seed it with what the enclosing list has already + // spent so a viewport's contents cannot start over with a fresh allowance and exceed the + // documented limit (CWE-400). Both the serial sub-builder and the parallel chunks share it. + let budget = std::sync::atomic::AtomicUsize::new(b.list.expanded); + let mut sub = Builder { + list: DisplayList::default(), + opts: b.opts, + plotting: b.plotting, + styles: b.styles, + named: None, + shared_budget: Some(&budget), + transparency: 0, + }; + #[cfg(not(target_arch = "wasm32"))] + let parallel = !b.plotting && b.styles.is_none() && d.model.len() >= 2 * PARALLEL_CHUNK; + #[cfg(target_arch = "wasm32")] + let parallel = false; + if parallel { + #[cfg(not(target_arch = "wasm32"))] + { + // As `build_parallel`: chunks built side by side, joined in drawing order. + use rayon::prelude::*; + let ents: Vec<&std::sync::Arc> = d.model.iter().collect(); + let opts = b.opts; + let parts: Vec = ents + .par_chunks(PARALLEL_CHUNK) + .map(|chunk| { + let mut part = Builder { + list: DisplayList::default(), + opts, + plotting: false, + styles: None, + named: None, + shared_budget: Some(&budget), + transparency: 0, + }; + let c = ctx(); + for me in chunk.iter().filter(|me| seen(me)) { + entity(&mut part, &c, me); + } + part.list + }) + .collect(); + for part in parts { + append(&mut sub.list, part); + } + } + } else { + let c = ctx(); + for me in d.model.iter().filter(|me| seen(me)) { + entity(&mut sub, &c, me); + } + } + // Plotted text glyphs move to the layout's list; their outlines carry the glyph's index there + // in their handle's high half until clipped (see `take_viewport_layers`). + for t in std::mem::take(&mut sub.list.texts) { + let k = b.list.texts.len() as u64 + 1; + if let Some(p) = sub.list.prims.get_mut(t.prim as usize) + && k < u64::from(u32::MAX) + { + p.handle = Handle(p.handle.0 | (k << 32)); + b.list.texts.push(PlotText { prim: PlotText::NONE, ..t }); + } + } + // The contents are in paper units now: draw their pens before clipping. + pen::apply(&mut sub.list, unit_mm); + b.list.expanded = b.list.expanded.saturating_add(sub.list.expanded); + append_clipped(&mut b.list, &sub.list, clip); +} + +/// The clip region of a clipped viewport: the outline of its clip boundary entity, when that +/// is a closed shape in the drawing. `None` for a rectangular viewport (or a dangling, +/// unusable or self-referencing boundary, which falls back to the rectangle). +pub fn viewport_clip(d: &Drawing, handle: Handle, vp: &cadcraft_doc::Viewport) -> Option { + let h = vp.clip.filter(|h| *h != handle)?; + let be = d.entity(h)?; + clip::PolyClip::new(&clip::boundary_polygon(&be.kind)?) +} + +/// What viewport contents are clipped to. +enum Clip<'a> { + /// The viewport rectangle (the fast path). + Rect(Bounds2), + /// A clip boundary's outline. + Poly(&'a clip::PolyClip), +} + +/// Append `src` to `dst`, clipped to `clip`. +fn append_clipped(dst: &mut DisplayList, src: &DisplayList, clip: &Clip) { + for p in &src.prims { + let pts = src.points(p); + match (p.kind, clip) { + (Kind::Polyline, Clip::Rect(rect)) => { + for piece in clip::clip_polyline(pts, rect) { + push_raw(dst, p, Kind::Polyline, &piece); + } + } + (Kind::Polyline, Clip::Poly(c)) => { + for piece in c.clip_polyline(pts) { + push_raw(dst, p, Kind::Polyline, &piece); + } + } + (Kind::Tris | Kind::Mask, Clip::Rect(rect)) => { + let t = clip::clip_triangles(pts, rect); + push_raw(dst, p, p.kind, &t); + } + (Kind::Tris | Kind::Mask, Clip::Poly(c)) => { + let t = c.clip_triangles(pts); + push_raw(dst, p, p.kind, &t); + } + (Kind::Point, _) => { + let inside = |q: Vec2| match clip { + Clip::Rect(rect) => rect.contains(q), + Clip::Poly(c) => c.contains(q), + }; + if let Some(q) = pts.first() + && inside(*q) + { + push_raw(dst, p, Kind::Point, &[*q]); + } + } + (Kind::Infinite { ray }, Clip::Rect(rect)) => { + if let (Some(base), Some(dir)) = (pts.first(), pts.get(1)) + && let Some((a, c)) = clip::clip_infinite(*base, *dir, ray, rect) + { + push_raw(dst, p, Kind::Polyline, &[a, c]); + } + } + (Kind::Infinite { ray }, Clip::Poly(c)) => { + if let (Some(base), Some(dir)) = (pts.first(), pts.get(1)) { + for piece in c.clip_infinite(*base, *dir, ray) { + push_raw(dst, p, Kind::Polyline, &piece); + } + } + } + } + } +} + +fn push_raw(dst: &mut DisplayList, p: &DPrim, kind: Kind, pts: &[Vec2]) { + let min = match kind { + Kind::Polyline => 2, + Kind::Tris | Kind::Mask => 3, + _ => 1, + }; + if pts.len() < min { + return; + } + let tris = matches!(kind, Kind::Tris | Kind::Mask); + let start = if tris { dst.tris.len() } else { dst.verts.len() } as u32; + for q in pts { + dst.bounds.add(*q); + } + if tris { + dst.tris.extend_from_slice(pts); + } else { + dst.verts.extend_from_slice(pts); + } + dst.prims.push(DPrim { + handle: p.handle, + color: p.color, + aci7: p.aci7, + lw: p.lw, + transparency: p.transparency, + kind, + start, + len: pts.len() as u32, + }); +} diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index aee4c47f7..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 } @@ -31,6 +32,8 @@ web-time = { workspace = true } 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"] } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index a2812367a..787aa1a2f 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -12,6 +12,34 @@ use egui::{Color32, Pos2, Rect, Sense, Shape, Stroke, pos2, vec2}; use crate::CadApp; use crate::theme::Tokens; +mod cursor; +mod cycle; +mod draw; +mod dyn_boxes; +mod grid; +mod grips; +mod list; +mod pick; +mod viewport; +mod widgets; + +use cursor::{draw_crosshair, draw_snap_marker, draw_tracking, effective_point, override_cursor}; +use cycle::{CycleList, cycle_badge, cycle_click, cycle_list, over_canvas_menu, shift_space_cycle}; +pub(crate) use draw::{Look, draw_highlight, draw_list}; +use draw::{draw_drag_gpu, draw_highlight_gpu, draw_list_gpu, list_mesh_key, selection_area_fill}; +use dyn_boxes::dynamic_input; +pub(crate) use dyn_boxes::length_units; +use grid::draw_grid; +use grips::{click_grip, grip_at, grip_menu, grip_options, grip_rect}; +use list::{PrimIndex, ensure_list, rebuilt_preview}; +#[cfg(test)] +use list::{preview_options, prims_of}; +use pick::{edit_text_at, hatch_at, wheel_zoom_points}; +#[cfg(test)] +use viewport::viewport_highlight_tolerance; +use viewport::{VpContent, VpHighlight, draw_vp_content, ensure_vp_content}; +use widgets::{draw_tiles, draw_ucs_icon, draw_viewcube, tile_rects, tile_xf, viewport_label}; + /// World ↔ screen transform for the canvas rect. #[derive(Clone, Copy, Debug)] pub struct Xf { @@ -189,1172 +217,6 @@ pub struct SnapCycle { index: usize, } -/// The objects under a click that selection cycling lets the user choose between: where the click -/// was, each object with its row's label (type and layer), Shift held, and the rows as drawn. -#[derive(Clone, Debug)] -struct CycleList { - at: Pos2, - items: Vec<(Handle, String, String)>, - shift: bool, - rows: Vec, -} - -impl CycleList { - fn row_at(&self, p: Pos2) -> Option { - self.rows.iter().zip(&self.items).find(|(r, _)| r.contains(p)).map(|(_, (h, _, _))| *h) - } -} - -/// Primitives of `list` sorted by entity handle, built on the first highlight after each list -/// rebuild. Highlighting the hovered entity used to scan every primitive on every frame: ~2.5 ms -/// per frame on a 270k-entity drawing, whether or not anything changed. -#[derive(Default)] -struct PrimIndex { - list_gen: Option, - by_handle: Vec<(Handle, usize)>, -} - -impl PrimIndex { - /// Indices of the primitives drawn for `handles` (in list order). - fn prims(&mut self, list_gen: u64, list: &DisplayList, handles: &[Handle]) -> Vec { - if handles.is_empty() { - return Vec::new(); - } - if self.list_gen != Some(list_gen) { - self.by_handle = list.prims.iter().enumerate().map(|(i, p)| (p.handle, i)).collect(); - self.by_handle.sort_unstable(); - self.list_gen = Some(list_gen); - } - let mut out = Vec::new(); - for h in handles { - let from = self.by_handle.partition_point(|(k, _)| k < h); - out.extend(self.by_handle.get(from..).unwrap_or(&[]).iter().take_while(|(k, _)| k == h).map(|(_, i)| *i)); - } - out.sort_unstable(); - out.dedup(); - out - } -} - -/// Indices of the primitives in `list` drawn for `handles` (a scan: the reference [`PrimIndex`] -/// is tested against). -#[cfg(test)] -fn prims_of(list: &DisplayList, handles: &[Handle]) -> Vec { - if handles.is_empty() { - return Vec::new(); - } - let set: std::collections::HashSet = handles.iter().copied().collect(); - list.prims.iter().enumerate().filter(|(_, pr)| set.contains(&pr.handle)).map(|(i, _)| i).collect() -} - -/// Build options for previews at `scale` pixels per drawing unit (opaque). -fn preview_options(scale: f64) -> cadcraft_render::Options { - cadcraft_render::Options { tolerance: 0.5 / scale, min_dash: 2.0 / scale, ..Default::default() } -} - -/// The display list of preview entities the session builds with the current properties -/// ([`cadcraft_engine::Session::preview`], the extras of [`cadcraft_engine::Session::drag`]): the -/// command's rubber band and rebuilt copies of existing objects. They carry CLAYER's properties, -/// not the objects', so they are drawn opaque (the GPU drag of the objects themselves keeps their -/// transparency, see [`draw_drag_gpu`]). -fn rebuilt_preview<'a>(doc: &cadcraft_doc::Drawing, ents: impl IntoIterator, scale: f64) -> DisplayList { - cadcraft_render::build_entities(doc, ents, &preview_options(scale)) -} - -fn color32(c: Rgb) -> Color32 { - Color32::from_rgb(c.0, c.1, c.2) -} - -/// A primitive's colour on `bg`, with its transparency. -fn prim_color32(prim: &cadcraft_render::DPrim, bg: Rgb) -> Color32 { - let c = prim.display_rgb(bg); - Color32::from_rgba_unmultiplied(c.0, c.1, c.2, prim.alpha()) -} - -/// Rebuild the display list when needed. `xf`: the current view; `views`: every area on screen -/// that shows the list (the current view, other model tiles) with its drawing units per pixel. -/// -/// A rebuild only the view asks for (a new zoom band, a pan out of the built area) runs in the -/// background while the current list is drawn (see [`rebuild`]); a changed drawing, or no list -/// yet, is built at once. -fn ensure_list(app: &mut CadApp, xf: &Xf, views: &[(Bounds2, f64)], ctx: &egui::Context) { - let px = 1.0 / xf.scale; - let Ok(st) = app.session.state() else { return }; - // Rebuild when the drawing changes, the zoom moves by more than 2x (tessellation band) or a - // view leaves the area the list was built for. - let band = |px: f64| px.max(1e-300).log2().floor() as i32; - // Hatch level of detail follows the finest view showing the list. - let lod_band = views.iter().map(|v| band(v.1)).fold(band(px), i32::min); - // The active MSPACE viewport's contents are drawn apart (see `VpContent`), so panning and - // zooming inside it leave this list alone. Without a stencil buffer a clipped viewport's - // contents stay in the list, which then follows its view: realtime navigation changes it - // without a new revision. - let split = split_viewport(st, stencil_clip(app)).map(|(h, ..)| h); - let vp_view = { - use std::hash::{Hash, Hasher}; - let mut s = std::collections::hash_map::DefaultHasher::new(); - split.hash(&mut s); - if split.is_none() - && let Some((h, vp)) = st.active_viewport() - { - (h, vp.view_center.x.to_bits(), vp.view_center.y.to_bits(), vp.view_height.to_bits()).hash(&mut s); - } - s.finish() - }; - let (lwdisplay, transparency_display) = (app.session.settings.lwdisplay, app.session.settings.transparency_display); - let key: ListKey = (st.uid, st.revision, band(px), lod_band, lwdisplay, transparency_display, std::sync::Arc::as_ptr(&st.doc) as usize, vp_view); - // A background build that finished: use it unless the drawing has changed since it started - // (or the list was dropped to force a rebuild). - if let Some(job) = &app.canvas.job { - match job.poll() { - rebuild::Poll::Pending => {} - rebuild::Poll::Done(built) => { - let (job_key, window) = (job.key, job.window); - app.canvas.job = None; - if app.canvas.list.is_some() && same_content(&job_key, &key) { - app.canvas.install(job_key, window, *built); - } - } - rebuild::Poll::Lost => { - log::warn!("canvas: a background rebuild failed; rebuilding on the UI thread"); - app.canvas.job = None; - app.canvas.list = None; - } - } - } - let visible = views.iter().fold(Bounds2::EMPTY, |acc, v| acc.union(&v.0)); - let inside = app.canvas.window.is_none_or(|w| visible.is_empty() || w.contains_box(&visible)); - if app.canvas.list.is_some() && app.canvas.key == key && inside { - return; - } - // Only the view moved: keep drawing the current list and build the new one in the background. - let view_only = app.canvas.list.is_some() && same_content(&app.canvas.key, &key); - if view_only && app.canvas.job.as_ref().is_some_and(|j| j.serves(&key, &visible)) { - return; - } - // A build for another view, or for a drawing that has since changed, is no longer wanted: - // dropping it cancels it. - app.canvas.job = None; - let t0 = crate::now_ms(); - let opts = cadcraft_render::Options { - tolerance: 2f64.powi(band(px)) * 0.5, - // Two pixels at the band's finest zoom: the list serves every zoom in the band, so its - // level of detail must hold at the finest one. - min_dash: 2f64.powi(lod_band) * 2.0, - text: true, - fill: true, - lineweights: lwdisplay, - view_height: st.paper_view().height, - plot_style_table: None, - view: build_window(views), - transparency: app.session.settings.transparency_display, - skip_viewport: split, - cancel: None, - }; - if view_only { - let t = Tokens::get(); - let mesh = app.canvas.gpu.is_some().then(|| rebuild::MeshRequest { - view_center: xf.center, - view_height: f64::from(xf.rect.height()) / xf.scale.max(1e-300), - model_bg: Rgb(t.canvas.r(), t.canvas.g(), t.canvas.b()), - paper_bg: Rgb(t.paper.r(), t.paper.g(), t.paper.b()), - lwdisplay, - device: app.canvas.gpu_device.clone(), - }); - let window = opts.view; - let req = rebuild::Request { doc: st.doc.clone(), space: st.space.clone(), opts: opts.clone(), mesh }; - if let Some(job) = rebuild::spawn(req, key, window, ctx) { - app.canvas.job = Some(job); - return; - } - } - let space = st.space.clone(); - app.canvas.list = Some(cadcraft_render::build(&st.doc, &space, &opts)); - app.canvas.key = key; - app.canvas.window = opts.view; - app.canvas.list_gen = app.canvas.list_gen.wrapping_add(1); - app.canvas.build_ms = crate::now_ms() - t0; -} - -/// The active MSPACE viewport when its contents are drawn apart from the layout's list (see -/// [`VpContent`]), with its clip outline when it is clipped. A clipped viewport is clipped with -/// the GPU's stencil buffer (`stencil`); without one its contents stay in the list. -fn split_viewport( - st: &cadcraft_engine::DocState, - stencil: bool, -) -> Option<(Handle, cadcraft_doc::Viewport, Option)> { - let (h, vp) = st.active_viewport()?; - let clip = cadcraft_render::viewport_clip(&st.doc, h, &vp); - (clip.is_none() || stencil).then_some((h, vp, clip)) -} - -/// Whether the canvas can clip a viewport to any outline (a GPU with a stencil buffer). -fn stencil_clip(app: &CadApp) -> bool { - app.canvas.gpu.is_some_and(|g| g.stencil.is_some()) -} - -/// How long the view inside the active viewport stays still before a zoomed [`VpContent`] is -/// rebuilt at the exact view (dash lengths and curve detail follow the viewport's scale). -const VP_SETTLE_MS: f64 = 150.0; - -/// Model space seen through the active MSPACE viewport, drawn apart from the layout's list. It is -/// built for an area around the viewport at one view and drawn clipped to the viewport, moved -/// and scaled to the current view, so panning and zooming inside the viewport redraw without -/// rebuilding anything. It is rebuilt when the drawing changes, when the zoom has moved 2x from -/// the view it was built for, when the view leaves the area, and once a zoom comes to rest. -#[derive(Default)] -struct VpContent { - list: DisplayList, - /// What the list was built from: drawing, revision, document, viewport, tessellation band - /// and lineweight display. - key: Option<(u64, u64, usize, Handle, i32, bool, bool)>, - /// The viewport's view (centre, height) the list was built for, and the paper area it covers. - view: (Vec2, f64), - area: Bounds2, - /// The view on the previous frame and when it last changed (ms). - last_view: (Vec2, f64), - moved_at: f64, - /// Scale and offset drawing the list at the viewport's current view (see [`vp_motion`]). - motion: Option<(f64, Vec2)>, - /// Bumped on each rebuild. - builds: u64, - mesh_key: Option, - mesh_slot: crate::gpu::MeshSlot, - mesh_origin: Vec2, - /// A clipped viewport's outline on the GPU (see [`draw_vp_mask`]). - mask_key: Option, - mask_slot: crate::gpu::MeshSlot, - mask_origin: Vec2, -} - -impl VpContent { - /// Where the contents are on paper at the viewport's current view. - fn paper_bounds(&self) -> Option { - let (k, b) = self.motion?; - (!self.list.bounds.is_empty()).then(|| Bounds2::new(self.list.bounds.min * k + b, self.list.bounds.max * k + b)) - } -} - -/// How a list built for view `built` (centre, height) of viewport `vp` maps to its current -/// view, as `p = k·p0 + b` on paper. A model point m is at p0 = c + s0 (m − c0) in the list and -/// belongs at p = c + s (m − vc), with c the viewport's centre and s, s0 paper units per model -/// unit, so k = s / s0 and b = c (1 − k) + s (c0 − vc). -fn vp_motion(vp: &cadcraft_doc::Viewport, built: (Vec2, f64)) -> Option<(f64, Vec2)> { - let (s, k) = (vp.height / vp.view_height, built.1 / vp.view_height); - let b = vp.center.xy() * (1.0 - k) + (built.0 - vp.view_center) * s; - (k.is_finite() && k > 0.0 && b.is_finite()).then_some((k, b)) -} - -/// How to draw the active viewport's [`VpContent`] this frame. -struct VpFrame { - /// Draws the list at the viewport's current view. - xf: Xf, - /// The viewport's rectangle (a clipped one's outline bounds) on screen. - on_screen: Rect, - /// A clipped viewport's outline on paper, masked with the stencil buffer. - outline: Option>, -} - -/// Build or reuse the active viewport's [`VpContent`] for this frame. -fn ensure_vp_content(app: &mut CadApp, xf_paper: &Xf, ctx: &egui::Context) -> Option { - let (lwdisplay, transparency) = (app.session.settings.lwdisplay, app.session.settings.transparency_display); - let stencil = stencil_clip(app); - let c = &mut app.canvas.vp; - let Some((st, (h, vp, clip))) = app.session.state().ok().and_then(|st| Some((st, split_viewport(st, stencil)?))) else { - // Not working in a viewport drawn apart: let the geometry go. - if c.key.is_some() { - *c = VpContent::default(); - } - return None; - }; - let half = Vec2::new(vp.width / 2.0, vp.height / 2.0); - let rect = match &clip { - Some(p) => p.bounds(), - None => Bounds2::new(vp.center.xy() - half, vp.center.xy() + half), - }; - let px = 1.0 / xf_paper.scale.max(1e-300); - let band = px.max(1e-300).log2().floor() as i32; - let key = (st.uid, st.revision, std::sync::Arc::as_ptr(&st.doc) as usize, h, band, lwdisplay, transparency); - let now = crate::now_ms(); - let view = (vp.view_center, vp.view_height); - if view != c.last_view { - c.last_view = view; - c.moved_at = now; - } - let rebuild = match vp_motion(&vp, c.view).filter(|_| c.key == Some(key)) { - Some((k, b)) => { - // The viewport in the list's own coordinates. - let visible = Bounds2::new((rect.min - b) / k, (rect.max - b) / k); - let zoomed = (k - 1.0).abs() > 1e-9; - let still = now - c.moved_at; - if zoomed && still < VP_SETTLE_MS { - ctx.request_repaint_after(std::time::Duration::from_millis((VP_SETTLE_MS - still).max(1.0) as u64)); - } - !(0.5..2.0).contains(&k) || !c.area.contains_box(&visible) || (zoomed && still >= VP_SETTLE_MS) - } - None => true, - }; - if rebuild { - let t0 = crate::now_ms(); - // The viewport with half its larger side of margin all round: panning by up to that much - // only moves the geometry. - let area = rect.expand(rect.width().max(rect.height()) * 0.5); - let opts = cadcraft_render::Options { - tolerance: 2f64.powi(band) * 0.5, - min_dash: 2f64.powi(band) * 2.0, - text: true, - fill: true, - lineweights: lwdisplay, - view_height: st.paper_view().height, - plot_style_table: None, - view: Some(area), - skip_viewport: None, - transparency, - cancel: None, - }; - c.list = cadcraft_render::viewport_content(&st.doc, &st.space, h, area, &opts); - c.key = Some(key); - c.view = view; - c.area = area; - c.builds = c.builds.wrapping_add(1); - app.canvas.build_ms = crate::now_ms() - t0; - } - let c = &mut app.canvas.vp; - let (k, b) = vp_motion(&vp, c.view)?; - c.motion = Some((k, b)); - // Paper p = k·p0 + b, so the list is drawn by the paper transform scaled by k about b. - let xf = Xf { rect: xf_paper.rect, center: (xf_paper.center - b) / k, scale: xf_paper.scale * k }; - let on_screen = Rect::from_two_pos(xf_paper.to_screen(Vec2::new(rect.min.x, rect.max.y)), xf_paper.to_screen(Vec2::new(rect.max.x, rect.min.y))); - Some(VpFrame { xf, on_screen, outline: clip.map(|p| p.polygon().to_vec()) }) -} - -/// Draw the active viewport's contents (see [`VpContent`]) as [`ensure_vp_content`] set out; -/// `p` clips them to the viewport's rectangle, and a clipped viewport's outline is masked with -/// the stencil buffer first. -fn draw_vp_content(c: &mut VpContent, gpu: bool, p: &egui::Painter, f: &VpFrame, xf_paper: &Xf, bg: Rgb, lwdisplay: bool) { - let xf = &f.xf; - if !gpu { - draw_list(p, xf, &c.list, bg, lwdisplay); - return; - } - let mut stencil = crate::gpu::Stencil::Off; - if let Some(outline) = &f.outline { - draw_vp_mask(c, p, outline, xf_paper); - stencil = crate::gpu::Stencil::Inside; - } - let origin = crate::gpu::choose_origin(&c.list.bounds, xf.center, f64::from(xf.rect.height()) / xf.scale.max(1e-300)); - let key = { - use std::hash::{Hash, Hasher}; - let mut h = std::collections::hash_map::DefaultHasher::new(); - (c.builds, origin.x.to_bits(), origin.y.to_bits(), bg.0, bg.1, bg.2, lwdisplay).hash(&mut h); - h.finish() - }; - if c.mesh_key != Some(key) { - let mesh = crate::gpu::build_mesh(&c.list, origin, bg, lwdisplay); - if let Ok(mut slot) = c.mesh_slot.lock() { - *slot = Some(mesh); - } - c.mesh_key = Some(key); - c.mesh_origin = origin; - } - let center = xf.rect.center(); - p.add(egui_wgpu::Callback::new_paint_callback( - xf.rect, - crate::gpu::CanvasCallback { - key, - slot: c.mesh_slot.clone(), - offset: [(c.mesh_origin.x - xf.center.x) as f32, (c.mesh_origin.y - xf.center.y) as f32], - scale: xf.scale as f32, - center: [center.x, center.y], - layer: crate::gpu::LAYER_VIEWPORT, - xform: crate::gpu::IDENTITY_XFORM, - dash: 0.0, - stencil, - }, - )); -} - -/// Mask a clipped viewport's `outline` (paper units) in the stencil buffer: a triangle fan from -/// its first point, each triangle flipping the stencil bit, leaves the bit set inside the outline -/// (even-odd, concave or not), with no triangulation. -fn draw_vp_mask(c: &mut VpContent, p: &egui::Painter, outline: &[Vec2], xf_paper: &Xf) { - let key = { - use std::hash::{Hash, Hasher}; - let mut h = std::collections::hash_map::DefaultHasher::new(); - outline.iter().for_each(|q| (q.x.to_bits(), q.y.to_bits()).hash(&mut h)); - h.finish() - }; - if c.mask_key != Some(key) { - let mut fan = DisplayList::default(); - if let Some((&first, rest)) = outline.split_first() { - for w in rest.windows(2) { - if let [a, b] = w { - fan.tris.extend_from_slice(&[first, *a, *b]); - } - } - } - fan.bounds = Bounds2::from_points(outline.iter().copied()); - let len = u32::try_from(fan.tris.len()).unwrap_or(0); - fan.prims.push(cadcraft_render::DPrim { - handle: Handle(0), - color: Rgb(0, 0, 0), - aci7: false, - lw: 0.0, - transparency: 0, - kind: Kind::Tris, - start: 0, - len, - }); - let origin = if fan.bounds.is_empty() { Vec2::ZERO } else { fan.bounds.center() }; - if let Ok(mut slot) = c.mask_slot.lock() { - *slot = Some(crate::gpu::build_mesh(&fan, origin, Rgb(0, 0, 0), false)); - } - c.mask_key = Some(key); - c.mask_origin = origin; - } - let center = xf_paper.rect.center(); - p.add(egui_wgpu::Callback::new_paint_callback( - xf_paper.rect, - crate::gpu::CanvasCallback { - key, - slot: c.mask_slot.clone(), - offset: [(c.mask_origin.x - xf_paper.center.x) as f32, (c.mask_origin.y - xf_paper.center.y) as f32], - scale: xf_paper.scale as f32, - center: [center.x, center.y], - layer: crate::gpu::LAYER_MASK, - xform: crate::gpu::IDENTITY_XFORM, - dash: 0.0, - stencil: crate::gpu::Stencil::Mask, - }, - )); -} - -/// Tessellation tolerance for the highlights inside the active viewport: `scale` (pixels per -/// drawing unit) snapped to its 2x zoom band, as in [`ensure_list`], so zooming and panning -/// inside the band doesn't rebuild the geometry. -fn viewport_highlight_tolerance(scale: f64) -> f64 { - 2f64.powi((1.0 / scale.max(1e-300)).log2().floor() as i32) * 0.5 -} - -/// The selected or the hovered objects tessellated in model units for the active MSPACE -/// viewport, whose contents can't be looked up by handle in the layout's lists. The selection and -/// the hover are kept apart, so moving the cursor over objects doesn't tessellate the selection -/// again; each is rebuilt only when the drawing, the objects or the zoom band change. -#[derive(Default)] -struct VpHighlight { - key: Option, - list: DisplayList, - /// Bumped on each rebuild (identifies `list` for [`draw_highlight_gpu`]). - list_gen: u64, -} - -impl VpHighlight { - fn update(&mut self, st: &cadcraft_engine::DocState, handles: &[Handle], scale: f64) { - let tolerance = viewport_highlight_tolerance(scale); - let key = { - use std::hash::{Hash, Hasher}; - let mut h = std::collections::hash_map::DefaultHasher::new(); - (st.uid, st.revision, std::sync::Arc::as_ptr(&st.doc) as usize, tolerance.to_bits(), handles).hash(&mut h); - h.finish() - }; - if self.key != Some(key) { - let ents = handles.iter().filter_map(|h| st.doc.model.get(*h)).map(|e| e.as_ref()); - self.list = cadcraft_render::build_entities(&st.doc, ents, &cadcraft_render::Options { tolerance, ..Default::default() }); - self.key = Some(key); - self.list_gen = self.list_gen.wrapping_add(1); - } - } -} - -/// The area a display list is built for: each view with half its larger side of margin all -/// round (panning by up to that much doesn't rebuild), joined. `None` (everything) when a view -/// isn't usable. -fn build_window(views: &[(Bounds2, f64)]) -> Option { - let mut w = Bounds2::EMPTY; - for (v, _) in views { - let side = v.width().max(v.height()); - if v.is_empty() || !side.is_finite() { - return None; - } - w = w.union(&v.expand(side * 0.5)); - } - (!w.is_empty()).then_some(w) -} - -/// Grid spacing adapted to the zoom: multiply the unit until lines are at least `min_px` apart. -fn adaptive(unit: f64, scale: f64, min_px: f64) -> f64 { - let mut u = unit.max(1e-12); - let mut guard = 0; - while u * scale < min_px && guard < 40 { - u *= 5.0; - guard += 1; - } - 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(); - let wb = xf.world_bounds(); - if s.gridmode && s.isodraft { - draw_iso_grid(app, p, xf); - } else if s.gridmode { - let major_n = f64::from(s.gridmajor.max(1)); - let unit = if s.gridunit.x == 0.0 { s.snapunit.x } else { s.gridunit.x }; - let minor = adaptive(unit, xf.scale, 8.0); - let major = minor * major_n; - let mut lines = Vec::new(); - 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; - let sx = xf.to_screen(Vec2::new(x, 0.0)).x; - lines.push(Shape::line_segment( - [pos2(sx, xf.rect.top()), pos2(sx, xf.rect.bottom())], - Stroke::new(1.0, if is_major { t.grid_major } else { t.grid_minor }), - )); - } - for j in y0..=y1 { - let y = j as f64 * minor; - let is_major = ((y / major) - (y / major).round()).abs() < 1e-6; - let sy = xf.to_screen(Vec2::new(0.0, y)).y; - lines.push(Shape::line_segment( - [pos2(xf.rect.left(), sy), pos2(xf.rect.right(), sy)], - Stroke::new(1.0, if is_major { t.grid_major } else { t.grid_minor }), - )); - } - } - p.extend(lines); - } - // Axes through the origin. - let o = xf.to_screen(Vec2::ZERO); - if xf.rect.y_range().contains(o.y) { - p.line_segment([pos2(xf.rect.left(), o.y), pos2(xf.rect.right(), o.y)], Stroke::new(1.0, t.axis_x)); - } - if xf.rect.x_range().contains(o.x) { - p.line_segment([pos2(o.x, xf.rect.top()), pos2(o.x, xf.rect.bottom())], Stroke::new(1.0, t.axis_y)); - } -} - -/// The isometric grid (SNAPSTYL 1): lines at 30°, 90° and 150° through the snap lattice, GRIDUNIT -/// Y apart along the axes, coarsened while they would crowd closer than 8 pixels. -fn draw_iso_grid(app: &CadApp, p: &egui::Painter, xf: &Xf) { - use cadcraft_engine::snap::iso; - let t = Tokens::get(); - let s = app.session.drafting_settings(); - let mut d = s.gridunit.y.max(1e-12); - for _ in 0..40 { - if d * 0.866 * xf.scale >= 8.0 { - break; - } - d *= 5.0; - } - let (origin, angle) = app.session.doc().map(cadcraft_engine::snap::grid_frame).unwrap_or((Vec2::ZERO, 0.0)); - let lines = iso::grid_lines(xf.world_bounds(), d, origin, angle, s.gridmajor); - p.extend( - lines.into_iter().map(|l| { - Shape::line_segment([xf.to_screen(l.a), xf.to_screen(l.b)], Stroke::new(1.0, if l.major { t.grid_major } else { t.grid_minor })) - }), - ); -} - -fn clip_infinite(xf: &Xf, base: Vec2, dir: Vec2, ray: bool) -> Option<[Pos2; 2]> { - let wb = xf.world_bounds(); - let far = (wb.width() + wb.height()) * 2.0 + base.dist(wb.center()); - let a = if ray { base } else { base - dir * far }; - let b = base + dir * far; - Some([xf.to_screen(a), xf.to_screen(b)]) -} - -/// Draw a display list. -pub(crate) fn draw_list(p: &egui::Painter, xf: &Xf, list: &DisplayList, bg: Rgb, lwdisplay: bool) { - let vis = xf.world_bounds(); - let mut shapes: Vec = Vec::with_capacity(list.prims.len()); - let px = 1.0 / xf.scale; - for prim in &list.prims { - let col = prim_color32(prim, bg); - let pts = list.points(prim); - match prim.kind { - Kind::Polyline => { - let bb = Bounds2::from_points(pts.iter().copied()); - if !bb.intersects(&vis) { - continue; - } - let w = if lwdisplay && prim.lw > 0.0 { (prim.lw * 3.78).clamp(1.0, 12.0) } else { 1.0 }; - // Skip sub-pixel objects except as a dot. - if bb.width() < px && bb.height() < px { - shapes.push(Shape::rect_filled(Rect::from_center_size(xf.to_screen(bb.center()), vec2(1.0, 1.0)), 0.0, col)); - continue; - } - let screen: Vec = pts.iter().map(|q| xf.to_screen(*q)).collect(); - if screen.len() == 2 { - if let [a, b] = screen.as_slice() { - shapes.push(Shape::line_segment([*a, *b], Stroke::new(w, col))); - } - } else { - shapes.push(Shape::line(screen, Stroke::new(w, col))); - } - } - Kind::Tris | Kind::Mask => { - // A wipeout's mask hides what was drawn before it with the background colour. - let col = if prim.kind == Kind::Mask { color32(bg) } else { col }; - let mut mesh = egui::Mesh::default(); - for tri in pts.chunks(3) { - if let [a, b, c] = tri { - let i = mesh.vertices.len() as u32; - for q in [a, b, c] { - mesh.colored_vertex(xf.to_screen(*q), col); - } - mesh.add_triangle(i, i + 1, i + 2); - } - } - if !mesh.vertices.is_empty() { - shapes.push(Shape::mesh(mesh)); - } - } - Kind::Point => { - if let Some(q) = pts.first() - && vis.contains(*q) - { - shapes.push(Shape::rect_filled(Rect::from_center_size(xf.to_screen(*q), vec2(1.5, 1.5)), 0.0, col)); - } - } - Kind::Infinite { ray } => { - if let (Some(b), Some(d)) = (pts.first(), pts.get(1)) - && let Some(seg) = clip_infinite(xf, *b, *d, ray) - { - shapes.push(Shape::line_segment(seg, Stroke::new(1.0, col))); - } - } - } - } - p.extend(shapes); -} - -/// Draw the selection highlight (as `look` says) through the GPU: its vertex data is -/// rebuilt only when the display list, the selection or the vertex origin changes. Dashing every -/// selected line and tessellating it as egui shapes each frame made large selections sluggish. -/// `prims`: the selection's primitives in `list`, asked for only when the vertex data is rebuilt. -/// `stencil`: [`crate::gpu::Stencil::Inside`] keeps it inside a clipped viewport's outline. -#[allow(clippy::too_many_arguments)] -fn draw_highlight_gpu( - p: &egui::Painter, - xf: &Xf, - list: &DisplayList, - list_gen: u64, - prims: impl FnOnce() -> Vec, - sel: &[Handle], - color: Color32, - look: Look, - hl: (&mut Option, &crate::gpu::MeshSlot, &mut Vec2, &mut Vec), - stencil: crate::gpu::Stencil, -) { - let (hl_key, hl_slot, hl_origin, hl_infinite) = hl; - let origin = crate::gpu::choose_origin(&list.bounds, xf.center, f64::from(xf.rect.height()) / xf.scale.max(1e-300)); - let key = { - use std::hash::{Hash, Hasher}; - let mut h = std::collections::hash_map::DefaultHasher::new(); - (list_gen, sel, origin.x.to_bits(), origin.y.to_bits(), color.to_array(), look.width.to_bits(), look.dashed).hash(&mut h); - h.finish() - }; - if *hl_key != Some(key) { - let prims = prims(); - let mesh = crate::gpu::build_highlight_mesh(list, &prims, origin, color.to_array(), look.width, look.dashed); - *hl_infinite = prims.into_iter().filter(|&i| list.prims.get(i).is_some_and(|p| matches!(p.kind, Kind::Infinite { .. }))).collect(); - if let Ok(mut slot) = hl_slot.lock() { - *slot = Some(mesh); - } - *hl_key = Some(key); - *hl_origin = origin; - } - let center = xf.rect.center(); - p.add(egui_wgpu::Callback::new_paint_callback( - xf.rect, - crate::gpu::CanvasCallback { - key, - slot: hl_slot.clone(), - offset: [(hl_origin.x - xf.center.x) as f32, (hl_origin.y - xf.center.y) as f32], - scale: xf.scale as f32, - center: [center.x, center.y], - layer: crate::gpu::LAYER_HIGHLIGHT, - xform: crate::gpu::IDENTITY_XFORM, - dash: if look.dashed { 10.0 } else { 0.0 }, - stencil, - }, - )); -} - -/// Draw a MOVE/COPY/ROTATE/SCALE preview the way the drawing itself is drawn: the dragged -/// objects are tessellated and uploaded once (again only when the drawing, the selection or the -/// zoom band changes), and each frame only the transform in the view uniform moves. Rebuilding -/// the transformed copies as egui shapes every frame was slow with heavy selections and could -/// overflow the GPU buffer limit. -/// `clipboard` is the session's clipboard and its serial (dragged when `drag.clipboard`, by PASTE). -/// `transparency`: TRANSPARENCYDISPLAY (dragged objects keep their transparency). -#[allow(clippy::too_many_arguments)] -fn draw_drag_gpu( - c: &mut CanvasState, - p: &egui::Painter, - xf: &Xf, - doc: &cadcraft_doc::Drawing, - drag: &cadcraft_engine::Drag, - clipboard: (&[cadcraft_doc::Entity], u64), - bg: Rgb, - transparency: bool, -) { - // Tessellate for a power-of-two zoom band so zooming mid-drag rebuilds only every 2x. - let band = xf.scale.max(1e-300).log2().round() as i32; - let key = { - use std::hash::{Hash, Hasher}; - let mut h = std::collections::hash_map::DefaultHasher::new(); - (c.key, band, bg.0, bg.1, bg.2, &drag.handles, transparency).hash(&mut h); - if drag.clipboard { - clipboard.1.hash(&mut h); - } - h.finish() - }; - if c.drag_key != Some(key) { - let band_scale = 2f64.powi(band); - let opts = cadcraft_render::Options { transparency, ..preview_options(band_scale) }; - let list = if drag.clipboard { - cadcraft_render::build_entities(doc, clipboard.0.iter(), &opts) - } else { - cadcraft_render::build_entities(doc, drag.handles.iter().filter_map(|h| doc.entity(*h)).map(|e| e.as_ref()), &opts) - }; - let origin = if list.bounds.is_empty() { Vec2::ZERO } else { list.bounds.center() }; - let mesh = crate::gpu::build_mesh(&list, origin, bg, false); - if let Ok(mut slot) = c.drag_slot.lock() { - *slot = Some(mesh); - } - c.drag_key = Some(key); - c.drag_origin = origin; - } - // Positions are relative to the origin o: M(o + p) = L·p + (M(o) − o). - let m = drag.matrix; - let o = c.drag_origin; - let t = m.apply(o) - o; - let center = xf.rect.center(); - p.add(egui_wgpu::Callback::new_paint_callback( - xf.rect, - crate::gpu::CanvasCallback { - key, - slot: c.drag_slot.clone(), - offset: [(o.x - xf.center.x) as f32, (o.y - xf.center.y) as f32], - scale: xf.scale as f32, - center: [center.x, center.y], - layer: crate::gpu::LAYER_DRAG, - xform: [m.a as f32, m.b as f32, m.c as f32, m.d as f32, t.x as f32, t.y as f32], - dash: 0.0, - stencil: crate::gpu::Stencil::Off, - }, - )); -} - -/// Identifies the GPU vertex data of list `key` / `list_gen` converted at `origin` against `bg`. -fn list_mesh_key(key: &ListKey, list_gen: u64, origin: Vec2, bg: Rgb, lwdisplay: bool) -> u64 { - use std::hash::{Hash, Hasher}; - let mut h = std::collections::hash_map::DefaultHasher::new(); - (key, list_gen, origin.x.to_bits(), origin.y.to_bits(), bg.0, bg.1, bg.2, lwdisplay).hash(&mut h); - h.finish() -} - -/// Draw the display list through the GPU paint callback (uploading it when it changed), plus -/// the infinite lines, which are clipped to the view on the CPU every frame. -fn draw_list_gpu(c: &mut CanvasState, p: &egui::Painter, xf: &Xf, bg: Rgb, lwdisplay: bool) { - let Some(list) = &c.list else { return }; - let origin = crate::gpu::choose_origin(&list.bounds, xf.center, f64::from(xf.rect.height()) / xf.scale.max(1e-300)); - let key = list_mesh_key(&c.key, c.list_gen, origin, bg, lwdisplay); - if c.mesh_key != Some(key) { - let t0 = crate::now_ms(); - let mesh = crate::gpu::build_mesh(list, origin, bg, lwdisplay); - if let Ok(mut slot) = c.mesh_slot.lock() { - *slot = Some(mesh); - } - c.infinite = list.prims.iter().enumerate().filter(|(_, pr)| matches!(pr.kind, Kind::Infinite { .. })).map(|(i, _)| i).collect(); - c.mesh_key = Some(key); - c.mesh_origin = origin; - c.mesh_ms = crate::now_ms() - t0; - } - let center = xf.rect.center(); - p.add(egui_wgpu::Callback::new_paint_callback( - xf.rect, - crate::gpu::CanvasCallback { - key, - slot: c.mesh_slot.clone(), - offset: [(c.mesh_origin.x - xf.center.x) as f32, (c.mesh_origin.y - xf.center.y) as f32], - scale: xf.scale as f32, - center: [center.x, center.y], - layer: crate::gpu::LAYER_DRAWING, - xform: crate::gpu::IDENTITY_XFORM, - dash: 0.0, - stencil: crate::gpu::Stencil::Off, - }, - )); - let mut shapes = Vec::new(); - for prim in c.infinite.iter().filter_map(|i| list.prims.get(*i)) { - let pts = list.points(prim); - if let (Kind::Infinite { ray }, Some(b), Some(d)) = (prim.kind, pts.first(), pts.get(1)) - && let Some(seg) = clip_infinite(xf, *b, *d, ray) - { - shapes.push(Shape::line_segment(seg, Stroke::new(1.0, prim_color32(prim, bg)))); - } - } - p.extend(shapes); -} - -/// How a highlight is drawn: lines `width` points wide, and either solid with fills filled or -/// dashed with fills outlined. -#[derive(Clone, Copy, Debug, PartialEq)] -pub(crate) struct Look { - pub width: f32, - pub dashed: bool, -} - -impl Look { - /// Selected objects (HIGHLIGHT, SELECTIONEFFECT); `None`: not highlighted. - pub(crate) fn selection(s: &cadcraft_engine::Settings) -> Option { - s.highlight.then_some(Look { width: 1.5, dashed: s.selectioneffect == 0 }) - } - /// The object under the cursor (PREVIEWEFFECT: 0 dashed, 1 thicker, 2 both). - pub(crate) fn preview(s: &cadcraft_engine::Settings) -> Look { - Look { width: if s.previeweffect == 0 { 1.0 } else { 2.0 }, dashed: s.previeweffect != 1 } - } -} - -/// Draw selected/hovered entities as highlight overlays: `prims` are indices into `list` (from -/// [`PrimIndex::prims`], [`prims_of`], or all of a [`VpHighlight`] list), redrawn in `color` as `look` says. A wipeout is always -/// outlined, since filling it would hide the drawing under it. -pub(crate) fn draw_highlight(p: &egui::Painter, xf: &Xf, list: &DisplayList, prims: &[usize], color: Color32, look: Look) { - let stroke = Stroke::new(look.width, color); - let line = |pts: Vec, out: &mut Vec| { - if look.dashed { - out.extend(Shape::dashed_line(&pts, stroke, 6.0, 4.0)); - } else { - out.push(Shape::line(pts, stroke)); - } - }; - let mut shapes = Vec::new(); - let mut fill = egui::Mesh::default(); - let fill_color = color.gamma_multiply(crate::gpu::HIGHLIGHT_FILL_ALPHA); - for prim in prims.iter().filter_map(|&i| list.prims.get(i)) { - let pts = list.points(prim); - match prim.kind { - Kind::Polyline => line(pts.iter().map(|q| xf.to_screen(*q)).collect(), &mut shapes), - Kind::Infinite { ray } => { - if let (Some(b), Some(d)) = (pts.first(), pts.get(1)) - && let Some(seg) = clip_infinite(xf, *b, *d, ray) - { - line(seg.to_vec(), &mut shapes); - } - } - Kind::Point => { - if let Some(q) = pts.first() { - shapes.push(Shape::circle_stroke(xf.to_screen(*q), 3.0, Stroke::new(1.0, color))); - } - } - Kind::Tris if !look.dashed => { - for q in pts.as_chunks::<3>().0.iter().flatten() { - let i = fill.vertices.len() as u32; - fill.colored_vertex(xf.to_screen(*q), fill_color); - fill.indices.push(i); - } - } - Kind::Tris | Kind::Mask => { - for outline in list.fill_outlines(prim) { - line(outline.iter().map(|q| xf.to_screen(*q)).collect(), &mut shapes); - } - } - } - } - if !fill.is_empty() { - shapes.push(Shape::mesh(fill)); - } - p.extend(shapes); -} - -#[cfg(test)] -mod highlight_stability_tests { - use super::*; - - fn frame(list: &DisplayList, look: Look, pointer: Pos2) -> Vec { - let ctx = egui::Context::default(); - let rect = Rect::from_min_size(Pos2::ZERO, vec2(600.0, 400.0)); - let input = egui::RawInput { screen_rect: Some(rect), events: vec![egui::Event::PointerMoved(pointer)], ..Default::default() }; - let mut output = ctx.run_ui(input, |ui| { - draw_highlight(ui.painter(), &Xf { rect, center: Vec2::ZERO, scale: 11.0 }, list, &[0], Color32::LIGHT_BLUE, look); - }); - let shapes = std::mem::take(&mut output.shapes); - output.textures_delta.clear(); - shapes - } - - // Compare visible dash segments, not their submission order or endpoint direction. - fn dash_positions(shapes: &[egui::epaint::ClippedShape]) -> Vec<((u32, u32), (u32, u32))> { - let mut segments: Vec<_> = shapes - .iter() - .filter_map(|s| match &s.shape { - Shape::LineSegment { points: [a, b], stroke } if stroke.color == Color32::LIGHT_BLUE => { - let (a, b) = ((a.x.to_bits(), a.y.to_bits()), (b.x.to_bits(), b.y.to_bits())); - Some(if a < b { (a, b) } else { (b, a) }) - } - _ => None, - }) - .collect(); - segments.sort_unstable(); - segments - } - - #[test] - fn moving_the_pointer_does_not_move_cpu_fill_highlight_dashes() { - let p = |x, y| Vec2::new(x, y); - let mut list = DisplayList { tris: vec![p(0.0, 0.0), p(7.0, 0.0), p(7.0, 3.0), p(0.0, 0.0), p(7.0, 3.0), p(0.0, 3.0)], ..Default::default() }; - list.prims.push(cadcraft_render::DPrim { - handle: Handle(256), - color: Rgb(255, 255, 255), - aci7: true, - lw: 0.0, - transparency: 0, - kind: Kind::Tris, - start: 0, - len: 6, - }); - // Filled glyphs, hatches, solids and arrowheads share the Tris path; wipeouts use Mask. - for kind in [Kind::Tris, Kind::Mask] { - list.prims.first_mut().unwrap().kind = kind; - for width in [1.0, 1.5, 2.0] { - let look = Look { width, dashed: true }; - let expected = dash_positions(&frame(&list, look, pos2(320.0, 190.0))); - assert!(!expected.is_empty()); - for i in 0..32 { - assert_eq!( - dash_positions(&frame(&list, look, pos2(320.0 + i as f32, 190.0))), - expected, - "pointer movement changes only the cursor, not the dashed contour" - ); - } - } - } - if let Some(dir) = std::env::var_os("CADCRAFT_HIGHLIGHT_SHOTS") { - let dir = std::path::PathBuf::from(dir); - std::fs::create_dir_all(&dir).unwrap(); - let mut h = egui_kittest::Harness::builder().with_size(vec2(600.0, 400.0)).build_ui(|ui| { - let xf = Xf { rect: ui.max_rect(), center: Vec2::new(3.5, 1.5), scale: 31.0 }; - draw_highlight(ui.painter(), &xf, &list, &[0], Color32::LIGHT_BLUE, Look { width: 2.0, dashed: true }); - }); - h.run_steps(3); - h.event(egui::Event::PointerMoved(pos2(250.0, 190.0))); - h.run_steps(2); - // Harness::render adds a synthetic cursor; omit it from the pixel comparison. - h.remove_cursor(); - h.run_steps(1); - let first = h.render().unwrap(); - first.save(dir.join("highlight-pointer-a.png")).unwrap(); - h.event(egui::Event::PointerMoved(pos2(320.0, 190.0))); - h.run_steps(2); - h.remove_cursor(); - h.run_steps(1); - let second = h.render().unwrap(); - second.save(dir.join("highlight-pointer-b.png")).unwrap(); - let changed = first.pixels().zip(second.pixels()).filter(|(a, b)| a != b).count(); - assert_eq!(changed, 0, "moving the pointer cannot change any highlight pixels"); - } - } -} - -/// The fill of a selection window or crossing area (SELECTIONAREA, WINDOWAREACOLOR, -/// CROSSINGAREACOLOR, SELECTIONAREAOPACITY); `None` when areas aren't filled. -fn selection_area_fill(s: &cadcraft_engine::Settings, crossing: bool) -> Option { - let aci = if crossing { s.crossingareacolor } else { s.windowareacolor }; - let c = cadcraft_color::aci_rgb(aci); - s.selectionarea.then(|| Color32::from_rgb(c.0, c.1, c.2).gamma_multiply(f32::from(s.selectionareaopacity.min(100)) / 100.0)) -} - -fn draw_snap_marker(p: &egui::Painter, at: Pos2, hit: &SnapHit) { - let t = Tokens::get(); - let s = crate::theme::snap_marker_half(); - let st = Stroke::new(2.0, t.snap); - use snap::mode; - match hit.mode { - mode::END => { - p.rect_stroke(Rect::from_center_size(at, vec2(2.0 * s, 2.0 * s)), 0.0, st, egui::StrokeKind::Middle); - } - mode::MID => { - p.add(Shape::closed_line(vec![at + vec2(0.0, -s), at + vec2(s, s * 0.8), at + vec2(-s, s * 0.8)], st)); - } - mode::CEN | mode::GCEN => { - p.circle_stroke(at, s, st); - } - mode::QUA => { - p.add(Shape::closed_line(vec![at + vec2(0.0, -s), at + vec2(s, 0.0), at + vec2(0.0, s), at + vec2(-s, 0.0)], st)); - } - mode::INT | mode::APP => { - p.line_segment([at + vec2(-s, -s), at + vec2(s, s)], st); - p.line_segment([at + vec2(s, -s), at + vec2(-s, s)], st); - if hit.mode == mode::APP { - p.rect_stroke(Rect::from_center_size(at, vec2(2.0 * s, 2.0 * s)), 0.0, st, egui::StrokeKind::Middle); - } - } - mode::PER => { - p.line(vec![at + vec2(-s, -s), at + vec2(-s, s), at + vec2(s, s)], st); - p.line(vec![at + vec2(-s, 0.0), at + vec2(0.0, 0.0), at + vec2(0.0, s)], st); - } - mode::TAN => { - p.circle_stroke(at, s * 0.8, st); - p.line_segment([at + vec2(-s, -s), at + vec2(s, -s)], st); - } - mode::NOD => { - p.circle_stroke(at, s, st); - p.line_segment([at + vec2(-s, -s), at + vec2(s, s)], st); - p.line_segment([at + vec2(s, -s), at + vec2(-s, s)], st); - } - mode::INS => { - p.line( - vec![ - at + vec2(-s, -s), - at + vec2(0.0, -s), - at + vec2(0.0, 0.0), - at + vec2(s, 0.0), - at + vec2(s, s), - at + vec2(-s, s), - at + vec2(-s, -s), - ], - st, - ); - } - _ => { - p.line(vec![at + vec2(-s, -s), at + vec2(s, -s), at + vec2(-s, s), at + vec2(s, s), at + vec2(-s, -s)], st); - } - } - p.text(at + vec2(12.0, 12.0), egui::Align2::LEFT_TOP, crate::i18n::t(hit.name), crate::theme::small(), Color32::BLACK); - let galley = p.layout_no_wrap(crate::i18n::t(hit.name).to_string(), crate::theme::small(), Color32::BLACK); - let r = Rect::from_min_size(at + vec2(10.0, 10.0), galley.size() + vec2(6.0, 2.0)); - p.rect_filled(r, 2.0, Color32::from_rgb(0xff, 0xff, 0xe1)); - p.galley(r.min + vec2(3.0, 1.0), galley, Color32::BLACK); -} - -/// Acquired tracking points (small `+`, `//` for a Parallel line), the dotted paths the cursor -/// is on and the tooltip naming them ("Endpoint: 5.0000 < 90°"). -fn draw_tracking(p: &egui::Painter, xf: &Xf, acquired: &[snap::tracking::Acquired], track: Option<&snap::tracking::Track>, far: f64) { - use snap::tracking::{Acquired, PathShape}; - let t = Tokens::get(); - let st = Stroke::new(1.0, t.snap); - for a in acquired { - let c = xf.to_screen(a.at()); - if let Acquired::Parallel { .. } = a { - p.line_segment([c + vec2(-5.0, 3.0), c + vec2(-1.0, -5.0)], st); - p.line_segment([c + vec2(1.0, 3.0), c + vec2(5.0, -5.0)], st); - } else { - p.line_segment([c + vec2(-4.0, 0.0), c + vec2(4.0, 0.0)], st); - p.line_segment([c + vec2(0.0, -4.0), c + vec2(0.0, 4.0)], st); - } - } - let Some(tr) = track else { return }; - let mut tips = Vec::new(); - for path in &tr.paths { - // Around the cursor only (a path's origin may be far off screen). - let pts: Vec = match path.shape { - PathShape::Ray { origin, dir } => { - let back = (tr.point - origin).dot(dir).clamp(0.0, far); - vec![xf.to_screen(tr.point - dir * back), xf.to_screen(tr.point + dir * far)] - } - PathShape::Line { dir, .. } => vec![xf.to_screen(tr.point - dir * far), xf.to_screen(tr.point + dir * far)], - PathShape::Circle { center, radius, .. } => { - let (a0, span) = (center.angle_to(tr.point), (far / radius).min(std::f64::consts::PI)); - (0..=96).map(|i| xf.to_screen(Vec2::polar(center, radius, a0 - span + span * f64::from(i) / 48.0))).collect() - } - PathShape::Ellipse { center, major, ratio, .. } => { - let e = cadcraft_geom::Ellipse::full(center, major, ratio); - let (t0, span) = (e.param_of(tr.point), (far / (major.len() * ratio.min(1.0))).min(std::f64::consts::PI)); - (0..=96).map(|i| xf.to_screen(e.at_param(t0 - span + span * f64::from(i) / 48.0))).collect() - } - }; - p.extend(Shape::dotted_line(&pts, t.snap, 6.0, 1.0)); - let (distance, angle) = ( - cadcraft_engine::units::format_distance(path.from.dist(tr.point), 2, 4), - (path.from.angle_to(tr.point).to_degrees().round() as i64).rem_euclid(360), - ); - tips.push(if path.name == "Polar" { - crate::tf!("Polar: {distance} < {angle}°", distance = distance, angle = angle) - } else { - crate::tf!("{name}: {distance} < {angle}°", name = crate::i18n::t(path.name), distance = distance, angle = angle) - }); - } - if let Some(o) = tr.object { - tips.push(crate::i18n::t(o).to_string()); - } - let at = xf.to_screen(tr.point); - if tr.paths.len() > 1 || tr.object.is_some() { - p.line_segment([at + vec2(-4.0, -4.0), at + vec2(4.0, 4.0)], st); - p.line_segment([at + vec2(4.0, -4.0), at + vec2(-4.0, 4.0)], st); - } - tooltip(p, at + vec2(16.0, 18.0), &tips.join(", ")); -} - -/// The rubber-band and snap-adjusted cursor point for the current prompt (the engine's -/// `Session::snap_cursor`: object snaps, acquiring tracking points, grid, tracking paths, ortho). -fn effective_point(app: &mut CadApp, raw: Vec2, xf: &Xf, ctx: &egui::Context) -> Vec2 { - let prompt = app.session.current_prompt(); - let wants_point = prompt.as_ref().is_some_and(cadcraft_engine::pointmod::accepts); - let base = prompt.as_ref().and_then(|p| p.base); - let deferred = prompt.as_ref().is_some_and(|p| p.deferred); - let corner = prompt.as_ref().is_some_and(|p| p.corner); - app.canvas.snap = None; - app.canvas.track = None; - if !wants_point || app.session.pending_window.is_some() { - if !wants_point { - app.session.tracking.clear(); - } - return raw; - } - let (now, shift) = ctx.input(|i| (i.time, i.modifiers.shift)); - let q = - snap::tracking::CursorQuery { base, deferred, corner, aperture: app.session.settings.aperture / xf.scale, tol: 6.0 / xf.scale, now, shift }; - let r = app.session.snap_cursor(raw, &q); - if r.wait { - ctx.request_repaint_after(std::time::Duration::from_secs_f64(snap::tracking::DWELL / 4.0)); - } - app.canvas.snap = r.snap; - app.canvas.track = r.track; - r.point -} - -/// The vertical wheel scroll this frame that zooms, in points, read from the raw wheel events so -/// each notch zooms by its whole step at once, as in standard CAD (#660). egui's smoothed delta -/// spreads a notch over several frames, so quick notches lag behind the wheel. Units convert to -/// points as egui does (a line is `line_scroll_speed` points, a page the view's height), and -/// events egui turns into a pinch zoom (⌘/Ctrl) or a sideways scroll (⇧) are left out, as it does. -fn wheel_zoom_points(events: &[egui::Event], opts: &egui::InputOptions, page: f32) -> f32 { - let points: f32 = events - .iter() - .filter_map(|e| match e { - egui::Event::MouseWheel { unit, delta, modifiers, .. } - if !modifiers.matches_any(opts.zoom_modifier) && !modifiers.matches_any(opts.horizontal_scroll_modifier) => - { - let per = match unit { - egui::MouseWheelUnit::Point => 1.0, - egui::MouseWheelUnit::Line => opts.line_scroll_speed, - egui::MouseWheelUnit::Page => page, - }; - Some(delta.y * per) - } - _ => None, - }) - .sum(); - if points.is_finite() { points } else { 0.0 } -} - /// Draw the drawing area and handle its input (zoom and pan, picking, the crosshair) for this /// frame. /// @@ -2066,887 +928,9 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { // spin the CPU with the low-latency present mode (`gpu::surface_config`). } -/// The model space tiles on screen (none without tiling): each tile's rectangle in `full`. -/// Start TEXTEDIT on the text, mtext or attribute definition at `p` (the objects its shortcut menu -/// offers Edit Text for); false when there is none. -fn edit_text_at(app: &mut CadApp, p: Vec2) -> bool { - use cadcraft_engine::doc::EntityKind; - let is_text = cadcraft_engine::cmd::curves::pick_at(&app.session, p).and_then(|h| { - app.session.doc().ok()?.entity(h).map(|e| matches!(e.kind, EntityKind::Text(_) | EntityKind::MText(_) | EntityKind::AttDef(_))) - }); - if is_text != Some(true) { - return false; - } - app.start("textedit"); - if let Err(e) = app.session.input(Input::Point(p)) { - app.session.echo(e.to_string()); - } - true -} - -fn tile_rects(app: &CadApp, full: Rect) -> Vec<(Rect, cadcraft_engine::ModelTile)> { - let x = |f: f64| full.left() + full.width() * f.clamp(0.0, 1.0) as f32; - let y = |f: f64| full.bottom() - full.height() * f.clamp(0.0, 1.0) as f32; - app.session - .model_tiles() - .into_iter() - .map(|t| (Rect::from_min_max(pos2(x(t.min.x), y(t.max.y)), pos2(x(t.max.x), y(t.min.y))).shrink(1.0), t)) - .collect() -} - -/// The world ↔ screen transform of a model tile drawn in `r`. -fn tile_xf(r: Rect, tile: &cadcraft_engine::ModelTile) -> Xf { - Xf { rect: r, center: tile.view.center, scale: f64::from(r.height()) / tile.view.height.max(1e-12) } -} - -/// The tiles other than the current one (each with its own view, clipped to it), then the -/// tile borders: thin, and heavy around the current tile. -fn draw_tiles(app: &CadApp, ui: &egui::Ui, tiles: &[(Rect, cadcraft_engine::ModelTile)]) { - let t = Tokens::get(); - let bg = Rgb(t.canvas.r(), t.canvas.g(), t.canvas.b()); - for (r, tile) in tiles.iter().filter(|(r, tile)| !tile.active && r.width() >= 1.0 && r.height() >= 1.0) { - let p = ui.painter_at(*r); - let xf = tile_xf(*r, tile); - p.rect_filled(*r, 0.0, t.canvas); - draw_grid(app, &p, &xf); - if let Some(list) = &app.canvas.list { - draw_list(&p, &xf, list, bg, app.session.settings.lwdisplay); - } - viewport_label(&p, *r); - } - let p = ui.painter(); - for (r, tile) in tiles { - let (w, c) = if tile.active { (2.0, t.accent) } else { (1.0, t.border) }; - p.rect_stroke(*r, 0.0, Stroke::new(w, c), egui::StrokeKind::Outside); - } -} - -/// A click while selection cycling's list is open picks the row under it (anywhere else just -/// closes the list); otherwise a click over overlapping objects that a pick would choose between -/// opens the list (SELECTIONCYCLING 2). True when the click was used up here. -fn cycle_click(app: &mut CadApp, xf: &Xf, hover: Option, world: Option, shift: bool) -> bool { - // Only where a click picks objects; a click that places a point goes through. - let picks = app.session.click_picks(); - if let Some((_, hs, i)) = app.canvas.hover_cycle.take() - && picks - && let Some(h) = hs.get(i % hs.len().max(1)) - { - if let Err(e) = app.session.pick_cycled(*h, shift) { - app.session.echo(e.to_string()); - } - return true; - } - if let Some(list) = app.canvas.cycle.take() { - let Some(h) = hover.and_then(|hp| list.row_at(hp)).filter(|_| picks) else { return picks }; - if let Err(e) = app.session.pick_cycled(h, list.shift) { - app.session.echo(e.to_string()); - } - return true; - } - let (Some(hp), Some(w)) = (hover, world) else { return false }; - if crate::dyngrips::at(app, xf, hover).is_some() || grip_at(app, xf, hover).is_some() { - return false; - } - let hs = app.session.cycling_candidates(w); - let Ok(d) = app.session.doc() else { return false }; - let items: Vec<_> = - hs.into_iter().filter_map(|h| d.entity(h).map(|e| (h, crate::i18n::t(e.kind.type_name()).to_string(), e.common.layer.clone()))).collect(); - if items.len() < 2 { - return false; - } - app.canvas.cycle = Some(CycleList { at: hp, items, shift, rows: Vec::new() }); - true -} - -/// Shift+Space over overlapping objects highlights the next one under the cursor, and a click -/// picks the highlighted one, whether selection cycling is on or not; moving the cursor off the -/// pick box where it started ends it. -fn shift_space_cycle(app: &mut CadApp, ui: &egui::Ui, hover: Option, world: Option) { - let pick_px = (app.session.settings.pickbox.max(1.0) * 1.5) as f32; - let (Some(hp), Some(w)) = (hover, world) else { - app.canvas.hover_cycle = None; - return; - }; - if app.canvas.hover_cycle.as_ref().is_some_and(|(a, _, _)| a.distance(hp) > pick_px) { - app.canvas.hover_cycle = None; - } - let pressed = ui - .input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Key { key: egui::Key::Space, pressed: true, modifiers, .. } if modifiers.shift))); - if pressed { - // With the list open Shift+Space does nothing, and isn't Enter either. - let next = match app.canvas.hover_cycle.take() { - _ if app.canvas.cycle.is_some() => None, - Some((a, hs, i)) => Some((a, hs, i + 1)), - None => Some((hp, app.session.pick_candidates(w), 1)).filter(|(_, hs, _)| hs.len() > 1), - }; - if next.is_some() || app.canvas.cycle.is_some() { - // Space is Enter at the command line; this one only steps the highlight. - ui.ctx().input_mut(|i| { - i.events.retain(|e| !matches!(e, egui::Event::Key { key: egui::Key::Space, .. }) && !matches!(e, egui::Event::Text(t) if t == " ")) - }); - } - app.canvas.hover_cycle = next; - } - if let Some((_, hs, i)) = &app.canvas.hover_cycle { - app.canvas.hover = hs.get(i % hs.len().max(1)).copied(); - app.canvas.hover_at = None; - } -} - -/// Selection cycling's badge beside the cursor: two overlapping squares, objects overlap here. -fn cycle_badge(p: &egui::Painter, at: Pos2) { - let t = Tokens::get(); - for off in [vec2(14.0, -22.0), vec2(19.0, -17.0)] { - let r = Rect::from_min_size(at + off, vec2(9.0, 9.0)); - p.rect_filled(r, 1.0, t.cmd_bg); - p.rect_stroke(r, 1.0, Stroke::new(1.0, t.text), egui::StrokeKind::Inside); - } -} - -/// Whether `p` lies on a menu the canvas paints itself — the selection cycling list's rows or the -/// Dynamic Input options menu's rows. They are menus, so the OS cursor stays visible over them, but -/// they share the canvas's layer: `layer_id_at` reports the drawing, and only their rectangles can -/// tell them apart from it (#1701). -fn over_canvas_menu(app: &CadApp, p: Pos2) -> bool { - app.canvas.cycle.as_ref().is_some_and(|c| c.rows.iter().any(|r| r.expand(2.0).contains(p))) - || app.cmd.option_rows.iter().any(|(r, _)| r.expand(2.0).contains(p)) -} - -/// The selection cycling list beside the click that opened it: a row per object, its type and -/// layer, the row under the pointer highlighted; the rows are kept for clicks. -fn cycle_list(app: &mut CadApp, p: &egui::Painter) { - let Some(list) = app.canvas.cycle.as_mut() else { return }; - let t = Tokens::get(); - let font = crate::theme::small(); - let galleys: Vec<_> = list - .items - .iter() - .map(|(_, ty, layer)| (p.layout_no_wrap(ty.clone(), font.clone(), t.text), p.layout_no_wrap(layer.clone(), font.clone(), t.text_dim))) - .collect(); - let row_h = galleys.iter().map(|(a, _)| a.size().y).fold(12.0, f32::max) + 6.0; - let ty_w = galleys.iter().map(|(a, _)| a.size().x).fold(40.0, f32::max); - let layer_w = galleys.iter().map(|(_, b)| b.size().x).fold(20.0, f32::max); - let w = ty_w + layer_w + 36.0; - let m = Rect::from_min_size(list.at + vec2(14.0, 14.0), vec2(w, row_h * galleys.len() as f32 + 4.0)); - p.rect_filled(m, 3.0, t.cmd_bg); - p.rect_stroke(m, 3.0, Stroke::new(1.0, t.cmd_border), egui::StrokeKind::Inside); - let hover = app.canvas.hover; - list.rows.clear(); - for (i, ((ty, layer), (h, _, _))) in galleys.into_iter().zip(&list.items).enumerate() { - let row = Rect::from_min_size(pos2(m.left() + 2.0, m.top() + 2.0 + row_h * i as f32), vec2(w - 4.0, row_h)); - if hover == Some(*h) { - p.rect_filled(row, 2.0, t.cmd_keyword_hover); - } - p.galley(pos2(row.left() + 8.0, row.center().y - ty.size().y / 2.0), ty, t.text); - p.galley(pos2(row.left() + 20.0 + ty_w, row.center().y - layer.size().y / 2.0), layer, t.text_dim); - list.rows.push(row); - } -} - -fn tooltip(p: &egui::Painter, at: Pos2, text: &str) { - let galley = p.layout_no_wrap(text.to_string(), crate::theme::small(), Color32::from_rgb(0x20, 0x20, 0x20)); - let r = Rect::from_min_size(at, galley.size() + vec2(8.0, 4.0)); - p.rect_filled(r, 2.0, Color32::from_rgb(0xf4, 0xf4, 0xf4)); - p.galley(r.min + vec2(4.0, 2.0), galley, Color32::BLACK); -} - -/// The hatch under world point `w` within the pick aperture (PICKBOX × 1.5 pixels at `scale` -/// pixels per unit), if any. -fn hatch_at(app: &CadApp, w: Vec2, scale: f64) -> Option { - let st = app.session.state().ok()?; - let aperture = app.session.settings.pickbox.max(1.0) * 1.5 / scale.max(1e-12); - let h = cadcraft_engine::select::pick(&st.doc, &st.edit_space(), w, aperture)?; - matches!(st.doc.entity(h)?.kind, cadcraft_doc::EntityKind::Hatch(_)).then_some(h) -} - -/// Half the side of the pick box: the pick aperture (PICKBOX × 1.5 pixels, as `select::pick` is -/// called with), so the square shows what a click hits. `None` when PICKBOX is 0 (no box). -/// The candidate Tab has cycled to, if cycling: a Tab press starts or advances it, and the -/// cursor leaving the aperture where it started ends it. -fn snap_cycle(app: &mut CadApp, raw: Vec2, ap: f64) -> Option { - let tab = std::mem::take(&mut app.canvas.snap_tab); - let c = &mut app.canvas.snap_cycle; - if c.is_some_and(|c| c.anchor.dist(raw) > ap) { - *c = None; - } - if tab { - *c = Some(c.map_or(SnapCycle { anchor: raw, index: 1 }, |c| SnapCycle { index: c.index + 1, ..c })); - } - c.map(|c| c.index) -} - -/// The cursor under a one-pick object snap override (`Session::snap_override`: END, NON…) or -/// while Tab cycles the candidates: the snap of that mode (or the cycled candidate) replaces -/// what the running snaps found; with none, a running snap no longer holds the cursor. -fn override_cursor(app: &mut CadApp, raw: Vec2, xf: &Xf) { - let over = app.session.snap_override(); - let ap = app.session.settings.aperture / xf.scale; - let cycle = snap_cycle(app, raw, ap); - let prompt = app.session.current_prompt(); - let wants_point = prompt.as_ref().is_some_and(cadcraft_engine::pointmod::accepts); - if (over.is_none() && cycle.is_none()) || !wants_point || app.session.pending_window.is_some() { - return; - } - let base = prompt.as_ref().and_then(|p| p.base); - let deferred = prompt.as_ref().is_some_and(|p| p.deferred); - let s = &app.session.settings; - let osmode = over.unwrap_or(s.osmode) | if s.osnaphatch { snap::mode::HATCH } else { 0 }; - let Ok(st) = app.session.state() else { return }; - let hit = match cycle { - Some(i) => { - let cands = cadcraft_engine::pointmod::snap_candidates(&st.doc, &st.edit_space(), raw, ap, osmode, base, deferred); - cands.get(i % cands.len().max(1)).copied() - } - None => snap::osnap(&st.doc, &st.edit_space(), raw, ap, osmode, base, deferred), - }; - let p = match (hit, app.canvas.snap) { - (Some(h), _) => { - app.canvas.snap = Some(h); - h.point - } - (None, Some(_)) => { - app.canvas.snap = None; - raw - } - (None, None) => return, - }; - app.canvas.cursor = Some(p); - app.session.cursor = p; - app.session.cursor_deferred = app.canvas.snap.and_then(|h| h.deferred); -} - -fn pickbox_half(pickbox: f64) -> Option { - let half = (pickbox * 1.5) as f32; - (half.is_finite() && half >= 1.0).then_some(half) -} - -fn draw_crosshair(app: &CadApp, p: &egui::Painter, rect: Rect, at: Pos2, pickbox: bool, ink: Color32) { - let st = Stroke::new(1.0, ink); - let len = (rect.height() * app.session.settings.cursorsize as f32 / 100.0).max(12.0); - let pb = if pickbox { pickbox_half(app.session.settings.pickbox) } else { None }; - let gap = pb.unwrap_or(0.0); - // Along the isometric plane's axes in isometric drafting. - if app.session.settings.isodraft { - for a in cadcraft_engine::snap::iso::cursor_axes(&app.session.settings) { - let dir = vec2(a.cos() as f32, -(a.sin() as f32)); - p.line_segment([at - dir * len, at - dir * gap], st); - p.line_segment([at + dir * gap, at + dir * len], st); - } - } else { - p.line_segment([pos2(at.x - len, at.y), pos2(at.x - gap, at.y)], st); - p.line_segment([pos2(at.x + gap, at.y), pos2(at.x + len, at.y)], st); - p.line_segment([pos2(at.x, at.y - len), pos2(at.x, at.y - gap)], st); - p.line_segment([pos2(at.x, at.y + gap), pos2(at.x, at.y + len)], st); - } - if let Some(pb) = pb { - p.rect_stroke(Rect::from_center_size(at, vec2(pb * 2.0, pb * 2.0)), 0.0, st, egui::StrokeKind::Middle); - } -} - -/// The Dynamic Input tooltip beside the cursor, styled like the command line. At a point prompt -/// it shows the pair of value boxes, a view of the command-line text (see [`crate::dyninput`]); -/// otherwise the prompt with what is being typed, or the live distance and angle. With `fields` -/// (a distance prompt's field is on show, [`crate::grip_dyn`]) only the prompt and its options. -fn dynamic_input(app: &mut CadApp, p: &egui::Painter, hover: Pos2, fields: bool) { - app.cmd.option_rows.clear(); - // While the options menu is open the tooltip stays where it opened, so the menu can be reached. - let at = match app.cmd.options { - Some(_) => *app.cmd.options_anchor.get_or_insert(hover), - None => { - app.cmd.options_anchor = None; - hover - } - }; - let Some(prompt) = app.session.current_prompt() else { return }; - // DYNPROMPT = 0: no prompt beside the cursor (the value boxes stay). - let show_prompt = app.session.settings.dynprompt; - let options = show_prompt && !prompt.keywords.is_empty(); - if !fields - && let Some(f) = crate::cmdline::dyn_frame(app) - && let Some(e) = crate::dyninput::parse(&app.cmd.buffer, &f) - && let Some(c) = app.canvas.cursor - { - let message = if show_prompt { crate::i18n::t(&prompt.message) } else { "" }; - let r = dynamic_input_boxes(p, at, message, &f, &e, c, app.cmd.second_locked(), options, length_units(&app.session)); - options_menu(app, p, r, hover); - return; - } - if !show_prompt { - return; - } - let mut text = crate::i18n::t(&prompt.message).to_string(); - if fields { - // The field shows the distance and what is typed. - } else if !app.cmd.buffer.is_empty() { - text = format!("{}: {}", crate::i18n::t(&prompt.message), app.cmd.buffer); - } else if let (Some(base), Some(c)) = (prompt.base, app.canvas.cursor) { - text = format!( - "{} {} < {}°", - crate::i18n::t(&prompt.message), - distance_text(&app.session, base.dist(c)), - (base.angle_to(c).to_degrees() * 10.0).round() / 10.0 - ); - } else if let Some(c) = app.canvas.cursor { - text = format!("{} {}, {}", crate::i18n::t(&prompt.message), distance_text(&app.session, c.x), distance_text(&app.session, c.y)); - } - let t = Tokens::get(); - let galley = p.layout_no_wrap(text, crate::theme::small(), t.text); - let hint = if options { OPTIONS_HINT_W } else { 0.0 }; - let r = tooltip_frame(p, at, galley.size() + vec2(12.0 + hint, 8.0)); - p.galley(r.min + vec2(6.0, 4.0), galley, t.text); - if options { - options_hint(p, r); - } - options_menu(app, p, r, hover); -} - -/// The drawing's length units: (LUNITS, LUPREC). -pub(crate) fn length_units(session: &cadcraft_engine::Session) -> (i64, i64) { - session.doc().map(|d| (d.header.i64("LUNITS", 2), d.header.i64("LUPREC", 4))).unwrap_or((2, 4)) -} - -/// A distance in the drawing's length units. -fn distance_text(session: &cadcraft_engine::Session, v: f64) -> String { - let (lu, lp) = length_units(session); - cadcraft_engine::units::format_distance(v, lu, lp) -} - -/// Room for the down-arrow sign that a prompt has options. -const OPTIONS_HINT_W: f32 = 14.0; - -/// The down-arrow sign at the right end of the tooltip `r`: the prompt has options, and Down -/// Arrow lists them. -fn options_hint(p: &egui::Painter, r: Rect) { - let t = Tokens::get(); - let c = pos2(r.right() - OPTIONS_HINT_W / 2.0 - 2.0, r.center().y + 1.0); - p.add(Shape::convex_polygon(vec![pos2(c.x - 4.0, c.y - 2.5), pos2(c.x + 4.0, c.y - 2.5), pos2(c.x, c.y + 2.5)], t.text_dim, Stroke::NONE)); -} - -/// The options menu under the tooltip `r` (DYNPROMPT): one row per keyword, the highlighted one -/// marked; the rows are kept for clicks. -fn options_menu(app: &mut CadApp, p: &egui::Painter, r: Rect, hover: Pos2) { - let Some(mut menu) = app.cmd.options.clone() else { return }; - let t = Tokens::get(); - let font = crate::theme::small(); - let rows: Vec<_> = menu.keywords.iter().map(|k| p.layout_no_wrap(k.clone(), font.clone(), t.text)).collect(); - let row_h = rows.iter().map(|g| g.size().y).fold(12.0, f32::max) + 6.0; - let w = rows.iter().map(|g| g.size().x).fold(60.0, f32::max) + 28.0; - let h = row_h * rows.len() as f32 + 4.0; - let m = Rect::from_min_size(pos2(r.left(), r.bottom() + 2.0), vec2(w, h)); - p.rect_filled(m, 3.0, t.cmd_bg); - p.rect_stroke(m, 3.0, Stroke::new(1.0, t.cmd_border), egui::StrokeKind::Inside); - let row_at = |i: usize| Rect::from_min_size(pos2(m.left() + 2.0, m.top() + 2.0 + row_h * i as f32), vec2(w - 4.0, row_h)); - // The row under the pointer is the highlighted one. - if let Some(i) = (0..menu.keywords.len()).find(|i| row_at(*i).contains(hover)) { - menu.index = i; - app.cmd.options = Some(menu.clone()); - } - for (i, (g, k)) in rows.into_iter().zip(&menu.keywords).enumerate() { - let row = row_at(i); - if i == menu.index { - p.rect_filled(row, 2.0, t.cmd_keyword_hover); - p.circle_filled(pos2(row.left() + 9.0, row.center().y), 2.5, t.accent); - } - p.galley(pos2(row.left() + 20.0, row.center().y - g.size().y / 2.0), g, t.text); - app.cmd.option_rows.push((row, k.clone())); - } -} - -/// The tooltip background, matching the command line bar; returns its rectangle. -fn tooltip_frame(p: &egui::Painter, at: Pos2, size: egui::Vec2) -> Rect { - let t = Tokens::get(); - let r = Rect::from_min_size(at + vec2(18.0, -size.y - 14.0), size); - p.rect_filled(r, 3.0, t.cmd_bg); - p.rect_stroke(r, 3.0, Stroke::new(1.0, t.cmd_border), egui::StrokeKind::Inside); - r -} - -/// The Dynamic Input value boxes: X and Y, or distance and angle. Typed values show in full -/// text colour, a locked value (a separator typed after it) gets a lock, the active box gets the -/// accent outline, and open boxes show the cursor's live value, dimmed. Values measured from the -/// last point are marked with `@`. -#[allow(clippy::too_many_arguments)] -fn dynamic_input_boxes( - p: &egui::Painter, - at: Pos2, - message: &str, - f: &crate::dyninput::Frame, - e: &crate::dyninput::Entry<'_>, - cursor: Vec2, - second_locked: bool, - options: bool, - (lu, lp): (i64, i64), -) -> Rect { - use crate::dyninput::Format; - let t = Tokens::get(); - let font = crate::theme::small(); - let dist = |v: f64| cadcraft_engine::units::format_distance(v, lu, lp); - let (live_first, live_second) = f.live(e.format, cursor); - let live_second = match e.format { - Format::Cartesian => dist(live_second), - Format::Polar => format!("{}°", (live_second.to_degrees() * 10.0).round() / 10.0), - }; - let (label_first, label_second) = match e.format { - Format::Cartesian => ("X", "Y"), - Format::Polar => ("", "<"), - }; - // (label, text, typed, locked, active) - let cells = [ - ( - label_first, - if e.first.is_empty() { dist(live_first) } else { e.first.to_owned() }, - !e.first.is_empty(), - e.first_locked().is_some(), - !e.split, - ), - ( - label_second, - if e.second.is_empty() { live_second } else { e.second.to_owned() }, - !e.second.is_empty(), - second_locked, - e.split && !second_locked, - ), - ]; - - let msg = p.layout_no_wrap(message.to_owned(), font.clone(), t.text_dim); - let marker = p.layout_no_wrap(if f.relative() { "@".to_owned() } else { String::new() }, font.clone(), t.text_faint); - let lock_w = 10.0; - let laid: Vec<_> = cells - .into_iter() - .map(|(label, text, typed, locked, active)| { - let label = p.layout_no_wrap(label.to_owned(), font.clone(), t.text_faint); - let value = p.layout_no_wrap(text, font.clone(), if typed { t.text } else { t.text_faint }); - let w = (value.size().x + 10.0 + if locked { lock_w } else { 0.0 }).max(48.0); - (label, value, w, locked, active) - }) - .collect(); - - // Measure, then draw. - let pad = 6.0; - let gap = 6.0; - let box_h = msg.size().y + 6.0; - let mut w = pad + msg.size().x + gap + marker.size().x; - for (label, _, bw, _, _) in &laid { - w += gap + label.size().x + 3.0 + bw; - } - let hint = if options { OPTIONS_HINT_W } else { 0.0 }; - let r = tooltip_frame(p, at, vec2(w + pad + hint, box_h + 2.0 * pad - 2.0)); - if options { - options_hint(p, r); - } - let cy = r.center().y; - let mut x = r.left() + pad; - let (msg_w, msg_h) = (msg.size().x, msg.size().y); - p.galley(pos2(x, cy - msg_h / 2.0), msg, t.text_dim); - x += msg_w + gap; - let mw = marker.size().x; - p.galley(pos2(x, cy - marker.size().y / 2.0), marker, t.text_faint); - x += mw; - for (label, value, bw, locked, active) in laid { - x += gap; - let lw = label.size().x; - p.galley(pos2(x, cy - label.size().y / 2.0), label, t.text_faint); - x += lw + 3.0; - let b = Rect::from_min_size(pos2(x, cy - box_h / 2.0), vec2(bw, box_h)); - p.rect_filled(b, 2.0, t.chrome_dark); - let stroke = if active { Stroke::new(1.5, t.accent) } else { Stroke::new(1.0, t.border) }; - p.rect_stroke(b, 2.0, stroke, egui::StrokeKind::Inside); - let vw = value.size().x; - p.galley(pos2(b.left() + 5.0, cy - value.size().y / 2.0), value, t.text); - if active { - let cx = b.left() + 6.0 + vw; - p.line_segment([pos2(cx, b.top() + 3.0), pos2(cx, b.bottom() - 3.0)], Stroke::new(1.0, t.text)); - } - if locked { - draw_lock(p, pos2(b.right() - lock_w / 2.0 - 3.0, cy), t.accent); - } - x += bw; - } - r -} - -/// A small padlock, drawn in code: body plus shackle. -fn draw_lock(p: &egui::Painter, c: Pos2, color: Color32) { - p.rect_filled(Rect::from_center_size(c + vec2(0.0, 1.5), vec2(7.0, 5.0)), 1.0, color); - p.add(Shape::line( - vec![pos2(c.x - 2.0, c.y - 1.0), pos2(c.x - 2.0, c.y - 4.0), pos2(c.x + 2.0, c.y - 4.0), pos2(c.x + 2.0, c.y - 1.0)], - Stroke::new(1.2, color), - )); -} - -fn draw_ucs_icon(p: &egui::Painter, rect: Rect) { - let t = Tokens::get(); - let o = pos2(rect.left() + 36.0, rect.bottom() - 34.0); - let st = Stroke::new(1.0, t.canvas_ink); - p.line_segment([o, o + vec2(60.0, 0.0)], st); - p.line_segment([o, o + vec2(0.0, -60.0)], st); - p.rect_stroke(Rect::from_center_size(o, vec2(9.0, 9.0)), 0.0, st, egui::StrokeKind::Middle); - let f = egui::FontId::proportional(13.0); - p.text(o + vec2(70.0, 0.0), egui::Align2::LEFT_CENTER, crate::tl!("X"), f.clone(), t.canvas_ink); - p.text(o + vec2(0.0, -70.0), egui::Align2::CENTER_BOTTOM, crate::tl!("Y"), f, t.canvas_ink); -} - -fn viewport_label(p: &egui::Painter, rect: Rect) { - let t = Tokens::get(); - let at = pos2(rect.left() + 10.0, rect.top() + 8.0); - p.text(at, egui::Align2::LEFT_TOP, crate::tl!("+ | Top | 2D Wireframe"), crate::theme::small(), t.canvas_ink); -} - -fn draw_viewcube(app: &mut CadApp, ui: &mut egui::Ui, rect: Rect) { - let l = crate::viewcube::Layout::new(rect); - let (c, ring) = (l.center, l.ring); - let p = ui.painter_at(rect); - crate::viewcube::show(app, ui, &l); - // WCS pill: the label and a down chevron, drawn as lines rather than a "⌄" glyph that a UI - // font may lack (it showed as a box, #1541). - let pill = Rect::from_center_size(c + vec2(0.0, ring + 26.0), vec2(56.0, 16.0)); - p.rect_filled(pill, 8.0, Color32::from_rgb(0x48, 0x50, 0x5c)); - let ink = Tokens::DARK.canvas_ink; - let label = p.layout_no_wrap(crate::tl!("WCS").to_string(), crate::theme::small(), ink); - let (gap, chevron) = (4.0, 6.0); - let left = pill.center().x - (label.size().x + gap + chevron) / 2.0; - let mid = pill.center().y; - p.galley(pos2(left, mid - label.size().y / 2.0), label.clone(), ink); - let x = left + label.size().x + gap; - p.line(vec![pos2(x, mid - 1.5), pos2(x + chevron / 2.0, mid + 1.5), pos2(x + chevron, mid - 1.5)], Stroke::new(1.2, ink)); -} - -/// A click on grip `g` (idle): Shift+click makes it hot or cold again; a plain click starts -/// editing it, with the other hot grips when it is one of them. -fn click_grip(app: &mut CadApp, g: (Handle, usize), shift: bool) { - let hot = &mut app.canvas.hot_grips; - if shift { - match hot.iter().position(|x| *x == g) { - Some(i) => { - hot.remove(i); - } - None => hot.push(g), - } - return; - } - if !hot.contains(&g) { - hot.clear(); - } - hot.retain(|x| *x != g); - hot.insert(0, g); - let hs = hot.clone(); - if let Err(e) = app.session.start_grip_edit(&hs) { - app.canvas.hot_grips.clear(); - app.session.echo(e.to_string()); - } -} - -/// The multifunctional options of grip `g` (Add Vertex, Lengthen…; empty: Stretch only). -fn grip_options(app: &CadApp, (h, i): (Handle, usize)) -> Vec { - let Ok(st) = app.session.state() else { return Vec::new() }; - st.doc.entity(h).map(|e| cadcraft_engine::grips::grip_actions(&e.kind, i)).unwrap_or_default() -} - -/// The menu of a multifunctional grip: Stretch and its options. A choice makes the grip hot and -/// runs the option at the grip prompt, as typing its keyword would. -fn grip_menu(app: &mut CadApp, resp: &egui::Response) { - let Some(g) = app.canvas.grip_menu else { return }; - let options = grip_options(app, g); - let mut chosen: Option> = None; - let open = resp - .context_menu(|ui| { - let vertex = options.contains(&cadcraft_engine::grips::GripAction::RemoveVertex); - if ui.button(crate::i18n::t(if vertex { "Stretch Vertex" } else { "Stretch" })).clicked() { - chosen = Some(None); - ui.close(); - } - for a in &options { - if ui.button(crate::i18n::t(a.label())).clicked() { - chosen = Some(Some(*a)); - ui.close(); - } - } - }) - .is_some(); - if !open || chosen.is_some() { - app.canvas.grip_menu = None; - } - let Some(choice) = chosen else { return }; - app.canvas.hot_grips = vec![g]; - let r = app.session.start_grip_edit(&[g]).and_then(|()| match choice { - Some(a) => app.session.input(Input::Keyword(a.keyword().to_string())), - None => Ok(()), - }); - if let Err(e) = r { - app.canvas.hot_grips.clear(); - app.session.echo(e.to_string()); - } -} - -/// Polyline segment midpoint grips are rectangular; vertex grips remain square. -fn grip_rect(kind: &cadcraft_doc::EntityKind, count: usize, index: usize, center: Pos2, size: f32) -> Rect { - // The grip list interleaves vertices and available segment midpoints, then any remaining vertices. - let midpoint = matches!(kind, cadcraft_doc::EntityKind::LwPolyline(p) if index % 2 == 1 && index / 2 < count.saturating_sub(p.vertices.len())); - Rect::from_center_size(center, vec2(size * 2.0, if midpoint { size } else { size * 2.0 })) -} - -/// The grip of a selected object under the cursor, if any: (object, grip index). -fn grip_at(app: &CadApp, xf: &Xf, hover: Option) -> Option<(Handle, usize)> { - let hp = hover?; - let st = app.session.state().ok()?; - let gs = app.session.settings.gripsize as f32; - let sel = app.session.selection(); - if !app.session.shows_grips(sel.len()) { - return None; - } - for h in sel.iter().take(200) { - let e = st.doc.entity(*h)?; - if st.doc.layer(&e.common.layer).is_some_and(|l| l.locked) { - continue; - } - let grips = e.kind.grips(); - let count = grips.len(); - for (i, g) in grips.into_iter().enumerate() { - let sp = xf.to_screen(g); - if grip_rect(&e.kind, count, i, sp, gs).expand(3.0).contains(hp) { - return Some((*h, i)); - } - } - } - None -} - #[cfg(test)] #[allow(clippy::unwrap_used)] -pub(crate) mod tests { - use super::*; - use cadcraft_render::DPrim; - - fn list(handles: &[u64]) -> DisplayList { - let prim = - |h| DPrim { handle: Handle(h), color: Rgb(255, 255, 255), aci7: true, lw: 0.0, transparency: 0, kind: Kind::Polyline, start: 0, len: 0 }; - DisplayList { prims: handles.iter().map(|&h| prim(h)).collect(), ..Default::default() } - } - - fn hs(v: &[u64]) -> Vec { - v.iter().map(|&h| Handle(h)).collect() - } - - /// A dragged object keeps its transparency in the GPU preview when TRANSPARENCYDISPLAY is - /// on, and is opaque when it is off. - #[test] - fn dragged_objects_keep_their_transparency() { - let mut d = cadcraft_doc::Drawing::new_metric(); - let common = cadcraft_doc::Common { transparency: cadcraft_doc::Transparency::Percent(50), ..Default::default() }; - let line = cadcraft_doc::Line { a: cadcraft_geom::Vec3::ZERO, b: cadcraft_geom::Vec3::new(10.0, 0.0, 0.0) }; - let h = d.add(&cadcraft_doc::Space::Model, common, cadcraft_doc::EntityKind::Line(line)).unwrap(); - let drag = cadcraft_engine::Drag { handles: vec![h], matrix: cadcraft_geom::Mat3::IDENTITY, extra: Vec::new(), clipboard: false }; - let rect = Rect::from_min_size(Pos2::ZERO, vec2(200.0, 100.0)); - let xf = Xf { rect, center: Vec2::new(5.0, 0.0), scale: 10.0 }; - let painter = egui::Painter::new(egui::Context::default(), egui::LayerId::background(), rect); - // One canvas across the toggle: the cached drag mesh must be rebuilt. - let mut c = CanvasState::default(); - let mut alpha = |transparency: bool| { - draw_drag_gpu(&mut c, &painter, &xf, &d, &drag, (&[], 0), Rgb(0, 0, 0), transparency); - let mesh = c.drag_slot.lock().unwrap().take().unwrap(); - // The first line vertex's colour alpha (after its 2 × f32 position). - mesh.lines[11] - }; - assert_eq!(alpha(true), 128); - assert_eq!(alpha(false), 255); - assert_eq!(alpha(true), 128); - } - - /// Previews the session rebuilds with the current properties (the rubber band, copies of - /// objects) stay opaque even when CLAYER is transparent: they were opaque before transparency - /// was drawn, and CLAYER's transparency isn't the objects'. - #[test] - fn rebuilt_previews_stay_opaque_on_a_transparent_current_layer() { - let mut s = cadcraft_engine::Session::new(); - let d = s.doc_mut().unwrap(); - d.layers.push(cadcraft_doc::Layer { name: "Glass".into(), transparency: 40, ..Default::default() }); - d.header.set_str("CLAYER", "Glass"); - assert!(s.settings.transparency_display); - // LINE: the segment from the first point to the cursor. - s.cmdline("line").unwrap(); - s.cmdline("0,0").unwrap(); - let ents = s.preview(Vec2::new(10.0, 5.0)); - assert!(!ents.is_empty()); - assert_eq!(ents[0].common.layer, "Glass"); - let doc = s.doc().unwrap(); - // Built with transparency, the preview would take CLAYER's 40 %. - let with = cadcraft_render::build_entities(doc, ents.iter(), &cadcraft_render::Options { transparency: true, ..preview_options(1.0) }); - assert!(with.prims.iter().all(|p| p.transparency == 40)); - let list = rebuilt_preview(doc, ents.iter(), 1.0); - assert!(!list.prims.is_empty() && list.prims.iter().all(|p| p.transparency == 0)); - s.cancel(); - // MOVE's base-to-cursor rubber band (a drag extra). - s.execute("line", &serde_json::json!({"points": [[0, 0], [10, 0]]})).unwrap(); - s.cmdline("move").unwrap(); - s.cmdline("all").unwrap(); - s.cmdline("").unwrap(); - s.cmdline("0,0").unwrap(); - let (_, extra) = s.drag(Vec2::new(4.0, 4.0)).unwrap(); - assert!(!extra.is_empty()); - let list = rebuilt_preview(s.doc().unwrap(), extra.iter(), 1.0); - assert!(!list.prims.is_empty() && list.prims.iter().all(|p| p.transparency == 0)); - } - - #[test] - fn viewport_highlight_tolerance_is_constant_within_a_zoom_band() { - assert_eq!(viewport_highlight_tolerance(4.0), viewport_highlight_tolerance(3.0)); - assert_ne!(viewport_highlight_tolerance(4.0), viewport_highlight_tolerance(1.5)); - for s in [0.3, 1.0, 3.0, 4.0, 250.0] { - assert!(viewport_highlight_tolerance(s) <= 0.5 / s); - } - } - - #[test] - fn selection_area_fill_follows_the_area_effect_settings() { - let mut s = cadcraft_engine::Settings::default(); - // Window: ACI 150 at 25 %; crossing: ACI 100. - let rgb = |c: Rgb| Color32::from_rgb(c.0, c.1, c.2); - assert_eq!(selection_area_fill(&s, false), Some(rgb(cadcraft_color::aci_rgb(150)).gamma_multiply(0.25))); - assert_eq!(selection_area_fill(&s, true), Some(rgb(cadcraft_color::aci_rgb(100)).gamma_multiply(0.25))); - s.selectionareaopacity = 100; - assert_eq!(selection_area_fill(&s, false), Some(rgb(cadcraft_color::aci_rgb(150)))); - s.selectionarea = false; - assert_eq!(selection_area_fill(&s, false), None, "Indicate selection area off: no fill"); - } - - #[test] - fn viewport_highlight_is_tessellated_once_per_selection_and_zoom_band() { - use cadcraft_doc::{Circle, Common, Drawing, EntityKind, Space}; - let mut d = Drawing::new_imperial(); - let circle = |r| EntityKind::Circle(Circle { center: cadcraft_geom::Vec3::new(0.0, 0.0, 0.0), radius: r }); - let a = d.add(&Space::Model, Common::default(), circle(10.0)).unwrap(); - let b = d.add(&Space::Model, Common::default(), circle(5.0)).unwrap(); - let st = cadcraft_engine::DocState::new(d, "highlight", None); - let points = |l: &DisplayList| l.prims.iter().map(|p| l.points(p).len()).sum::(); - let (mut sel, mut hover) = (VpHighlight::default(), VpHighlight::default()); - sel.update(&st, &[a], 10.0); - let (first, segments) = (sel.list_gen, points(&sel.list)); - assert!(!sel.list.prims.is_empty()); - // Every frame of a pan, zooming within the 2x band, and the hover moving: the same list. - sel.update(&st, &[a], 10.0); - sel.update(&st, &[a], 12.0); - hover.update(&st, &[b], 12.0); - hover.update(&st, &[a], 12.0); - assert_eq!(sel.list_gen, first); - // Zooming in past the band tessellates finer. - sel.update(&st, &[a], 40.0); - assert_ne!(sel.list_gen, first); - assert!(points(&sel.list) > segments); - // A different selection is rebuilt. - let zoomed = sel.list_gen; - sel.update(&st, &[a, b], 40.0); - assert_ne!(sel.list_gen, zoomed); - assert_eq!(sel.list.prims.iter().map(|p| p.handle).collect::>().len(), 2); - } - - #[test] - fn prim_index_matches_a_scan_and_follows_list_rebuilds() { - let a = list(&[3, 1, 3, 2, 7, 3]); - let mut ix = PrimIndex::default(); - assert_eq!(ix.prims(1, &a, &hs(&[3])), vec![0, 2, 5]); - assert_eq!(ix.prims(1, &a, &hs(&[3])), prims_of(&a, &hs(&[3]))); - assert_eq!(ix.prims(1, &a, &hs(&[2, 1, 2])), vec![1, 3]); - assert!(ix.prims(1, &a, &hs(&[9])).is_empty()); - assert!(ix.prims(1, &a, &[]).is_empty()); - // A rebuilt list (new generation) is re-indexed. - let b = list(&[3, 9]); - assert_eq!(ix.prims(2, &b, &hs(&[9, 3])), vec![0, 1]); - } - - 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, source: egui::MouseWheelSource::Unknown, modifiers } - } - - /// Wheel events convert to points as egui does; ⌘/Ctrl (egui's pinch zoom) and ⇧ (a sideways - /// scroll) don't zoom here, and every notch of the frame counts (#660). - #[test] - fn wheel_zoom_reads_every_notch_of_the_frame() { - use egui::{Modifiers, MouseWheelUnit as U}; - let o = egui::InputOptions::default(); - let none = Modifiers::NONE; - assert_eq!(wheel_zoom_points(&[wheel(U::Line, 1.0, none)], &o, 500.0), o.line_scroll_speed); - assert_eq!(wheel_zoom_points(&[wheel(U::Line, -2.0, none)], &o, 500.0), -2.0 * o.line_scroll_speed); - assert_eq!(wheel_zoom_points(&[wheel(U::Point, 7.5, none)], &o, 500.0), 7.5); - assert_eq!(wheel_zoom_points(&[wheel(U::Page, 1.0, none)], &o, 500.0), 500.0); - // Three quick notches in one frame zoom three steps at once. - let three = [wheel(U::Line, 1.0, none), wheel(U::Line, 1.0, none), wheel(U::Line, 1.0, none)]; - assert_eq!(wheel_zoom_points(&three, &o, 500.0), 3.0 * o.line_scroll_speed); - assert_eq!(wheel_zoom_points(&[wheel(U::Line, 1.0, Modifiers::COMMAND)], &o, 500.0), 0.0); - assert_eq!(wheel_zoom_points(&[wheel(U::Line, 1.0, Modifiers::SHIFT)], &o, 500.0), 0.0); - assert_eq!(wheel_zoom_points(&[wheel(U::Line, f32::NAN, none)], &o, 500.0), 0.0); - assert_eq!(wheel_zoom_points(&[egui::Event::PointerGone], &o, 500.0), 0.0); - } - - /// The frame a notch arrives, egui's smoothed scroll holds only part of it (the rest trickles - /// in over later frames, which made quick notches lag), while the raw reading zooms the - /// whole notch at once (#660). - #[test] - fn a_wheel_notch_zooms_whole_in_its_own_frame() { - let ctx = egui::Context::default(); - let o = ctx.options(|o| o.input_options); - let input = egui::RawInput { events: vec![wheel(egui::MouseWheelUnit::Line, 1.0, egui::Modifiers::NONE)], ..Default::default() }; - let mut seen = (0.0, 0.0); - ctx.run_ui(input, |ui| { - seen = ui.input(|i| (i.smooth_scroll_delta.y, wheel_zoom_points(&i.raw.events, &o, 500.0))); - }) - .textures_delta - .clear(); - let (smoothed, raw) = seen; - assert_eq!(raw, o.line_scroll_speed, "the whole notch"); - assert!(smoothed < raw, "egui's smoothed delta lags behind: {smoothed} of {raw}"); - } - - /// The text painted by one frame of the app at `pointer`. - pub(crate) fn painted_text(app: &mut CadApp, ctx: &egui::Context, pointer: Pos2) -> String { - fn collect(shape: &Shape, text: &mut String) { - match shape { - Shape::Text(s) => { - text.push_str(s.galley.text()); - text.push('\n'); - } - Shape::Vec(shapes) => shapes.iter().for_each(|s| collect(s, text)), - _ => {} - } - } - let raw = egui::RawInput { - screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1400.0, 900.0))), - events: vec![egui::Event::PointerMoved(pointer)], - ..Default::default() - }; - let mut out = ctx.run_ui(raw, |ui| { - app.logic(ui.ctx()); - app.ui(ui); - }); - out.textures_delta.clear(); - let mut text = String::new(); - out.shapes.iter().for_each(|s| collect(&s.shape, &mut text)); - text - } - - /// The Dynamic Input distance box shows the cursor's live distance in the drawing's length - /// units (LUNITS/LUPREC), as DIST and the status bar do (#841). - #[test] - fn dynamic_input_distance_follows_the_drawing_units() { - let mut app = CadApp::new(cadcraft_engine::Session::new(), crate::Services::default()); - app.session.execute("units", &serde_json::json!({"lunits": 4, "luprec": 4})).unwrap(); - let ctx = egui::Context::default(); - painted_text(&mut app, &ctx, pos2(700.0, 450.0)); - app.session.cmdline("line").unwrap(); - app.session.input(Input::Point(Vec2::ZERO)).unwrap(); - let at = app.canvas.xf.unwrap().to_screen(Vec2::ZERO) + vec2(130.0, -70.0); - painted_text(&mut app, &ctx, at); - let text = painted_text(&mut app, &ctx, at); - let c = app.canvas.cursor.unwrap(); - let arch = |v: f64| cadcraft_engine::units::format_distance(v, 4, 4); - // The status bar shows X and Y in the same units: the distance must be neither. - assert!(arch(c.len()) != arch(c.x) && arch(c.len()) != arch(c.y), "{c:?}"); - assert!(text.contains(&arch(c.len())), "no {} in:\n{text}", arch(c.len())); - // Without the value boxes (a window corner pending) the tooltip shows the distance too. - app.session.pending_window = Some(cadcraft_engine::PendingWindow { corner: Vec2::ZERO, during_command: true }); - let text = painted_text(&mut app, &ctx, at); - assert!(text.contains(&format!(" {} <", arch(c.len()))), "no {} in:\n{text}", arch(c.len())); - } -} +pub(crate) mod tests; /// The Block Editor's tint, base point and bar. mod blockedit; @@ -2955,64 +939,3 @@ mod compare; /// Background rebuilds of the display list for a new view. mod rebuild; - -#[cfg(test)] -mod hatch_pick_tests { - use cadcraft_engine::Session; - use cadcraft_geom::Vec2; - use serde_json::json; - - use crate::{CadApp, Services}; - - #[test] - fn hatch_at_finds_a_hatch_under_the_cursor_only() { - let mut app = CadApp::new(Session::new(), Services::default()); - app.session.execute("rectang", &json!({ "p1": [0, 0], "p2": [10, 10] })).unwrap(); - let hx = app.session.execute("hatch", &json!({ "points": [[5, 5]], "pattern": "SOLID" })).unwrap()["handle"].as_str().unwrap().to_string(); - app.session.execute("line", &json!({ "points": [[20, 0], [30, 0]] })).unwrap(); - assert_eq!(super::hatch_at(&app, Vec2::new(5.0, 5.0), 10.0).map(|h| h.hex()), Some(hx)); - assert_eq!(super::hatch_at(&app, Vec2::new(25.0, 0.0), 10.0), None, "a line is not a hatch"); - assert_eq!(super::hatch_at(&app, Vec2::new(60.0, 60.0), 10.0), None); - } - - #[test] - fn double_clicking_a_hatch_opens_the_hatch_edit_dialog() { - let mut app = CadApp::new(Session::new(), Services::default()); - app.session.execute("rectang", &json!({ "p1": [0, 0], "p2": [10, 10] })).unwrap(); - let hx = app.session.execute("hatch", &json!({ "points": [[5, 5]], "pattern": "SOLID" })).unwrap()["handle"].as_str().unwrap().to_string(); - let _ = app.session.zoom_extents(); - let ctx = egui::Context::default(); - let screen = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 1000.0))); - let frame = |app: &mut CadApp, events: Vec, time: f64| { - app.logic(&ctx); - let input = egui::RawInput { screen_rect: screen, events, time: Some(time), ..Default::default() }; - let mut out = ctx.run_ui(input, |ui| app.ui(ui)); - out.textures_delta.clear(); - }; - frame(&mut app, Vec::new(), 0.0); - let at = app.canvas.xf.unwrap().to_screen(Vec2::new(5.0, 5.0)); - let button = - |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::default() }; - frame(&mut app, vec![egui::Event::PointerMoved(at)], 0.1); - frame(&mut app, vec![button(true), button(false)], 0.2); - frame(&mut app, vec![button(true), button(false)], 0.3); - frame(&mut app, Vec::new(), 0.4); - assert_eq!(app.ui.dialog.as_deref(), Some("hatch")); - assert_eq!(app.hatch_dlg.as_ref().map(|s| s.edit.iter().map(|h| h.hex()).collect::>()), Some(vec![hx])); - // The double-click (at the hatch's centre grip) only opens the dialog: no grip edit. - 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/compare.rs b/crates/ui-egui/src/canvas/compare.rs index c545a1d28..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; @@ -29,6 +30,23 @@ fn id() -> egui::Id { 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. pub(super) fn paint(app: &mut CadApp, ctx: &egui::Context, p: &egui::Painter, xf: &Xf, rect: Rect) { let t = Tokens::get(); @@ -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(); diff --git a/crates/ui-egui/src/canvas/cursor.rs b/crates/ui-egui/src/canvas/cursor.rs new file mode 100644 index 000000000..427445561 --- /dev/null +++ b/crates/ui-egui/src/canvas/cursor.rs @@ -0,0 +1,241 @@ +use super::*; + +pub(super) fn draw_snap_marker(p: &egui::Painter, at: Pos2, hit: &SnapHit) { + let t = Tokens::get(); + let s = crate::theme::snap_marker_half(); + let st = Stroke::new(2.0, t.snap); + use snap::mode; + match hit.mode { + mode::END => { + p.rect_stroke(Rect::from_center_size(at, vec2(2.0 * s, 2.0 * s)), 0.0, st, egui::StrokeKind::Middle); + } + mode::MID => { + p.add(Shape::closed_line(vec![at + vec2(0.0, -s), at + vec2(s, s * 0.8), at + vec2(-s, s * 0.8)], st)); + } + mode::CEN | mode::GCEN => { + p.circle_stroke(at, s, st); + } + mode::QUA => { + p.add(Shape::closed_line(vec![at + vec2(0.0, -s), at + vec2(s, 0.0), at + vec2(0.0, s), at + vec2(-s, 0.0)], st)); + } + mode::INT | mode::APP => { + p.line_segment([at + vec2(-s, -s), at + vec2(s, s)], st); + p.line_segment([at + vec2(s, -s), at + vec2(-s, s)], st); + if hit.mode == mode::APP { + p.rect_stroke(Rect::from_center_size(at, vec2(2.0 * s, 2.0 * s)), 0.0, st, egui::StrokeKind::Middle); + } + } + mode::PER => { + p.line(vec![at + vec2(-s, -s), at + vec2(-s, s), at + vec2(s, s)], st); + p.line(vec![at + vec2(-s, 0.0), at + vec2(0.0, 0.0), at + vec2(0.0, s)], st); + } + mode::TAN => { + p.circle_stroke(at, s * 0.8, st); + p.line_segment([at + vec2(-s, -s), at + vec2(s, -s)], st); + } + mode::NOD => { + p.circle_stroke(at, s, st); + p.line_segment([at + vec2(-s, -s), at + vec2(s, s)], st); + p.line_segment([at + vec2(s, -s), at + vec2(-s, s)], st); + } + mode::INS => { + p.line( + vec![ + at + vec2(-s, -s), + at + vec2(0.0, -s), + at + vec2(0.0, 0.0), + at + vec2(s, 0.0), + at + vec2(s, s), + at + vec2(-s, s), + at + vec2(-s, -s), + ], + st, + ); + } + _ => { + p.line(vec![at + vec2(-s, -s), at + vec2(s, -s), at + vec2(-s, s), at + vec2(s, s), at + vec2(-s, -s)], st); + } + } + p.text(at + vec2(12.0, 12.0), egui::Align2::LEFT_TOP, crate::i18n::t(hit.name), crate::theme::small(), Color32::BLACK); + let galley = p.layout_no_wrap(crate::i18n::t(hit.name).to_string(), crate::theme::small(), Color32::BLACK); + let r = Rect::from_min_size(at + vec2(10.0, 10.0), galley.size() + vec2(6.0, 2.0)); + p.rect_filled(r, 2.0, Color32::from_rgb(0xff, 0xff, 0xe1)); + p.galley(r.min + vec2(3.0, 1.0), galley, Color32::BLACK); +} + +/// Acquired tracking points (small `+`, `//` for a Parallel line), the dotted paths the cursor +/// is on and the tooltip naming them ("Endpoint: 5.0000 < 90°"). +pub(super) fn draw_tracking(p: &egui::Painter, xf: &Xf, acquired: &[snap::tracking::Acquired], track: Option<&snap::tracking::Track>, far: f64) { + use snap::tracking::{Acquired, PathShape}; + let t = Tokens::get(); + let st = Stroke::new(1.0, t.snap); + for a in acquired { + let c = xf.to_screen(a.at()); + if let Acquired::Parallel { .. } = a { + p.line_segment([c + vec2(-5.0, 3.0), c + vec2(-1.0, -5.0)], st); + p.line_segment([c + vec2(1.0, 3.0), c + vec2(5.0, -5.0)], st); + } else { + p.line_segment([c + vec2(-4.0, 0.0), c + vec2(4.0, 0.0)], st); + p.line_segment([c + vec2(0.0, -4.0), c + vec2(0.0, 4.0)], st); + } + } + let Some(tr) = track else { return }; + let mut tips = Vec::new(); + for path in &tr.paths { + // Around the cursor only (a path's origin may be far off screen). + let pts: Vec = match path.shape { + PathShape::Ray { origin, dir } => { + let back = (tr.point - origin).dot(dir).clamp(0.0, far); + vec![xf.to_screen(tr.point - dir * back), xf.to_screen(tr.point + dir * far)] + } + PathShape::Line { dir, .. } => vec![xf.to_screen(tr.point - dir * far), xf.to_screen(tr.point + dir * far)], + PathShape::Circle { center, radius, .. } => { + let (a0, span) = (center.angle_to(tr.point), (far / radius).min(std::f64::consts::PI)); + (0..=96).map(|i| xf.to_screen(Vec2::polar(center, radius, a0 - span + span * f64::from(i) / 48.0))).collect() + } + PathShape::Ellipse { center, major, ratio, .. } => { + let e = cadcraft_geom::Ellipse::full(center, major, ratio); + let (t0, span) = (e.param_of(tr.point), (far / (major.len() * ratio.min(1.0))).min(std::f64::consts::PI)); + (0..=96).map(|i| xf.to_screen(e.at_param(t0 - span + span * f64::from(i) / 48.0))).collect() + } + }; + p.extend(Shape::dotted_line(&pts, t.snap, 6.0, 1.0)); + let (distance, angle) = ( + cadcraft_engine::units::format_distance(path.from.dist(tr.point), 2, 4), + (path.from.angle_to(tr.point).to_degrees().round() as i64).rem_euclid(360), + ); + tips.push(if path.name == "Polar" { + crate::tf!("Polar: {distance} < {angle}°", distance = distance, angle = angle) + } else { + crate::tf!("{name}: {distance} < {angle}°", name = crate::i18n::t(path.name), distance = distance, angle = angle) + }); + } + if let Some(o) = tr.object { + tips.push(crate::i18n::t(o).to_string()); + } + let at = xf.to_screen(tr.point); + if tr.paths.len() > 1 || tr.object.is_some() { + p.line_segment([at + vec2(-4.0, -4.0), at + vec2(4.0, 4.0)], st); + p.line_segment([at + vec2(4.0, -4.0), at + vec2(-4.0, 4.0)], st); + } + tooltip(p, at + vec2(16.0, 18.0), &tips.join(", ")); +} + +/// The rubber-band and snap-adjusted cursor point for the current prompt (the engine's +/// `Session::snap_cursor`: object snaps, acquiring tracking points, grid, tracking paths, ortho). +pub(super) fn effective_point(app: &mut CadApp, raw: Vec2, xf: &Xf, ctx: &egui::Context) -> Vec2 { + let prompt = app.session.current_prompt(); + let wants_point = prompt.as_ref().is_some_and(cadcraft_engine::pointmod::accepts); + let base = prompt.as_ref().and_then(|p| p.base); + let deferred = prompt.as_ref().is_some_and(|p| p.deferred); + let corner = prompt.as_ref().is_some_and(|p| p.corner); + app.canvas.snap = None; + app.canvas.track = None; + if !wants_point || app.session.pending_window.is_some() { + if !wants_point { + app.session.tracking.clear(); + } + return raw; + } + let (now, shift) = ctx.input(|i| (i.time, i.modifiers.shift)); + let q = + snap::tracking::CursorQuery { base, deferred, corner, aperture: app.session.settings.aperture / xf.scale, tol: 6.0 / xf.scale, now, shift }; + let r = app.session.snap_cursor(raw, &q); + if r.wait { + ctx.request_repaint_after(std::time::Duration::from_secs_f64(snap::tracking::DWELL / 4.0)); + } + app.canvas.snap = r.snap; + app.canvas.track = r.track; + r.point +} + +fn tooltip(p: &egui::Painter, at: Pos2, text: &str) { + let galley = p.layout_no_wrap(text.to_string(), crate::theme::small(), Color32::from_rgb(0x20, 0x20, 0x20)); + let r = Rect::from_min_size(at, galley.size() + vec2(8.0, 4.0)); + p.rect_filled(r, 2.0, Color32::from_rgb(0xf4, 0xf4, 0xf4)); + p.galley(r.min + vec2(4.0, 2.0), galley, Color32::BLACK); +} + +/// Half the side of the pick box: the pick aperture (PICKBOX × 1.5 pixels, as `select::pick` is +/// called with), so the square shows what a click hits. `None` when PICKBOX is 0 (no box). +/// The candidate Tab has cycled to, if cycling: a Tab press starts or advances it, and the +/// cursor leaving the aperture where it started ends it. +fn snap_cycle(app: &mut CadApp, raw: Vec2, ap: f64) -> Option { + let tab = std::mem::take(&mut app.canvas.snap_tab); + let c = &mut app.canvas.snap_cycle; + if c.is_some_and(|c| c.anchor.dist(raw) > ap) { + *c = None; + } + if tab { + *c = Some(c.map_or(SnapCycle { anchor: raw, index: 1 }, |c| SnapCycle { index: c.index + 1, ..c })); + } + c.map(|c| c.index) +} + +/// The cursor under a one-pick object snap override (`Session::snap_override`: END, NON…) or +/// while Tab cycles the candidates: the snap of that mode (or the cycled candidate) replaces +/// what the running snaps found; with none, a running snap no longer holds the cursor. +pub(super) fn override_cursor(app: &mut CadApp, raw: Vec2, xf: &Xf) { + let over = app.session.snap_override(); + let ap = app.session.settings.aperture / xf.scale; + let cycle = snap_cycle(app, raw, ap); + let prompt = app.session.current_prompt(); + let wants_point = prompt.as_ref().is_some_and(cadcraft_engine::pointmod::accepts); + if (over.is_none() && cycle.is_none()) || !wants_point || app.session.pending_window.is_some() { + return; + } + let base = prompt.as_ref().and_then(|p| p.base); + let deferred = prompt.as_ref().is_some_and(|p| p.deferred); + let s = &app.session.settings; + let osmode = over.unwrap_or(s.osmode) | if s.osnaphatch { snap::mode::HATCH } else { 0 }; + let Ok(st) = app.session.state() else { return }; + let hit = match cycle { + Some(i) => { + let cands = cadcraft_engine::pointmod::snap_candidates(&st.doc, &st.edit_space(), raw, ap, osmode, base, deferred); + cands.get(i % cands.len().max(1)).copied() + } + None => snap::osnap(&st.doc, &st.edit_space(), raw, ap, osmode, base, deferred), + }; + let p = match (hit, app.canvas.snap) { + (Some(h), _) => { + app.canvas.snap = Some(h); + h.point + } + (None, Some(_)) => { + app.canvas.snap = None; + raw + } + (None, None) => return, + }; + app.canvas.cursor = Some(p); + app.session.cursor = p; + app.session.cursor_deferred = app.canvas.snap.and_then(|h| h.deferred); +} + +fn pickbox_half(pickbox: f64) -> Option { + let half = (pickbox * 1.5) as f32; + (half.is_finite() && half >= 1.0).then_some(half) +} + +pub(super) fn draw_crosshair(app: &CadApp, p: &egui::Painter, rect: Rect, at: Pos2, pickbox: bool, ink: Color32) { + let st = Stroke::new(1.0, ink); + let len = (rect.height() * app.session.settings.cursorsize as f32 / 100.0).max(12.0); + let pb = if pickbox { pickbox_half(app.session.settings.pickbox) } else { None }; + let gap = pb.unwrap_or(0.0); + // Along the isometric plane's axes in isometric drafting. + if app.session.settings.isodraft { + for a in cadcraft_engine::snap::iso::cursor_axes(&app.session.settings) { + let dir = vec2(a.cos() as f32, -(a.sin() as f32)); + p.line_segment([at - dir * len, at - dir * gap], st); + p.line_segment([at + dir * gap, at + dir * len], st); + } + } else { + p.line_segment([pos2(at.x - len, at.y), pos2(at.x - gap, at.y)], st); + p.line_segment([pos2(at.x + gap, at.y), pos2(at.x + len, at.y)], st); + p.line_segment([pos2(at.x, at.y - len), pos2(at.x, at.y - gap)], st); + p.line_segment([pos2(at.x, at.y + gap), pos2(at.x, at.y + len)], st); + } + if let Some(pb) = pb { + p.rect_stroke(Rect::from_center_size(at, vec2(pb * 2.0, pb * 2.0)), 0.0, st, egui::StrokeKind::Middle); + } +} diff --git a/crates/ui-egui/src/canvas/cycle.rs b/crates/ui-egui/src/canvas/cycle.rs new file mode 100644 index 000000000..f32123c8d --- /dev/null +++ b/crates/ui-egui/src/canvas/cycle.rs @@ -0,0 +1,140 @@ +use super::grips::grip_at; +use super::*; + +/// The objects under a click that selection cycling lets the user choose between: where the click +/// was, each object with its row's label (type and layer), Shift held, and the rows as drawn. +#[derive(Clone, Debug)] +pub(super) struct CycleList { + at: Pos2, + pub(super) items: Vec<(Handle, String, String)>, + shift: bool, + pub(super) rows: Vec, +} + +impl CycleList { + pub(super) fn row_at(&self, p: Pos2) -> Option { + self.rows.iter().zip(&self.items).find(|(r, _)| r.contains(p)).map(|(_, (h, _, _))| *h) + } +} + +/// A click while selection cycling's list is open picks the row under it (anywhere else just +/// closes the list); otherwise a click over overlapping objects that a pick would choose between +/// opens the list (SELECTIONCYCLING 2). True when the click was used up here. +pub(super) fn cycle_click(app: &mut CadApp, xf: &Xf, hover: Option, world: Option, shift: bool) -> bool { + // Only where a click picks objects; a click that places a point goes through. + let picks = app.session.click_picks(); + if let Some((_, hs, i)) = app.canvas.hover_cycle.take() + && picks + && let Some(h) = hs.get(i % hs.len().max(1)) + { + if let Err(e) = app.session.pick_cycled(*h, shift) { + app.session.echo(e.to_string()); + } + return true; + } + if let Some(list) = app.canvas.cycle.take() { + let Some(h) = hover.and_then(|hp| list.row_at(hp)).filter(|_| picks) else { return picks }; + if let Err(e) = app.session.pick_cycled(h, list.shift) { + app.session.echo(e.to_string()); + } + return true; + } + let (Some(hp), Some(w)) = (hover, world) else { return false }; + if crate::dyngrips::at(app, xf, hover).is_some() || grip_at(app, xf, hover).is_some() { + return false; + } + let hs = app.session.cycling_candidates(w); + let Ok(d) = app.session.doc() else { return false }; + let items: Vec<_> = + hs.into_iter().filter_map(|h| d.entity(h).map(|e| (h, crate::i18n::t(e.kind.type_name()).to_string(), e.common.layer.clone()))).collect(); + if items.len() < 2 { + return false; + } + app.canvas.cycle = Some(CycleList { at: hp, items, shift, rows: Vec::new() }); + true +} + +/// Shift+Space over overlapping objects highlights the next one under the cursor, and a click +/// picks the highlighted one, whether selection cycling is on or not; moving the cursor off the +/// pick box where it started ends it. +pub(super) fn shift_space_cycle(app: &mut CadApp, ui: &egui::Ui, hover: Option, world: Option) { + let pick_px = (app.session.settings.pickbox.max(1.0) * 1.5) as f32; + let (Some(hp), Some(w)) = (hover, world) else { + app.canvas.hover_cycle = None; + return; + }; + if app.canvas.hover_cycle.as_ref().is_some_and(|(a, _, _)| a.distance(hp) > pick_px) { + app.canvas.hover_cycle = None; + } + let pressed = ui + .input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Key { key: egui::Key::Space, pressed: true, modifiers, .. } if modifiers.shift))); + if pressed { + // With the list open Shift+Space does nothing, and isn't Enter either. + let next = match app.canvas.hover_cycle.take() { + _ if app.canvas.cycle.is_some() => None, + Some((a, hs, i)) => Some((a, hs, i + 1)), + None => Some((hp, app.session.pick_candidates(w), 1)).filter(|(_, hs, _)| hs.len() > 1), + }; + if next.is_some() || app.canvas.cycle.is_some() { + // Space is Enter at the command line; this one only steps the highlight. + ui.ctx().input_mut(|i| { + i.events.retain(|e| !matches!(e, egui::Event::Key { key: egui::Key::Space, .. }) && !matches!(e, egui::Event::Text(t) if t == " ")) + }); + } + app.canvas.hover_cycle = next; + } + if let Some((_, hs, i)) = &app.canvas.hover_cycle { + app.canvas.hover = hs.get(i % hs.len().max(1)).copied(); + app.canvas.hover_at = None; + } +} + +/// Selection cycling's badge beside the cursor: two overlapping squares, objects overlap here. +pub(super) fn cycle_badge(p: &egui::Painter, at: Pos2) { + let t = Tokens::get(); + for off in [vec2(14.0, -22.0), vec2(19.0, -17.0)] { + let r = Rect::from_min_size(at + off, vec2(9.0, 9.0)); + p.rect_filled(r, 1.0, t.cmd_bg); + p.rect_stroke(r, 1.0, Stroke::new(1.0, t.text), egui::StrokeKind::Inside); + } +} + +/// Whether `p` lies on a menu the canvas paints itself — the selection cycling list's rows or the +/// Dynamic Input options menu's rows. They are menus, so the OS cursor stays visible over them, but +/// they share the canvas's layer: `layer_id_at` reports the drawing, and only their rectangles can +/// tell them apart from it (#1701). +pub(super) fn over_canvas_menu(app: &CadApp, p: Pos2) -> bool { + app.canvas.cycle.as_ref().is_some_and(|c| c.rows.iter().any(|r| r.expand(2.0).contains(p))) + || app.cmd.option_rows.iter().any(|(r, _)| r.expand(2.0).contains(p)) +} + +/// The selection cycling list beside the click that opened it: a row per object, its type and +/// layer, the row under the pointer highlighted; the rows are kept for clicks. +pub(super) fn cycle_list(app: &mut CadApp, p: &egui::Painter) { + let Some(list) = app.canvas.cycle.as_mut() else { return }; + let t = Tokens::get(); + let font = crate::theme::small(); + let galleys: Vec<_> = list + .items + .iter() + .map(|(_, ty, layer)| (p.layout_no_wrap(ty.clone(), font.clone(), t.text), p.layout_no_wrap(layer.clone(), font.clone(), t.text_dim))) + .collect(); + let row_h = galleys.iter().map(|(a, _)| a.size().y).fold(12.0, f32::max) + 6.0; + let ty_w = galleys.iter().map(|(a, _)| a.size().x).fold(40.0, f32::max); + let layer_w = galleys.iter().map(|(_, b)| b.size().x).fold(20.0, f32::max); + let w = ty_w + layer_w + 36.0; + let m = Rect::from_min_size(list.at + vec2(14.0, 14.0), vec2(w, row_h * galleys.len() as f32 + 4.0)); + p.rect_filled(m, 3.0, t.cmd_bg); + p.rect_stroke(m, 3.0, Stroke::new(1.0, t.cmd_border), egui::StrokeKind::Inside); + let hover = app.canvas.hover; + list.rows.clear(); + for (i, ((ty, layer), (h, _, _))) in galleys.into_iter().zip(&list.items).enumerate() { + let row = Rect::from_min_size(pos2(m.left() + 2.0, m.top() + 2.0 + row_h * i as f32), vec2(w - 4.0, row_h)); + if hover == Some(*h) { + p.rect_filled(row, 2.0, t.cmd_keyword_hover); + } + p.galley(pos2(row.left() + 8.0, row.center().y - ty.size().y / 2.0), ty, t.text); + p.galley(pos2(row.left() + 20.0 + ty_w, row.center().y - layer.size().y / 2.0), layer, t.text_dim); + list.rows.push(row); + } +} diff --git a/crates/ui-egui/src/canvas/draw.rs b/crates/ui-egui/src/canvas/draw.rs new file mode 100644 index 000000000..0c11eb635 --- /dev/null +++ b/crates/ui-egui/src/canvas/draw.rs @@ -0,0 +1,423 @@ +use super::list::preview_options; +use super::*; + +fn color32(c: Rgb) -> Color32 { + Color32::from_rgb(c.0, c.1, c.2) +} + +/// A primitive's colour on `bg`, with its transparency. +fn prim_color32(prim: &cadcraft_render::DPrim, bg: Rgb) -> Color32 { + let c = prim.display_rgb(bg); + Color32::from_rgba_unmultiplied(c.0, c.1, c.2, prim.alpha()) +} + +fn clip_infinite(xf: &Xf, base: Vec2, dir: Vec2, ray: bool) -> Option<[Pos2; 2]> { + let wb = xf.world_bounds(); + let far = (wb.width() + wb.height()) * 2.0 + base.dist(wb.center()); + let a = if ray { base } else { base - dir * far }; + let b = base + dir * far; + Some([xf.to_screen(a), xf.to_screen(b)]) +} + +/// Draw a display list. +pub(crate) fn draw_list(p: &egui::Painter, xf: &Xf, list: &DisplayList, bg: Rgb, lwdisplay: bool) { + let vis = xf.world_bounds(); + let mut shapes: Vec = Vec::with_capacity(list.prims.len()); + let px = 1.0 / xf.scale; + for prim in &list.prims { + let col = prim_color32(prim, bg); + let pts = list.points(prim); + match prim.kind { + Kind::Polyline => { + let bb = Bounds2::from_points(pts.iter().copied()); + if !bb.intersects(&vis) { + continue; + } + let w = if lwdisplay && prim.lw > 0.0 { (prim.lw * 3.78).clamp(1.0, 12.0) } else { 1.0 }; + // Skip sub-pixel objects except as a dot. + if bb.width() < px && bb.height() < px { + shapes.push(Shape::rect_filled(Rect::from_center_size(xf.to_screen(bb.center()), vec2(1.0, 1.0)), 0.0, col)); + continue; + } + let screen: Vec = pts.iter().map(|q| xf.to_screen(*q)).collect(); + if screen.len() == 2 { + if let [a, b] = screen.as_slice() { + shapes.push(Shape::line_segment([*a, *b], Stroke::new(w, col))); + } + } else { + shapes.push(Shape::line(screen, Stroke::new(w, col))); + } + } + Kind::Tris | Kind::Mask => { + // A wipeout's mask hides what was drawn before it with the background colour. + let col = if prim.kind == Kind::Mask { color32(bg) } else { col }; + let mut mesh = egui::Mesh::default(); + for tri in pts.chunks(3) { + if let [a, b, c] = tri { + let i = mesh.vertices.len() as u32; + for q in [a, b, c] { + mesh.colored_vertex(xf.to_screen(*q), col); + } + mesh.add_triangle(i, i + 1, i + 2); + } + } + if !mesh.vertices.is_empty() { + shapes.push(Shape::mesh(mesh)); + } + } + Kind::Point => { + if let Some(q) = pts.first() + && vis.contains(*q) + { + shapes.push(Shape::rect_filled(Rect::from_center_size(xf.to_screen(*q), vec2(1.5, 1.5)), 0.0, col)); + } + } + Kind::Infinite { ray } => { + if let (Some(b), Some(d)) = (pts.first(), pts.get(1)) + && let Some(seg) = clip_infinite(xf, *b, *d, ray) + { + shapes.push(Shape::line_segment(seg, Stroke::new(1.0, col))); + } + } + } + } + p.extend(shapes); +} + +/// Draw the selection highlight (as `look` says) through the GPU: its vertex data is +/// rebuilt only when the display list, the selection or the vertex origin changes. Dashing every +/// selected line and tessellating it as egui shapes each frame made large selections sluggish. +/// `prims`: the selection's primitives in `list`, asked for only when the vertex data is rebuilt. +/// `stencil`: [`crate::gpu::Stencil::Inside`] keeps it inside a clipped viewport's outline. +#[allow(clippy::too_many_arguments)] +pub(super) fn draw_highlight_gpu( + p: &egui::Painter, + xf: &Xf, + list: &DisplayList, + list_gen: u64, + prims: impl FnOnce() -> Vec, + sel: &[Handle], + color: Color32, + look: Look, + hl: (&mut Option, &crate::gpu::MeshSlot, &mut Vec2, &mut Vec), + stencil: crate::gpu::Stencil, +) { + let (hl_key, hl_slot, hl_origin, hl_infinite) = hl; + let origin = crate::gpu::choose_origin(&list.bounds, xf.center, f64::from(xf.rect.height()) / xf.scale.max(1e-300)); + let key = { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + (list_gen, sel, origin.x.to_bits(), origin.y.to_bits(), color.to_array(), look.width.to_bits(), look.dashed).hash(&mut h); + h.finish() + }; + if *hl_key != Some(key) { + let prims = prims(); + let mesh = crate::gpu::build_highlight_mesh(list, &prims, origin, color.to_array(), look.width, look.dashed); + *hl_infinite = prims.into_iter().filter(|&i| list.prims.get(i).is_some_and(|p| matches!(p.kind, Kind::Infinite { .. }))).collect(); + if let Ok(mut slot) = hl_slot.lock() { + *slot = Some(mesh); + } + *hl_key = Some(key); + *hl_origin = origin; + } + let center = xf.rect.center(); + p.add(egui_wgpu::Callback::new_paint_callback( + xf.rect, + crate::gpu::CanvasCallback { + key, + slot: hl_slot.clone(), + offset: [(hl_origin.x - xf.center.x) as f32, (hl_origin.y - xf.center.y) as f32], + scale: xf.scale as f32, + center: [center.x, center.y], + layer: crate::gpu::LAYER_HIGHLIGHT, + xform: crate::gpu::IDENTITY_XFORM, + dash: if look.dashed { 10.0 } else { 0.0 }, + stencil, + }, + )); +} + +/// Draw a MOVE/COPY/ROTATE/SCALE preview the way the drawing itself is drawn: the dragged +/// objects are tessellated and uploaded once (again only when the drawing, the selection or the +/// zoom band changes), and each frame only the transform in the view uniform moves. Rebuilding +/// the transformed copies as egui shapes every frame was slow with heavy selections and could +/// overflow the GPU buffer limit. +/// `clipboard` is the session's clipboard and its serial (dragged when `drag.clipboard`, by PASTE). +/// `transparency`: TRANSPARENCYDISPLAY (dragged objects keep their transparency). +#[allow(clippy::too_many_arguments)] +pub(super) fn draw_drag_gpu( + c: &mut CanvasState, + p: &egui::Painter, + xf: &Xf, + doc: &cadcraft_doc::Drawing, + drag: &cadcraft_engine::Drag, + clipboard: (&[cadcraft_doc::Entity], u64), + bg: Rgb, + transparency: bool, +) { + // Tessellate for a power-of-two zoom band so zooming mid-drag rebuilds only every 2x. + let band = xf.scale.max(1e-300).log2().round() as i32; + let key = { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + (c.key, band, bg.0, bg.1, bg.2, &drag.handles, transparency).hash(&mut h); + if drag.clipboard { + clipboard.1.hash(&mut h); + } + h.finish() + }; + if c.drag_key != Some(key) { + let band_scale = 2f64.powi(band); + let opts = cadcraft_render::Options { transparency, ..preview_options(band_scale) }; + let list = if drag.clipboard { + cadcraft_render::build_entities(doc, clipboard.0.iter(), &opts) + } else { + cadcraft_render::build_entities(doc, drag.handles.iter().filter_map(|h| doc.entity(*h)).map(|e| e.as_ref()), &opts) + }; + let origin = if list.bounds.is_empty() { Vec2::ZERO } else { list.bounds.center() }; + let mesh = crate::gpu::build_mesh(&list, origin, bg, false); + if let Ok(mut slot) = c.drag_slot.lock() { + *slot = Some(mesh); + } + c.drag_key = Some(key); + c.drag_origin = origin; + } + // Positions are relative to the origin o: M(o + p) = L·p + (M(o) − o). + let m = drag.matrix; + let o = c.drag_origin; + let t = m.apply(o) - o; + let center = xf.rect.center(); + p.add(egui_wgpu::Callback::new_paint_callback( + xf.rect, + crate::gpu::CanvasCallback { + key, + slot: c.drag_slot.clone(), + offset: [(o.x - xf.center.x) as f32, (o.y - xf.center.y) as f32], + scale: xf.scale as f32, + center: [center.x, center.y], + layer: crate::gpu::LAYER_DRAG, + xform: [m.a as f32, m.b as f32, m.c as f32, m.d as f32, t.x as f32, t.y as f32], + dash: 0.0, + stencil: crate::gpu::Stencil::Off, + }, + )); +} + +/// Identifies the GPU vertex data of list `key` / `list_gen` converted at `origin` against `bg`. +pub(super) fn list_mesh_key(key: &ListKey, list_gen: u64, origin: Vec2, bg: Rgb, lwdisplay: bool) -> u64 { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + (key, list_gen, origin.x.to_bits(), origin.y.to_bits(), bg.0, bg.1, bg.2, lwdisplay).hash(&mut h); + h.finish() +} + +/// Draw the display list through the GPU paint callback (uploading it when it changed), plus +/// the infinite lines, which are clipped to the view on the CPU every frame. +pub(super) fn draw_list_gpu(c: &mut CanvasState, p: &egui::Painter, xf: &Xf, bg: Rgb, lwdisplay: bool) { + let Some(list) = &c.list else { return }; + let origin = crate::gpu::choose_origin(&list.bounds, xf.center, f64::from(xf.rect.height()) / xf.scale.max(1e-300)); + let key = list_mesh_key(&c.key, c.list_gen, origin, bg, lwdisplay); + if c.mesh_key != Some(key) { + let t0 = crate::now_ms(); + let mesh = crate::gpu::build_mesh(list, origin, bg, lwdisplay); + if let Ok(mut slot) = c.mesh_slot.lock() { + *slot = Some(mesh); + } + c.infinite = list.prims.iter().enumerate().filter(|(_, pr)| matches!(pr.kind, Kind::Infinite { .. })).map(|(i, _)| i).collect(); + c.mesh_key = Some(key); + c.mesh_origin = origin; + c.mesh_ms = crate::now_ms() - t0; + } + let center = xf.rect.center(); + p.add(egui_wgpu::Callback::new_paint_callback( + xf.rect, + crate::gpu::CanvasCallback { + key, + slot: c.mesh_slot.clone(), + offset: [(c.mesh_origin.x - xf.center.x) as f32, (c.mesh_origin.y - xf.center.y) as f32], + scale: xf.scale as f32, + center: [center.x, center.y], + layer: crate::gpu::LAYER_DRAWING, + xform: crate::gpu::IDENTITY_XFORM, + dash: 0.0, + stencil: crate::gpu::Stencil::Off, + }, + )); + let mut shapes = Vec::new(); + for prim in c.infinite.iter().filter_map(|i| list.prims.get(*i)) { + let pts = list.points(prim); + if let (Kind::Infinite { ray }, Some(b), Some(d)) = (prim.kind, pts.first(), pts.get(1)) + && let Some(seg) = clip_infinite(xf, *b, *d, ray) + { + shapes.push(Shape::line_segment(seg, Stroke::new(1.0, prim_color32(prim, bg)))); + } + } + p.extend(shapes); +} + +/// How a highlight is drawn: lines `width` points wide, and either solid with fills filled or +/// dashed with fills outlined. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct Look { + pub width: f32, + pub dashed: bool, +} + +impl Look { + /// Selected objects (HIGHLIGHT, SELECTIONEFFECT); `None`: not highlighted. + pub(crate) fn selection(s: &cadcraft_engine::Settings) -> Option { + s.highlight.then_some(Look { width: 1.5, dashed: s.selectioneffect == 0 }) + } + /// The object under the cursor (PREVIEWEFFECT: 0 dashed, 1 thicker, 2 both). + pub(crate) fn preview(s: &cadcraft_engine::Settings) -> Look { + Look { width: if s.previeweffect == 0 { 1.0 } else { 2.0 }, dashed: s.previeweffect != 1 } + } +} + +/// Draw selected/hovered entities as highlight overlays: `prims` are indices into `list` (from +/// [`PrimIndex::prims`], [`prims_of`], or all of a [`VpHighlight`] list), redrawn in `color` as `look` says. A wipeout is always +/// outlined, since filling it would hide the drawing under it. +pub(crate) fn draw_highlight(p: &egui::Painter, xf: &Xf, list: &DisplayList, prims: &[usize], color: Color32, look: Look) { + let stroke = Stroke::new(look.width, color); + let line = |pts: Vec, out: &mut Vec| { + if look.dashed { + out.extend(Shape::dashed_line(&pts, stroke, 6.0, 4.0)); + } else { + out.push(Shape::line(pts, stroke)); + } + }; + let mut shapes = Vec::new(); + let mut fill = egui::Mesh::default(); + let fill_color = color.gamma_multiply(crate::gpu::HIGHLIGHT_FILL_ALPHA); + for prim in prims.iter().filter_map(|&i| list.prims.get(i)) { + let pts = list.points(prim); + match prim.kind { + Kind::Polyline => line(pts.iter().map(|q| xf.to_screen(*q)).collect(), &mut shapes), + Kind::Infinite { ray } => { + if let (Some(b), Some(d)) = (pts.first(), pts.get(1)) + && let Some(seg) = clip_infinite(xf, *b, *d, ray) + { + line(seg.to_vec(), &mut shapes); + } + } + Kind::Point => { + if let Some(q) = pts.first() { + shapes.push(Shape::circle_stroke(xf.to_screen(*q), 3.0, Stroke::new(1.0, color))); + } + } + Kind::Tris if !look.dashed => { + for q in pts.as_chunks::<3>().0.iter().flatten() { + let i = fill.vertices.len() as u32; + fill.colored_vertex(xf.to_screen(*q), fill_color); + fill.indices.push(i); + } + } + Kind::Tris | Kind::Mask => { + for outline in list.fill_outlines(prim) { + line(outline.iter().map(|q| xf.to_screen(*q)).collect(), &mut shapes); + } + } + } + } + if !fill.is_empty() { + shapes.push(Shape::mesh(fill)); + } + p.extend(shapes); +} + +/// The fill of a selection window or crossing area (SELECTIONAREA, WINDOWAREACOLOR, +/// CROSSINGAREACOLOR, SELECTIONAREAOPACITY); `None` when areas aren't filled. +pub(super) fn selection_area_fill(s: &cadcraft_engine::Settings, crossing: bool) -> Option { + let aci = if crossing { s.crossingareacolor } else { s.windowareacolor }; + let c = cadcraft_color::aci_rgb(aci); + s.selectionarea.then(|| Color32::from_rgb(c.0, c.1, c.2).gamma_multiply(f32::from(s.selectionareaopacity.min(100)) / 100.0)) +} + +#[cfg(test)] +mod highlight_stability_tests { + use super::*; + + fn frame(list: &DisplayList, look: Look, pointer: Pos2) -> Vec { + let ctx = egui::Context::default(); + let rect = Rect::from_min_size(Pos2::ZERO, vec2(600.0, 400.0)); + let input = egui::RawInput { screen_rect: Some(rect), events: vec![egui::Event::PointerMoved(pointer)], ..Default::default() }; + let mut output = ctx.run_ui(input, |ui| { + draw_highlight(ui.painter(), &Xf { rect, center: Vec2::ZERO, scale: 11.0 }, list, &[0], Color32::LIGHT_BLUE, look); + }); + let shapes = std::mem::take(&mut output.shapes); + output.textures_delta.clear(); + shapes + } + + // Compare visible dash segments, not their submission order or endpoint direction. + fn dash_positions(shapes: &[egui::epaint::ClippedShape]) -> Vec<((u32, u32), (u32, u32))> { + let mut segments: Vec<_> = shapes + .iter() + .filter_map(|s| match &s.shape { + Shape::LineSegment { points: [a, b], stroke } if stroke.color == Color32::LIGHT_BLUE => { + let (a, b) = ((a.x.to_bits(), a.y.to_bits()), (b.x.to_bits(), b.y.to_bits())); + Some(if a < b { (a, b) } else { (b, a) }) + } + _ => None, + }) + .collect(); + segments.sort_unstable(); + segments + } + + #[test] + fn moving_the_pointer_does_not_move_cpu_fill_highlight_dashes() { + let p = |x, y| Vec2::new(x, y); + let mut list = DisplayList { tris: vec![p(0.0, 0.0), p(7.0, 0.0), p(7.0, 3.0), p(0.0, 0.0), p(7.0, 3.0), p(0.0, 3.0)], ..Default::default() }; + list.prims.push(cadcraft_render::DPrim { + handle: Handle(256), + color: Rgb(255, 255, 255), + aci7: true, + lw: 0.0, + transparency: 0, + kind: Kind::Tris, + start: 0, + len: 6, + }); + // Filled glyphs, hatches, solids and arrowheads share the Tris path; wipeouts use Mask. + for kind in [Kind::Tris, Kind::Mask] { + list.prims.first_mut().unwrap().kind = kind; + for width in [1.0, 1.5, 2.0] { + let look = Look { width, dashed: true }; + let expected = dash_positions(&frame(&list, look, pos2(320.0, 190.0))); + assert!(!expected.is_empty()); + for i in 0..32 { + assert_eq!( + dash_positions(&frame(&list, look, pos2(320.0 + i as f32, 190.0))), + expected, + "pointer movement changes only the cursor, not the dashed contour" + ); + } + } + } + if let Some(dir) = std::env::var_os("CADCRAFT_HIGHLIGHT_SHOTS") { + let dir = std::path::PathBuf::from(dir); + std::fs::create_dir_all(&dir).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(vec2(600.0, 400.0)).build_ui(|ui| { + let xf = Xf { rect: ui.max_rect(), center: Vec2::new(3.5, 1.5), scale: 31.0 }; + draw_highlight(ui.painter(), &xf, &list, &[0], Color32::LIGHT_BLUE, Look { width: 2.0, dashed: true }); + }); + h.run_steps(3); + h.event(egui::Event::PointerMoved(pos2(250.0, 190.0))); + h.run_steps(2); + // Harness::render adds a synthetic cursor; omit it from the pixel comparison. + h.remove_cursor(); + h.run_steps(1); + let first = h.render().unwrap(); + first.save(dir.join("highlight-pointer-a.png")).unwrap(); + h.event(egui::Event::PointerMoved(pos2(320.0, 190.0))); + h.run_steps(2); + h.remove_cursor(); + h.run_steps(1); + let second = h.render().unwrap(); + second.save(dir.join("highlight-pointer-b.png")).unwrap(); + let changed = first.pixels().zip(second.pixels()).filter(|(a, b)| a != b).count(); + assert_eq!(changed, 0, "moving the pointer cannot change any highlight pixels"); + } + } +} diff --git a/crates/ui-egui/src/canvas/dyn_boxes.rs b/crates/ui-egui/src/canvas/dyn_boxes.rs new file mode 100644 index 000000000..686f09110 --- /dev/null +++ b/crates/ui-egui/src/canvas/dyn_boxes.rs @@ -0,0 +1,224 @@ +use super::widgets::draw_lock; +use super::*; + +/// The Dynamic Input tooltip beside the cursor, styled like the command line. At a point prompt +/// it shows the pair of value boxes, a view of the command-line text (see [`crate::dyninput`]); +/// otherwise the prompt with what is being typed, or the live distance and angle. With `fields` +/// (a distance prompt's field is on show, [`crate::grip_dyn`]) only the prompt and its options. +pub(super) fn dynamic_input(app: &mut CadApp, p: &egui::Painter, hover: Pos2, fields: bool) { + app.cmd.option_rows.clear(); + // While the options menu is open the tooltip stays where it opened, so the menu can be reached. + let at = match app.cmd.options { + Some(_) => *app.cmd.options_anchor.get_or_insert(hover), + None => { + app.cmd.options_anchor = None; + hover + } + }; + let Some(prompt) = app.session.current_prompt() else { return }; + // DYNPROMPT = 0: no prompt beside the cursor (the value boxes stay). + let show_prompt = app.session.settings.dynprompt; + let options = show_prompt && !prompt.keywords.is_empty(); + if !fields + && let Some(f) = crate::cmdline::dyn_frame(app) + && let Some(e) = crate::dyninput::parse(&app.cmd.buffer, &f) + && let Some(c) = app.canvas.cursor + { + let message = if show_prompt { crate::i18n::t(&prompt.message) } else { "" }; + let r = dynamic_input_boxes(p, at, message, &f, &e, c, app.cmd.second_locked(), options, length_units(&app.session)); + options_menu(app, p, r, hover); + return; + } + if !show_prompt { + return; + } + let mut text = crate::i18n::t(&prompt.message).to_string(); + if fields { + // The field shows the distance and what is typed. + } else if !app.cmd.buffer.is_empty() { + text = format!("{}: {}", crate::i18n::t(&prompt.message), app.cmd.buffer); + } else if let (Some(base), Some(c)) = (prompt.base, app.canvas.cursor) { + text = format!( + "{} {} < {}°", + crate::i18n::t(&prompt.message), + distance_text(&app.session, base.dist(c)), + (base.angle_to(c).to_degrees() * 10.0).round() / 10.0 + ); + } else if let Some(c) = app.canvas.cursor { + text = format!("{} {}, {}", crate::i18n::t(&prompt.message), distance_text(&app.session, c.x), distance_text(&app.session, c.y)); + } + let t = Tokens::get(); + let galley = p.layout_no_wrap(text, crate::theme::small(), t.text); + let hint = if options { OPTIONS_HINT_W } else { 0.0 }; + let r = tooltip_frame(p, at, galley.size() + vec2(12.0 + hint, 8.0)); + p.galley(r.min + vec2(6.0, 4.0), galley, t.text); + if options { + options_hint(p, r); + } + options_menu(app, p, r, hover); +} + +/// The drawing's length units: (LUNITS, LUPREC). +pub(crate) fn length_units(session: &cadcraft_engine::Session) -> (i64, i64) { + session.doc().map(|d| (d.header.i64("LUNITS", 2), d.header.i64("LUPREC", 4))).unwrap_or((2, 4)) +} + +/// A distance in the drawing's length units. +fn distance_text(session: &cadcraft_engine::Session, v: f64) -> String { + let (lu, lp) = length_units(session); + cadcraft_engine::units::format_distance(v, lu, lp) +} + +/// Room for the down-arrow sign that a prompt has options. +const OPTIONS_HINT_W: f32 = 14.0; + +/// The down-arrow sign at the right end of the tooltip `r`: the prompt has options, and Down +/// Arrow lists them. +fn options_hint(p: &egui::Painter, r: Rect) { + let t = Tokens::get(); + let c = pos2(r.right() - OPTIONS_HINT_W / 2.0 - 2.0, r.center().y + 1.0); + p.add(Shape::convex_polygon(vec![pos2(c.x - 4.0, c.y - 2.5), pos2(c.x + 4.0, c.y - 2.5), pos2(c.x, c.y + 2.5)], t.text_dim, Stroke::NONE)); +} + +/// The options menu under the tooltip `r` (DYNPROMPT): one row per keyword, the highlighted one +/// marked; the rows are kept for clicks. +fn options_menu(app: &mut CadApp, p: &egui::Painter, r: Rect, hover: Pos2) { + let Some(mut menu) = app.cmd.options.clone() else { return }; + let t = Tokens::get(); + let font = crate::theme::small(); + let rows: Vec<_> = menu.keywords.iter().map(|k| p.layout_no_wrap(k.clone(), font.clone(), t.text)).collect(); + let row_h = rows.iter().map(|g| g.size().y).fold(12.0, f32::max) + 6.0; + let w = rows.iter().map(|g| g.size().x).fold(60.0, f32::max) + 28.0; + let h = row_h * rows.len() as f32 + 4.0; + let m = Rect::from_min_size(pos2(r.left(), r.bottom() + 2.0), vec2(w, h)); + p.rect_filled(m, 3.0, t.cmd_bg); + p.rect_stroke(m, 3.0, Stroke::new(1.0, t.cmd_border), egui::StrokeKind::Inside); + let row_at = |i: usize| Rect::from_min_size(pos2(m.left() + 2.0, m.top() + 2.0 + row_h * i as f32), vec2(w - 4.0, row_h)); + // The row under the pointer is the highlighted one. + if let Some(i) = (0..menu.keywords.len()).find(|i| row_at(*i).contains(hover)) { + menu.index = i; + app.cmd.options = Some(menu.clone()); + } + for (i, (g, k)) in rows.into_iter().zip(&menu.keywords).enumerate() { + let row = row_at(i); + if i == menu.index { + p.rect_filled(row, 2.0, t.cmd_keyword_hover); + p.circle_filled(pos2(row.left() + 9.0, row.center().y), 2.5, t.accent); + } + p.galley(pos2(row.left() + 20.0, row.center().y - g.size().y / 2.0), g, t.text); + app.cmd.option_rows.push((row, k.clone())); + } +} + +/// The tooltip background, matching the command line bar; returns its rectangle. +fn tooltip_frame(p: &egui::Painter, at: Pos2, size: egui::Vec2) -> Rect { + let t = Tokens::get(); + let r = Rect::from_min_size(at + vec2(18.0, -size.y - 14.0), size); + p.rect_filled(r, 3.0, t.cmd_bg); + p.rect_stroke(r, 3.0, Stroke::new(1.0, t.cmd_border), egui::StrokeKind::Inside); + r +} + +/// The Dynamic Input value boxes: X and Y, or distance and angle. Typed values show in full +/// text colour, a locked value (a separator typed after it) gets a lock, the active box gets the +/// accent outline, and open boxes show the cursor's live value, dimmed. Values measured from the +/// last point are marked with `@`. +#[allow(clippy::too_many_arguments)] +fn dynamic_input_boxes( + p: &egui::Painter, + at: Pos2, + message: &str, + f: &crate::dyninput::Frame, + e: &crate::dyninput::Entry<'_>, + cursor: Vec2, + second_locked: bool, + options: bool, + (lu, lp): (i64, i64), +) -> Rect { + use crate::dyninput::Format; + let t = Tokens::get(); + let font = crate::theme::small(); + let dist = |v: f64| cadcraft_engine::units::format_distance(v, lu, lp); + let (live_first, live_second) = f.live(e.format, cursor); + let live_second = match e.format { + Format::Cartesian => dist(live_second), + Format::Polar => format!("{}°", (live_second.to_degrees() * 10.0).round() / 10.0), + }; + let (label_first, label_second) = match e.format { + Format::Cartesian => ("X", "Y"), + Format::Polar => ("", "<"), + }; + // (label, text, typed, locked, active) + let cells = [ + ( + label_first, + if e.first.is_empty() { dist(live_first) } else { e.first.to_owned() }, + !e.first.is_empty(), + e.first_locked().is_some(), + !e.split, + ), + ( + label_second, + if e.second.is_empty() { live_second } else { e.second.to_owned() }, + !e.second.is_empty(), + second_locked, + e.split && !second_locked, + ), + ]; + + let msg = p.layout_no_wrap(message.to_owned(), font.clone(), t.text_dim); + let marker = p.layout_no_wrap(if f.relative() { "@".to_owned() } else { String::new() }, font.clone(), t.text_faint); + let lock_w = 10.0; + let laid: Vec<_> = cells + .into_iter() + .map(|(label, text, typed, locked, active)| { + let label = p.layout_no_wrap(label.to_owned(), font.clone(), t.text_faint); + let value = p.layout_no_wrap(text, font.clone(), if typed { t.text } else { t.text_faint }); + let w = (value.size().x + 10.0 + if locked { lock_w } else { 0.0 }).max(48.0); + (label, value, w, locked, active) + }) + .collect(); + + // Measure, then draw. + let pad = 6.0; + let gap = 6.0; + let box_h = msg.size().y + 6.0; + let mut w = pad + msg.size().x + gap + marker.size().x; + for (label, _, bw, _, _) in &laid { + w += gap + label.size().x + 3.0 + bw; + } + let hint = if options { OPTIONS_HINT_W } else { 0.0 }; + let r = tooltip_frame(p, at, vec2(w + pad + hint, box_h + 2.0 * pad - 2.0)); + if options { + options_hint(p, r); + } + let cy = r.center().y; + let mut x = r.left() + pad; + let (msg_w, msg_h) = (msg.size().x, msg.size().y); + p.galley(pos2(x, cy - msg_h / 2.0), msg, t.text_dim); + x += msg_w + gap; + let mw = marker.size().x; + p.galley(pos2(x, cy - marker.size().y / 2.0), marker, t.text_faint); + x += mw; + for (label, value, bw, locked, active) in laid { + x += gap; + let lw = label.size().x; + p.galley(pos2(x, cy - label.size().y / 2.0), label, t.text_faint); + x += lw + 3.0; + let b = Rect::from_min_size(pos2(x, cy - box_h / 2.0), vec2(bw, box_h)); + p.rect_filled(b, 2.0, t.chrome_dark); + let stroke = if active { Stroke::new(1.5, t.accent) } else { Stroke::new(1.0, t.border) }; + p.rect_stroke(b, 2.0, stroke, egui::StrokeKind::Inside); + let vw = value.size().x; + p.galley(pos2(b.left() + 5.0, cy - value.size().y / 2.0), value, t.text); + if active { + let cx = b.left() + 6.0 + vw; + p.line_segment([pos2(cx, b.top() + 3.0), pos2(cx, b.bottom() - 3.0)], Stroke::new(1.0, t.text)); + } + if locked { + draw_lock(p, pos2(b.right() - lock_w / 2.0 - 3.0, cy), t.accent); + } + x += bw; + } + r +} diff --git a/crates/ui-egui/src/canvas/grid.rs b/crates/ui-egui/src/canvas/grid.rs new file mode 100644 index 000000000..c2e616aaf --- /dev/null +++ b/crates/ui-egui/src/canvas/grid.rs @@ -0,0 +1,103 @@ +use super::*; + +/// Grid spacing adapted to the zoom: multiply the unit until lines are at least `min_px` apart. +fn adaptive(unit: f64, scale: f64, min_px: f64) -> f64 { + let mut u = unit.max(1e-12); + let mut guard = 0; + while u * scale < min_px && guard < 40 { + u *= 5.0; + guard += 1; + } + 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)?)) +} + +pub(super) fn draw_grid(app: &CadApp, p: &egui::Painter, xf: &Xf) { + let t = Tokens::get(); + let s = app.session.drafting_settings(); + let wb = xf.world_bounds(); + if s.gridmode && s.isodraft { + draw_iso_grid(app, p, xf); + } else if s.gridmode { + let major_n = f64::from(s.gridmajor.max(1)); + let unit = if s.gridunit.x == 0.0 { s.snapunit.x } else { s.gridunit.x }; + let minor = adaptive(unit, xf.scale, 8.0); + let major = minor * major_n; + let mut lines = Vec::new(); + 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; + let sx = xf.to_screen(Vec2::new(x, 0.0)).x; + lines.push(Shape::line_segment( + [pos2(sx, xf.rect.top()), pos2(sx, xf.rect.bottom())], + Stroke::new(1.0, if is_major { t.grid_major } else { t.grid_minor }), + )); + } + for j in y0..=y1 { + let y = j as f64 * minor; + let is_major = ((y / major) - (y / major).round()).abs() < 1e-6; + let sy = xf.to_screen(Vec2::new(0.0, y)).y; + lines.push(Shape::line_segment( + [pos2(xf.rect.left(), sy), pos2(xf.rect.right(), sy)], + Stroke::new(1.0, if is_major { t.grid_major } else { t.grid_minor }), + )); + } + } + p.extend(lines); + } + // Axes through the origin. + let o = xf.to_screen(Vec2::ZERO); + if xf.rect.y_range().contains(o.y) { + p.line_segment([pos2(xf.rect.left(), o.y), pos2(xf.rect.right(), o.y)], Stroke::new(1.0, t.axis_x)); + } + if xf.rect.x_range().contains(o.x) { + p.line_segment([pos2(o.x, xf.rect.top()), pos2(o.x, xf.rect.bottom())], Stroke::new(1.0, t.axis_y)); + } +} + +/// The isometric grid (SNAPSTYL 1): lines at 30°, 90° and 150° through the snap lattice, GRIDUNIT +/// Y apart along the axes, coarsened while they would crowd closer than 8 pixels. +fn draw_iso_grid(app: &CadApp, p: &egui::Painter, xf: &Xf) { + use cadcraft_engine::snap::iso; + let t = Tokens::get(); + let s = app.session.drafting_settings(); + let mut d = s.gridunit.y.max(1e-12); + for _ in 0..40 { + if d * 0.866 * xf.scale >= 8.0 { + break; + } + d *= 5.0; + } + let (origin, angle) = app.session.doc().map(cadcraft_engine::snap::grid_frame).unwrap_or((Vec2::ZERO, 0.0)); + let lines = iso::grid_lines(xf.world_bounds(), d, origin, angle, s.gridmajor); + p.extend( + lines.into_iter().map(|l| { + Shape::line_segment([xf.to_screen(l.a), xf.to_screen(l.b)], Stroke::new(1.0, if l.major { t.grid_major } else { t.grid_minor })) + }), + ); +} + +#[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/grips.rs b/crates/ui-egui/src/canvas/grips.rs new file mode 100644 index 000000000..65e1cbc16 --- /dev/null +++ b/crates/ui-egui/src/canvas/grips.rs @@ -0,0 +1,101 @@ +use super::*; + +/// A click on grip `g` (idle): Shift+click makes it hot or cold again; a plain click starts +/// editing it, with the other hot grips when it is one of them. +pub(super) fn click_grip(app: &mut CadApp, g: (Handle, usize), shift: bool) { + let hot = &mut app.canvas.hot_grips; + if shift { + match hot.iter().position(|x| *x == g) { + Some(i) => { + hot.remove(i); + } + None => hot.push(g), + } + return; + } + if !hot.contains(&g) { + hot.clear(); + } + hot.retain(|x| *x != g); + hot.insert(0, g); + let hs = hot.clone(); + if let Err(e) = app.session.start_grip_edit(&hs) { + app.canvas.hot_grips.clear(); + app.session.echo(e.to_string()); + } +} + +/// The multifunctional options of grip `g` (Add Vertex, Lengthen…; empty: Stretch only). +pub(super) fn grip_options(app: &CadApp, (h, i): (Handle, usize)) -> Vec { + let Ok(st) = app.session.state() else { return Vec::new() }; + st.doc.entity(h).map(|e| cadcraft_engine::grips::grip_actions(&e.kind, i)).unwrap_or_default() +} + +/// The menu of a multifunctional grip: Stretch and its options. A choice makes the grip hot and +/// runs the option at the grip prompt, as typing its keyword would. +pub(super) fn grip_menu(app: &mut CadApp, resp: &egui::Response) { + let Some(g) = app.canvas.grip_menu else { return }; + let options = grip_options(app, g); + let mut chosen: Option> = None; + let open = resp + .context_menu(|ui| { + let vertex = options.contains(&cadcraft_engine::grips::GripAction::RemoveVertex); + if ui.button(crate::i18n::t(if vertex { "Stretch Vertex" } else { "Stretch" })).clicked() { + chosen = Some(None); + ui.close(); + } + for a in &options { + if ui.button(crate::i18n::t(a.label())).clicked() { + chosen = Some(Some(*a)); + ui.close(); + } + } + }) + .is_some(); + if !open || chosen.is_some() { + app.canvas.grip_menu = None; + } + let Some(choice) = chosen else { return }; + app.canvas.hot_grips = vec![g]; + let r = app.session.start_grip_edit(&[g]).and_then(|()| match choice { + Some(a) => app.session.input(Input::Keyword(a.keyword().to_string())), + None => Ok(()), + }); + if let Err(e) = r { + app.canvas.hot_grips.clear(); + app.session.echo(e.to_string()); + } +} + +/// Polyline segment midpoint grips are rectangular; vertex grips remain square. +pub(super) fn grip_rect(kind: &cadcraft_doc::EntityKind, count: usize, index: usize, center: Pos2, size: f32) -> Rect { + // The grip list interleaves vertices and available segment midpoints, then any remaining vertices. + let midpoint = matches!(kind, cadcraft_doc::EntityKind::LwPolyline(p) if index % 2 == 1 && index / 2 < count.saturating_sub(p.vertices.len())); + Rect::from_center_size(center, vec2(size * 2.0, if midpoint { size } else { size * 2.0 })) +} + +/// The grip of a selected object under the cursor, if any: (object, grip index). +pub(super) fn grip_at(app: &CadApp, xf: &Xf, hover: Option) -> Option<(Handle, usize)> { + let hp = hover?; + let st = app.session.state().ok()?; + let gs = app.session.settings.gripsize as f32; + let sel = app.session.selection(); + if !app.session.shows_grips(sel.len()) { + return None; + } + for h in sel.iter().take(200) { + let e = st.doc.entity(*h)?; + if st.doc.layer(&e.common.layer).is_some_and(|l| l.locked) { + continue; + } + let grips = e.kind.grips(); + let count = grips.len(); + for (i, g) in grips.into_iter().enumerate() { + let sp = xf.to_screen(g); + if grip_rect(&e.kind, count, i, sp, gs).expand(3.0).contains(hp) { + return Some((*h, i)); + } + } + } + None +} diff --git a/crates/ui-egui/src/canvas/list.rs b/crates/ui-egui/src/canvas/list.rs new file mode 100644 index 000000000..0e15fc01e --- /dev/null +++ b/crates/ui-egui/src/canvas/list.rs @@ -0,0 +1,178 @@ +use super::viewport::{split_viewport, stencil_clip}; +use super::*; + +/// Primitives of `list` sorted by entity handle, built on the first highlight after each list +/// rebuild. Highlighting the hovered entity used to scan every primitive on every frame: ~2.5 ms +/// per frame on a 270k-entity drawing, whether or not anything changed. +#[derive(Default)] +pub(super) struct PrimIndex { + list_gen: Option, + by_handle: Vec<(Handle, usize)>, +} + +impl PrimIndex { + /// Indices of the primitives drawn for `handles` (in list order). + pub(super) fn prims(&mut self, list_gen: u64, list: &DisplayList, handles: &[Handle]) -> Vec { + if handles.is_empty() { + return Vec::new(); + } + if self.list_gen != Some(list_gen) { + self.by_handle = list.prims.iter().enumerate().map(|(i, p)| (p.handle, i)).collect(); + self.by_handle.sort_unstable(); + self.list_gen = Some(list_gen); + } + let mut out = Vec::new(); + for h in handles { + let from = self.by_handle.partition_point(|(k, _)| k < h); + out.extend(self.by_handle.get(from..).unwrap_or(&[]).iter().take_while(|(k, _)| k == h).map(|(_, i)| *i)); + } + out.sort_unstable(); + out.dedup(); + out + } +} + +/// Indices of the primitives in `list` drawn for `handles` (a scan: the reference [`PrimIndex`] +/// is tested against). +#[cfg(test)] +pub(super) fn prims_of(list: &DisplayList, handles: &[Handle]) -> Vec { + if handles.is_empty() { + return Vec::new(); + } + let set: std::collections::HashSet = handles.iter().copied().collect(); + list.prims.iter().enumerate().filter(|(_, pr)| set.contains(&pr.handle)).map(|(i, _)| i).collect() +} + +/// Build options for previews at `scale` pixels per drawing unit (opaque). +pub(super) fn preview_options(scale: f64) -> cadcraft_render::Options { + cadcraft_render::Options { tolerance: 0.5 / scale, min_dash: 2.0 / scale, ..Default::default() } +} + +/// The display list of preview entities the session builds with the current properties +/// ([`cadcraft_engine::Session::preview`], the extras of [`cadcraft_engine::Session::drag`]): the +/// command's rubber band and rebuilt copies of existing objects. They carry CLAYER's properties, +/// not the objects', so they are drawn opaque (the GPU drag of the objects themselves keeps their +/// transparency, see [`draw_drag_gpu`]). +pub(super) fn rebuilt_preview<'a>(doc: &cadcraft_doc::Drawing, ents: impl IntoIterator, scale: f64) -> DisplayList { + cadcraft_render::build_entities(doc, ents, &preview_options(scale)) +} + +/// Rebuild the display list when needed. `xf`: the current view; `views`: every area on screen +/// that shows the list (the current view, other model tiles) with its drawing units per pixel. +/// +/// A rebuild only the view asks for (a new zoom band, a pan out of the built area) runs in the +/// background while the current list is drawn (see [`rebuild`]); a changed drawing, or no list +/// yet, is built at once. +pub(super) fn ensure_list(app: &mut CadApp, xf: &Xf, views: &[(Bounds2, f64)], ctx: &egui::Context) { + let px = 1.0 / xf.scale; + let Ok(st) = app.session.state() else { return }; + // Rebuild when the drawing changes, the zoom moves by more than 2x (tessellation band) or a + // view leaves the area the list was built for. + let band = |px: f64| px.max(1e-300).log2().floor() as i32; + // Hatch level of detail follows the finest view showing the list. + let lod_band = views.iter().map(|v| band(v.1)).fold(band(px), i32::min); + // The active MSPACE viewport's contents are drawn apart (see `VpContent`), so panning and + // zooming inside it leave this list alone. Without a stencil buffer a clipped viewport's + // contents stay in the list, which then follows its view: realtime navigation changes it + // without a new revision. + let split = split_viewport(st, stencil_clip(app)).map(|(h, ..)| h); + let vp_view = { + use std::hash::{Hash, Hasher}; + let mut s = std::collections::hash_map::DefaultHasher::new(); + split.hash(&mut s); + if split.is_none() + && let Some((h, vp)) = st.active_viewport() + { + (h, vp.view_center.x.to_bits(), vp.view_center.y.to_bits(), vp.view_height.to_bits()).hash(&mut s); + } + s.finish() + }; + let (lwdisplay, transparency_display) = (app.session.settings.lwdisplay, app.session.settings.transparency_display); + let key: ListKey = (st.uid, st.revision, band(px), lod_band, lwdisplay, transparency_display, std::sync::Arc::as_ptr(&st.doc) as usize, vp_view); + // A background build that finished: use it unless the drawing has changed since it started + // (or the list was dropped to force a rebuild). + if let Some(job) = &app.canvas.job { + match job.poll() { + rebuild::Poll::Pending => {} + rebuild::Poll::Done(built) => { + let (job_key, window) = (job.key, job.window); + app.canvas.job = None; + if app.canvas.list.is_some() && same_content(&job_key, &key) { + app.canvas.install(job_key, window, *built); + } + } + rebuild::Poll::Lost => { + log::warn!("canvas: a background rebuild failed; rebuilding on the UI thread"); + app.canvas.job = None; + app.canvas.list = None; + } + } + } + let visible = views.iter().fold(Bounds2::EMPTY, |acc, v| acc.union(&v.0)); + let inside = app.canvas.window.is_none_or(|w| visible.is_empty() || w.contains_box(&visible)); + if app.canvas.list.is_some() && app.canvas.key == key && inside { + return; + } + // Only the view moved: keep drawing the current list and build the new one in the background. + let view_only = app.canvas.list.is_some() && same_content(&app.canvas.key, &key); + if view_only && app.canvas.job.as_ref().is_some_and(|j| j.serves(&key, &visible)) { + return; + } + // A build for another view, or for a drawing that has since changed, is no longer wanted: + // dropping it cancels it. + app.canvas.job = None; + let t0 = crate::now_ms(); + let opts = cadcraft_render::Options { + tolerance: 2f64.powi(band(px)) * 0.5, + // Two pixels at the band's finest zoom: the list serves every zoom in the band, so its + // level of detail must hold at the finest one. + min_dash: 2f64.powi(lod_band) * 2.0, + text: true, + fill: true, + lineweights: lwdisplay, + view_height: st.paper_view().height, + plot_style_table: None, + view: build_window(views), + transparency: app.session.settings.transparency_display, + skip_viewport: split, + cancel: None, + }; + if view_only { + let t = Tokens::get(); + let mesh = app.canvas.gpu.is_some().then(|| rebuild::MeshRequest { + view_center: xf.center, + view_height: f64::from(xf.rect.height()) / xf.scale.max(1e-300), + model_bg: Rgb(t.canvas.r(), t.canvas.g(), t.canvas.b()), + paper_bg: Rgb(t.paper.r(), t.paper.g(), t.paper.b()), + lwdisplay, + device: app.canvas.gpu_device.clone(), + }); + let window = opts.view; + let req = rebuild::Request { doc: st.doc.clone(), space: st.space.clone(), opts: opts.clone(), mesh }; + if let Some(job) = rebuild::spawn(req, key, window, ctx) { + app.canvas.job = Some(job); + return; + } + } + let space = st.space.clone(); + app.canvas.list = Some(cadcraft_render::build(&st.doc, &space, &opts)); + app.canvas.key = key; + app.canvas.window = opts.view; + app.canvas.list_gen = app.canvas.list_gen.wrapping_add(1); + app.canvas.build_ms = crate::now_ms() - t0; +} + +/// The area a display list is built for: each view with half its larger side of margin all +/// round (panning by up to that much doesn't rebuild), joined. `None` (everything) when a view +/// isn't usable. +fn build_window(views: &[(Bounds2, f64)]) -> Option { + let mut w = Bounds2::EMPTY; + for (v, _) in views { + let side = v.width().max(v.height()); + if v.is_empty() || !side.is_finite() { + return None; + } + w = w.union(&v.expand(side * 0.5)); + } + (!w.is_empty()).then_some(w) +} diff --git a/crates/ui-egui/src/canvas/pick.rs b/crates/ui-egui/src/canvas/pick.rs new file mode 100644 index 000000000..ea970a8d0 --- /dev/null +++ b/crates/ui-egui/src/canvas/pick.rs @@ -0,0 +1,101 @@ +use super::*; + +/// The vertical wheel scroll this frame that zooms, in points, read from the raw wheel events so +/// each notch zooms by its whole step at once, as in standard CAD (#660). egui's smoothed delta +/// spreads a notch over several frames, so quick notches lag behind the wheel. Units convert to +/// points as egui does (a line is `line_scroll_speed` points, a page the view's height), and +/// events egui turns into a pinch zoom (⌘/Ctrl) or a sideways scroll (⇧) are left out, as it does. +pub(super) fn wheel_zoom_points(events: &[egui::Event], opts: &egui::InputOptions, page: f32) -> f32 { + let points: f32 = events + .iter() + .filter_map(|e| match e { + egui::Event::MouseWheel { unit, delta, modifiers, .. } + if !modifiers.matches_any(opts.zoom_modifier) && !modifiers.matches_any(opts.horizontal_scroll_modifier) => + { + let per = match unit { + egui::MouseWheelUnit::Point => 1.0, + egui::MouseWheelUnit::Line => opts.line_scroll_speed, + egui::MouseWheelUnit::Page => page, + }; + Some(delta.y * per) + } + _ => None, + }) + .sum(); + if points.is_finite() { points } else { 0.0 } +} + +/// The model space tiles on screen (none without tiling): each tile's rectangle in `full`. +/// Start TEXTEDIT on the text, mtext or attribute definition at `p` (the objects its shortcut menu +/// offers Edit Text for); false when there is none. +pub(super) fn edit_text_at(app: &mut CadApp, p: Vec2) -> bool { + use cadcraft_engine::doc::EntityKind; + let is_text = cadcraft_engine::cmd::curves::pick_at(&app.session, p).and_then(|h| { + app.session.doc().ok()?.entity(h).map(|e| matches!(e.kind, EntityKind::Text(_) | EntityKind::MText(_) | EntityKind::AttDef(_))) + }); + if is_text != Some(true) { + return false; + } + app.start("textedit"); + if let Err(e) = app.session.input(Input::Point(p)) { + app.session.echo(e.to_string()); + } + true +} + +/// The hatch under world point `w` within the pick aperture (PICKBOX × 1.5 pixels at `scale` +/// pixels per unit), if any. +pub(super) fn hatch_at(app: &CadApp, w: Vec2, scale: f64) -> Option { + let st = app.session.state().ok()?; + let aperture = app.session.settings.pickbox.max(1.0) * 1.5 / scale.max(1e-12); + let h = cadcraft_engine::select::pick(&st.doc, &st.edit_space(), w, aperture)?; + matches!(st.doc.entity(h)?.kind, cadcraft_doc::EntityKind::Hatch(_)).then_some(h) +} + +#[cfg(test)] +mod hatch_pick_tests { + use cadcraft_engine::Session; + use cadcraft_geom::Vec2; + use serde_json::json; + + use crate::{CadApp, Services}; + + #[test] + fn hatch_at_finds_a_hatch_under_the_cursor_only() { + let mut app = CadApp::new(Session::new(), Services::default()); + app.session.execute("rectang", &json!({ "p1": [0, 0], "p2": [10, 10] })).unwrap(); + let hx = app.session.execute("hatch", &json!({ "points": [[5, 5]], "pattern": "SOLID" })).unwrap()["handle"].as_str().unwrap().to_string(); + app.session.execute("line", &json!({ "points": [[20, 0], [30, 0]] })).unwrap(); + assert_eq!(super::hatch_at(&app, Vec2::new(5.0, 5.0), 10.0).map(|h| h.hex()), Some(hx)); + assert_eq!(super::hatch_at(&app, Vec2::new(25.0, 0.0), 10.0), None, "a line is not a hatch"); + assert_eq!(super::hatch_at(&app, Vec2::new(60.0, 60.0), 10.0), None); + } + + #[test] + fn double_clicking_a_hatch_opens_the_hatch_edit_dialog() { + let mut app = CadApp::new(Session::new(), Services::default()); + app.session.execute("rectang", &json!({ "p1": [0, 0], "p2": [10, 10] })).unwrap(); + let hx = app.session.execute("hatch", &json!({ "points": [[5, 5]], "pattern": "SOLID" })).unwrap()["handle"].as_str().unwrap().to_string(); + let _ = app.session.zoom_extents(); + let ctx = egui::Context::default(); + let screen = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 1000.0))); + let frame = |app: &mut CadApp, events: Vec, time: f64| { + app.logic(&ctx); + let input = egui::RawInput { screen_rect: screen, events, time: Some(time), ..Default::default() }; + let mut out = ctx.run_ui(input, |ui| app.ui(ui)); + out.textures_delta.clear(); + }; + frame(&mut app, Vec::new(), 0.0); + let at = app.canvas.xf.unwrap().to_screen(Vec2::new(5.0, 5.0)); + let button = + |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::default() }; + frame(&mut app, vec![egui::Event::PointerMoved(at)], 0.1); + frame(&mut app, vec![button(true), button(false)], 0.2); + frame(&mut app, vec![button(true), button(false)], 0.3); + frame(&mut app, Vec::new(), 0.4); + assert_eq!(app.ui.dialog.as_deref(), Some("hatch")); + assert_eq!(app.hatch_dlg.as_ref().map(|s| s.edit.iter().map(|h| h.hex()).collect::>()), Some(vec![hx])); + // The double-click (at the hatch's centre grip) only opens the dialog: no grip edit. + assert!(app.session.running.is_none(), "{:?}", app.session.running.as_ref().map(|r| r.machine.name())); + } +} diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs new file mode 100644 index 000000000..c4e0c96ce --- /dev/null +++ b/crates/ui-egui/src/canvas/tests.rs @@ -0,0 +1,232 @@ +use super::*; +use cadcraft_render::DPrim; + +fn list(handles: &[u64]) -> DisplayList { + let prim = + |h| DPrim { handle: Handle(h), color: Rgb(255, 255, 255), aci7: true, lw: 0.0, transparency: 0, kind: Kind::Polyline, start: 0, len: 0 }; + DisplayList { prims: handles.iter().map(|&h| prim(h)).collect(), ..Default::default() } +} + +fn hs(v: &[u64]) -> Vec { + v.iter().map(|&h| Handle(h)).collect() +} + +/// A dragged object keeps its transparency in the GPU preview when TRANSPARENCYDISPLAY is +/// on, and is opaque when it is off. +#[test] +fn dragged_objects_keep_their_transparency() { + let mut d = cadcraft_doc::Drawing::new_metric(); + let common = cadcraft_doc::Common { transparency: cadcraft_doc::Transparency::Percent(50), ..Default::default() }; + let line = cadcraft_doc::Line { a: cadcraft_geom::Vec3::ZERO, b: cadcraft_geom::Vec3::new(10.0, 0.0, 0.0) }; + let h = d.add(&cadcraft_doc::Space::Model, common, cadcraft_doc::EntityKind::Line(line)).unwrap(); + let drag = cadcraft_engine::Drag { handles: vec![h], matrix: cadcraft_geom::Mat3::IDENTITY, extra: Vec::new(), clipboard: false }; + let rect = Rect::from_min_size(Pos2::ZERO, vec2(200.0, 100.0)); + let xf = Xf { rect, center: Vec2::new(5.0, 0.0), scale: 10.0 }; + let painter = egui::Painter::new(egui::Context::default(), egui::LayerId::background(), rect); + // One canvas across the toggle: the cached drag mesh must be rebuilt. + let mut c = CanvasState::default(); + let mut alpha = |transparency: bool| { + draw_drag_gpu(&mut c, &painter, &xf, &d, &drag, (&[], 0), Rgb(0, 0, 0), transparency); + let mesh = c.drag_slot.lock().unwrap().take().unwrap(); + // The first line vertex's colour alpha (after its 2 × f32 position). + mesh.lines[11] + }; + assert_eq!(alpha(true), 128); + assert_eq!(alpha(false), 255); + assert_eq!(alpha(true), 128); +} + +/// Previews the session rebuilds with the current properties (the rubber band, copies of +/// objects) stay opaque even when CLAYER is transparent: they were opaque before transparency +/// was drawn, and CLAYER's transparency isn't the objects'. +#[test] +fn rebuilt_previews_stay_opaque_on_a_transparent_current_layer() { + let mut s = cadcraft_engine::Session::new(); + let d = s.doc_mut().unwrap(); + d.layers.push(cadcraft_doc::Layer { name: "Glass".into(), transparency: 40, ..Default::default() }); + d.header.set_str("CLAYER", "Glass"); + assert!(s.settings.transparency_display); + // LINE: the segment from the first point to the cursor. + s.cmdline("line").unwrap(); + s.cmdline("0,0").unwrap(); + let ents = s.preview(Vec2::new(10.0, 5.0)); + assert!(!ents.is_empty()); + assert_eq!(ents[0].common.layer, "Glass"); + let doc = s.doc().unwrap(); + // Built with transparency, the preview would take CLAYER's 40 %. + let with = cadcraft_render::build_entities(doc, ents.iter(), &cadcraft_render::Options { transparency: true, ..preview_options(1.0) }); + assert!(with.prims.iter().all(|p| p.transparency == 40)); + let list = rebuilt_preview(doc, ents.iter(), 1.0); + assert!(!list.prims.is_empty() && list.prims.iter().all(|p| p.transparency == 0)); + s.cancel(); + // MOVE's base-to-cursor rubber band (a drag extra). + s.execute("line", &serde_json::json!({"points": [[0, 0], [10, 0]]})).unwrap(); + s.cmdline("move").unwrap(); + s.cmdline("all").unwrap(); + s.cmdline("").unwrap(); + s.cmdline("0,0").unwrap(); + let (_, extra) = s.drag(Vec2::new(4.0, 4.0)).unwrap(); + assert!(!extra.is_empty()); + let list = rebuilt_preview(s.doc().unwrap(), extra.iter(), 1.0); + assert!(!list.prims.is_empty() && list.prims.iter().all(|p| p.transparency == 0)); +} + +#[test] +fn viewport_highlight_tolerance_is_constant_within_a_zoom_band() { + assert_eq!(viewport_highlight_tolerance(4.0), viewport_highlight_tolerance(3.0)); + assert_ne!(viewport_highlight_tolerance(4.0), viewport_highlight_tolerance(1.5)); + for s in [0.3, 1.0, 3.0, 4.0, 250.0] { + assert!(viewport_highlight_tolerance(s) <= 0.5 / s); + } +} + +#[test] +fn selection_area_fill_follows_the_area_effect_settings() { + let mut s = cadcraft_engine::Settings::default(); + // Window: ACI 150 at 25 %; crossing: ACI 100. + let rgb = |c: Rgb| Color32::from_rgb(c.0, c.1, c.2); + assert_eq!(selection_area_fill(&s, false), Some(rgb(cadcraft_color::aci_rgb(150)).gamma_multiply(0.25))); + assert_eq!(selection_area_fill(&s, true), Some(rgb(cadcraft_color::aci_rgb(100)).gamma_multiply(0.25))); + s.selectionareaopacity = 100; + assert_eq!(selection_area_fill(&s, false), Some(rgb(cadcraft_color::aci_rgb(150)))); + s.selectionarea = false; + assert_eq!(selection_area_fill(&s, false), None, "Indicate selection area off: no fill"); +} + +#[test] +fn viewport_highlight_is_tessellated_once_per_selection_and_zoom_band() { + use cadcraft_doc::{Circle, Common, Drawing, EntityKind, Space}; + let mut d = Drawing::new_imperial(); + let circle = |r| EntityKind::Circle(Circle { center: cadcraft_geom::Vec3::new(0.0, 0.0, 0.0), radius: r }); + let a = d.add(&Space::Model, Common::default(), circle(10.0)).unwrap(); + let b = d.add(&Space::Model, Common::default(), circle(5.0)).unwrap(); + let st = cadcraft_engine::DocState::new(d, "highlight", None); + let points = |l: &DisplayList| l.prims.iter().map(|p| l.points(p).len()).sum::(); + let (mut sel, mut hover) = (VpHighlight::default(), VpHighlight::default()); + sel.update(&st, &[a], 10.0); + let (first, segments) = (sel.list_gen, points(&sel.list)); + assert!(!sel.list.prims.is_empty()); + // Every frame of a pan, zooming within the 2x band, and the hover moving: the same list. + sel.update(&st, &[a], 10.0); + sel.update(&st, &[a], 12.0); + hover.update(&st, &[b], 12.0); + hover.update(&st, &[a], 12.0); + assert_eq!(sel.list_gen, first); + // Zooming in past the band tessellates finer. + sel.update(&st, &[a], 40.0); + assert_ne!(sel.list_gen, first); + assert!(points(&sel.list) > segments); + // A different selection is rebuilt. + let zoomed = sel.list_gen; + sel.update(&st, &[a, b], 40.0); + assert_ne!(sel.list_gen, zoomed); + assert_eq!(sel.list.prims.iter().map(|p| p.handle).collect::>().len(), 2); +} + +#[test] +fn prim_index_matches_a_scan_and_follows_list_rebuilds() { + let a = list(&[3, 1, 3, 2, 7, 3]); + let mut ix = PrimIndex::default(); + assert_eq!(ix.prims(1, &a, &hs(&[3])), vec![0, 2, 5]); + assert_eq!(ix.prims(1, &a, &hs(&[3])), prims_of(&a, &hs(&[3]))); + assert_eq!(ix.prims(1, &a, &hs(&[2, 1, 2])), vec![1, 3]); + assert!(ix.prims(1, &a, &hs(&[9])).is_empty()); + assert!(ix.prims(1, &a, &[]).is_empty()); + // A rebuilt list (new generation) is re-indexed. + let b = list(&[3, 9]); + assert_eq!(ix.prims(2, &b, &hs(&[9, 3])), vec![0, 1]); +} + +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, source: egui::MouseWheelSource::Unknown, modifiers } +} + +/// Wheel events convert to points as egui does; ⌘/Ctrl (egui's pinch zoom) and ⇧ (a sideways +/// scroll) don't zoom here, and every notch of the frame counts (#660). +#[test] +fn wheel_zoom_reads_every_notch_of_the_frame() { + use egui::{Modifiers, MouseWheelUnit as U}; + let o = egui::InputOptions::default(); + let none = Modifiers::NONE; + assert_eq!(wheel_zoom_points(&[wheel(U::Line, 1.0, none)], &o, 500.0), o.line_scroll_speed); + assert_eq!(wheel_zoom_points(&[wheel(U::Line, -2.0, none)], &o, 500.0), -2.0 * o.line_scroll_speed); + assert_eq!(wheel_zoom_points(&[wheel(U::Point, 7.5, none)], &o, 500.0), 7.5); + assert_eq!(wheel_zoom_points(&[wheel(U::Page, 1.0, none)], &o, 500.0), 500.0); + // Three quick notches in one frame zoom three steps at once. + let three = [wheel(U::Line, 1.0, none), wheel(U::Line, 1.0, none), wheel(U::Line, 1.0, none)]; + assert_eq!(wheel_zoom_points(&three, &o, 500.0), 3.0 * o.line_scroll_speed); + assert_eq!(wheel_zoom_points(&[wheel(U::Line, 1.0, Modifiers::COMMAND)], &o, 500.0), 0.0); + assert_eq!(wheel_zoom_points(&[wheel(U::Line, 1.0, Modifiers::SHIFT)], &o, 500.0), 0.0); + assert_eq!(wheel_zoom_points(&[wheel(U::Line, f32::NAN, none)], &o, 500.0), 0.0); + assert_eq!(wheel_zoom_points(&[egui::Event::PointerGone], &o, 500.0), 0.0); +} + +/// The frame a notch arrives, egui's smoothed scroll holds only part of it (the rest trickles +/// in over later frames, which made quick notches lag), while the raw reading zooms the +/// whole notch at once (#660). +#[test] +fn a_wheel_notch_zooms_whole_in_its_own_frame() { + let ctx = egui::Context::default(); + let o = ctx.options(|o| o.input_options); + let input = egui::RawInput { events: vec![wheel(egui::MouseWheelUnit::Line, 1.0, egui::Modifiers::NONE)], ..Default::default() }; + let mut seen = (0.0, 0.0); + ctx.run_ui(input, |ui| { + seen = ui.input(|i| (i.smooth_scroll_delta.y, wheel_zoom_points(&i.raw.events, &o, 500.0))); + }) + .textures_delta + .clear(); + let (smoothed, raw) = seen; + assert_eq!(raw, o.line_scroll_speed, "the whole notch"); + assert!(smoothed < raw, "egui's smoothed delta lags behind: {smoothed} of {raw}"); +} + +/// The text painted by one frame of the app at `pointer`. +pub(crate) fn painted_text(app: &mut CadApp, ctx: &egui::Context, pointer: Pos2) -> String { + fn collect(shape: &Shape, text: &mut String) { + match shape { + Shape::Text(s) => { + text.push_str(s.galley.text()); + text.push('\n'); + } + Shape::Vec(shapes) => shapes.iter().for_each(|s| collect(s, text)), + _ => {} + } + } + let raw = egui::RawInput { + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1400.0, 900.0))), + events: vec![egui::Event::PointerMoved(pointer)], + ..Default::default() + }; + let mut out = ctx.run_ui(raw, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + let mut text = String::new(); + out.shapes.iter().for_each(|s| collect(&s.shape, &mut text)); + text +} + +/// The Dynamic Input distance box shows the cursor's live distance in the drawing's length +/// units (LUNITS/LUPREC), as DIST and the status bar do (#841). +#[test] +fn dynamic_input_distance_follows_the_drawing_units() { + let mut app = CadApp::new(cadcraft_engine::Session::new(), crate::Services::default()); + app.session.execute("units", &serde_json::json!({"lunits": 4, "luprec": 4})).unwrap(); + let ctx = egui::Context::default(); + painted_text(&mut app, &ctx, pos2(700.0, 450.0)); + app.session.cmdline("line").unwrap(); + app.session.input(Input::Point(Vec2::ZERO)).unwrap(); + let at = app.canvas.xf.unwrap().to_screen(Vec2::ZERO) + vec2(130.0, -70.0); + painted_text(&mut app, &ctx, at); + let text = painted_text(&mut app, &ctx, at); + let c = app.canvas.cursor.unwrap(); + let arch = |v: f64| cadcraft_engine::units::format_distance(v, 4, 4); + // The status bar shows X and Y in the same units: the distance must be neither. + assert!(arch(c.len()) != arch(c.x) && arch(c.len()) != arch(c.y), "{c:?}"); + assert!(text.contains(&arch(c.len())), "no {} in:\n{text}", arch(c.len())); + // Without the value boxes (a window corner pending) the tooltip shows the distance too. + app.session.pending_window = Some(cadcraft_engine::PendingWindow { corner: Vec2::ZERO, during_command: true }); + let text = painted_text(&mut app, &ctx, at); + assert!(text.contains(&format!(" {} <", arch(c.len()))), "no {} in:\n{text}", arch(c.len())); +} diff --git a/crates/ui-egui/src/canvas/viewport.rs b/crates/ui-egui/src/canvas/viewport.rs new file mode 100644 index 000000000..c96a2bc83 --- /dev/null +++ b/crates/ui-egui/src/canvas/viewport.rs @@ -0,0 +1,292 @@ +use super::draw::draw_list; +use super::*; + +/// The active MSPACE viewport when its contents are drawn apart from the layout's list (see +/// [`VpContent`]), with its clip outline when it is clipped. A clipped viewport is clipped with +/// the GPU's stencil buffer (`stencil`); without one its contents stay in the list. +pub(super) fn split_viewport( + st: &cadcraft_engine::DocState, + stencil: bool, +) -> Option<(Handle, cadcraft_doc::Viewport, Option)> { + let (h, vp) = st.active_viewport()?; + let clip = cadcraft_render::viewport_clip(&st.doc, h, &vp); + (clip.is_none() || stencil).then_some((h, vp, clip)) +} + +/// Whether the canvas can clip a viewport to any outline (a GPU with a stencil buffer). +pub(super) fn stencil_clip(app: &CadApp) -> bool { + app.canvas.gpu.is_some_and(|g| g.stencil.is_some()) +} + +/// How long the view inside the active viewport stays still before a zoomed [`VpContent`] is +/// rebuilt at the exact view (dash lengths and curve detail follow the viewport's scale). +const VP_SETTLE_MS: f64 = 150.0; + +/// Model space seen through the active MSPACE viewport, drawn apart from the layout's list. It is +/// built for an area around the viewport at one view and drawn clipped to the viewport, moved +/// and scaled to the current view, so panning and zooming inside the viewport redraw without +/// rebuilding anything. It is rebuilt when the drawing changes, when the zoom has moved 2x from +/// the view it was built for, when the view leaves the area, and once a zoom comes to rest. +#[derive(Default)] +pub(super) struct VpContent { + list: DisplayList, + /// What the list was built from: drawing, revision, document, viewport, tessellation band + /// and lineweight display. + pub(super) key: Option<(u64, u64, usize, Handle, i32, bool, bool)>, + /// The viewport's view (centre, height) the list was built for, and the paper area it covers. + view: (Vec2, f64), + area: Bounds2, + /// The view on the previous frame and when it last changed (ms). + last_view: (Vec2, f64), + moved_at: f64, + /// Scale and offset drawing the list at the viewport's current view (see [`vp_motion`]). + motion: Option<(f64, Vec2)>, + /// Bumped on each rebuild. + pub(super) builds: u64, + mesh_key: Option, + mesh_slot: crate::gpu::MeshSlot, + mesh_origin: Vec2, + /// A clipped viewport's outline on the GPU (see [`draw_vp_mask`]). + mask_key: Option, + mask_slot: crate::gpu::MeshSlot, + mask_origin: Vec2, +} + +impl VpContent { + /// Where the contents are on paper at the viewport's current view. + pub(super) fn paper_bounds(&self) -> Option { + let (k, b) = self.motion?; + (!self.list.bounds.is_empty()).then(|| Bounds2::new(self.list.bounds.min * k + b, self.list.bounds.max * k + b)) + } +} + +/// How a list built for view `built` (centre, height) of viewport `vp` maps to its current +/// view, as `p = k·p0 + b` on paper. A model point m is at p0 = c + s0 (m − c0) in the list and +/// belongs at p = c + s (m − vc), with c the viewport's centre and s, s0 paper units per model +/// unit, so k = s / s0 and b = c (1 − k) + s (c0 − vc). +fn vp_motion(vp: &cadcraft_doc::Viewport, built: (Vec2, f64)) -> Option<(f64, Vec2)> { + let (s, k) = (vp.height / vp.view_height, built.1 / vp.view_height); + let b = vp.center.xy() * (1.0 - k) + (built.0 - vp.view_center) * s; + (k.is_finite() && k > 0.0 && b.is_finite()).then_some((k, b)) +} + +/// How to draw the active viewport's [`VpContent`] this frame. +pub(super) struct VpFrame { + /// Draws the list at the viewport's current view. + xf: Xf, + /// The viewport's rectangle (a clipped one's outline bounds) on screen. + pub(super) on_screen: Rect, + /// A clipped viewport's outline on paper, masked with the stencil buffer. + pub(super) outline: Option>, +} + +/// Build or reuse the active viewport's [`VpContent`] for this frame. +pub(super) fn ensure_vp_content(app: &mut CadApp, xf_paper: &Xf, ctx: &egui::Context) -> Option { + let (lwdisplay, transparency) = (app.session.settings.lwdisplay, app.session.settings.transparency_display); + let stencil = stencil_clip(app); + let c = &mut app.canvas.vp; + let Some((st, (h, vp, clip))) = app.session.state().ok().and_then(|st| Some((st, split_viewport(st, stencil)?))) else { + // Not working in a viewport drawn apart: let the geometry go. + if c.key.is_some() { + *c = VpContent::default(); + } + return None; + }; + let half = Vec2::new(vp.width / 2.0, vp.height / 2.0); + let rect = match &clip { + Some(p) => p.bounds(), + None => Bounds2::new(vp.center.xy() - half, vp.center.xy() + half), + }; + let px = 1.0 / xf_paper.scale.max(1e-300); + let band = px.max(1e-300).log2().floor() as i32; + let key = (st.uid, st.revision, std::sync::Arc::as_ptr(&st.doc) as usize, h, band, lwdisplay, transparency); + let now = crate::now_ms(); + let view = (vp.view_center, vp.view_height); + if view != c.last_view { + c.last_view = view; + c.moved_at = now; + } + let rebuild = match vp_motion(&vp, c.view).filter(|_| c.key == Some(key)) { + Some((k, b)) => { + // The viewport in the list's own coordinates. + let visible = Bounds2::new((rect.min - b) / k, (rect.max - b) / k); + let zoomed = (k - 1.0).abs() > 1e-9; + let still = now - c.moved_at; + if zoomed && still < VP_SETTLE_MS { + ctx.request_repaint_after(std::time::Duration::from_millis((VP_SETTLE_MS - still).max(1.0) as u64)); + } + !(0.5..2.0).contains(&k) || !c.area.contains_box(&visible) || (zoomed && still >= VP_SETTLE_MS) + } + None => true, + }; + if rebuild { + let t0 = crate::now_ms(); + // The viewport with half its larger side of margin all round: panning by up to that much + // only moves the geometry. + let area = rect.expand(rect.width().max(rect.height()) * 0.5); + let opts = cadcraft_render::Options { + tolerance: 2f64.powi(band) * 0.5, + min_dash: 2f64.powi(band) * 2.0, + text: true, + fill: true, + lineweights: lwdisplay, + view_height: st.paper_view().height, + plot_style_table: None, + view: Some(area), + skip_viewport: None, + transparency, + cancel: None, + }; + c.list = cadcraft_render::viewport_content(&st.doc, &st.space, h, area, &opts); + c.key = Some(key); + c.view = view; + c.area = area; + c.builds = c.builds.wrapping_add(1); + app.canvas.build_ms = crate::now_ms() - t0; + } + let c = &mut app.canvas.vp; + let (k, b) = vp_motion(&vp, c.view)?; + c.motion = Some((k, b)); + // Paper p = k·p0 + b, so the list is drawn by the paper transform scaled by k about b. + let xf = Xf { rect: xf_paper.rect, center: (xf_paper.center - b) / k, scale: xf_paper.scale * k }; + let on_screen = Rect::from_two_pos(xf_paper.to_screen(Vec2::new(rect.min.x, rect.max.y)), xf_paper.to_screen(Vec2::new(rect.max.x, rect.min.y))); + Some(VpFrame { xf, on_screen, outline: clip.map(|p| p.polygon().to_vec()) }) +} + +/// Draw the active viewport's contents (see [`VpContent`]) as [`ensure_vp_content`] set out; +/// `p` clips them to the viewport's rectangle, and a clipped viewport's outline is masked with +/// the stencil buffer first. +pub(super) fn draw_vp_content(c: &mut VpContent, gpu: bool, p: &egui::Painter, f: &VpFrame, xf_paper: &Xf, bg: Rgb, lwdisplay: bool) { + let xf = &f.xf; + if !gpu { + draw_list(p, xf, &c.list, bg, lwdisplay); + return; + } + let mut stencil = crate::gpu::Stencil::Off; + if let Some(outline) = &f.outline { + draw_vp_mask(c, p, outline, xf_paper); + stencil = crate::gpu::Stencil::Inside; + } + let origin = crate::gpu::choose_origin(&c.list.bounds, xf.center, f64::from(xf.rect.height()) / xf.scale.max(1e-300)); + let key = { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + (c.builds, origin.x.to_bits(), origin.y.to_bits(), bg.0, bg.1, bg.2, lwdisplay).hash(&mut h); + h.finish() + }; + if c.mesh_key != Some(key) { + let mesh = crate::gpu::build_mesh(&c.list, origin, bg, lwdisplay); + if let Ok(mut slot) = c.mesh_slot.lock() { + *slot = Some(mesh); + } + c.mesh_key = Some(key); + c.mesh_origin = origin; + } + let center = xf.rect.center(); + p.add(egui_wgpu::Callback::new_paint_callback( + xf.rect, + crate::gpu::CanvasCallback { + key, + slot: c.mesh_slot.clone(), + offset: [(c.mesh_origin.x - xf.center.x) as f32, (c.mesh_origin.y - xf.center.y) as f32], + scale: xf.scale as f32, + center: [center.x, center.y], + layer: crate::gpu::LAYER_VIEWPORT, + xform: crate::gpu::IDENTITY_XFORM, + dash: 0.0, + stencil, + }, + )); +} + +/// Mask a clipped viewport's `outline` (paper units) in the stencil buffer: a triangle fan from +/// its first point, each triangle flipping the stencil bit, leaves the bit set inside the outline +/// (even-odd, concave or not), with no triangulation. +fn draw_vp_mask(c: &mut VpContent, p: &egui::Painter, outline: &[Vec2], xf_paper: &Xf) { + let key = { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + outline.iter().for_each(|q| (q.x.to_bits(), q.y.to_bits()).hash(&mut h)); + h.finish() + }; + if c.mask_key != Some(key) { + let mut fan = DisplayList::default(); + if let Some((&first, rest)) = outline.split_first() { + for w in rest.windows(2) { + if let [a, b] = w { + fan.tris.extend_from_slice(&[first, *a, *b]); + } + } + } + fan.bounds = Bounds2::from_points(outline.iter().copied()); + let len = u32::try_from(fan.tris.len()).unwrap_or(0); + fan.prims.push(cadcraft_render::DPrim { + handle: Handle(0), + color: Rgb(0, 0, 0), + aci7: false, + lw: 0.0, + transparency: 0, + kind: Kind::Tris, + start: 0, + len, + }); + let origin = if fan.bounds.is_empty() { Vec2::ZERO } else { fan.bounds.center() }; + if let Ok(mut slot) = c.mask_slot.lock() { + *slot = Some(crate::gpu::build_mesh(&fan, origin, Rgb(0, 0, 0), false)); + } + c.mask_key = Some(key); + c.mask_origin = origin; + } + let center = xf_paper.rect.center(); + p.add(egui_wgpu::Callback::new_paint_callback( + xf_paper.rect, + crate::gpu::CanvasCallback { + key, + slot: c.mask_slot.clone(), + offset: [(c.mask_origin.x - xf_paper.center.x) as f32, (c.mask_origin.y - xf_paper.center.y) as f32], + scale: xf_paper.scale as f32, + center: [center.x, center.y], + layer: crate::gpu::LAYER_MASK, + xform: crate::gpu::IDENTITY_XFORM, + dash: 0.0, + stencil: crate::gpu::Stencil::Mask, + }, + )); +} + +/// Tessellation tolerance for the highlights inside the active viewport: `scale` (pixels per +/// drawing unit) snapped to its 2x zoom band, as in [`ensure_list`], so zooming and panning +/// inside the band doesn't rebuild the geometry. +pub(super) fn viewport_highlight_tolerance(scale: f64) -> f64 { + 2f64.powi((1.0 / scale.max(1e-300)).log2().floor() as i32) * 0.5 +} + +/// The selected or the hovered objects tessellated in model units for the active MSPACE +/// viewport, whose contents can't be looked up by handle in the layout's lists. The selection and +/// the hover are kept apart, so moving the cursor over objects doesn't tessellate the selection +/// again; each is rebuilt only when the drawing, the objects or the zoom band change. +#[derive(Default)] +pub(super) struct VpHighlight { + key: Option, + pub(super) list: DisplayList, + /// Bumped on each rebuild (identifies `list` for [`draw_highlight_gpu`]). + pub(super) list_gen: u64, +} + +impl VpHighlight { + pub(super) fn update(&mut self, st: &cadcraft_engine::DocState, handles: &[Handle], scale: f64) { + let tolerance = viewport_highlight_tolerance(scale); + let key = { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + (st.uid, st.revision, std::sync::Arc::as_ptr(&st.doc) as usize, tolerance.to_bits(), handles).hash(&mut h); + h.finish() + }; + if self.key != Some(key) { + let ents = handles.iter().filter_map(|h| st.doc.model.get(*h)).map(|e| e.as_ref()); + self.list = cadcraft_render::build_entities(&st.doc, ents, &cadcraft_render::Options { tolerance, ..Default::default() }); + self.key = Some(key); + self.list_gen = self.list_gen.wrapping_add(1); + } + } +} diff --git a/crates/ui-egui/src/canvas/widgets.rs b/crates/ui-egui/src/canvas/widgets.rs new file mode 100644 index 000000000..cd55a7f67 --- /dev/null +++ b/crates/ui-egui/src/canvas/widgets.rs @@ -0,0 +1,86 @@ +use super::draw::draw_list; +use super::grid::draw_grid; +use super::*; + +pub(super) fn tile_rects(app: &CadApp, full: Rect) -> Vec<(Rect, cadcraft_engine::ModelTile)> { + let x = |f: f64| full.left() + full.width() * f.clamp(0.0, 1.0) as f32; + let y = |f: f64| full.bottom() - full.height() * f.clamp(0.0, 1.0) as f32; + app.session + .model_tiles() + .into_iter() + .map(|t| (Rect::from_min_max(pos2(x(t.min.x), y(t.max.y)), pos2(x(t.max.x), y(t.min.y))).shrink(1.0), t)) + .collect() +} + +/// The world ↔ screen transform of a model tile drawn in `r`. +pub(super) fn tile_xf(r: Rect, tile: &cadcraft_engine::ModelTile) -> Xf { + Xf { rect: r, center: tile.view.center, scale: f64::from(r.height()) / tile.view.height.max(1e-12) } +} + +/// The tiles other than the current one (each with its own view, clipped to it), then the +/// tile borders: thin, and heavy around the current tile. +pub(super) fn draw_tiles(app: &CadApp, ui: &egui::Ui, tiles: &[(Rect, cadcraft_engine::ModelTile)]) { + let t = Tokens::get(); + let bg = Rgb(t.canvas.r(), t.canvas.g(), t.canvas.b()); + for (r, tile) in tiles.iter().filter(|(r, tile)| !tile.active && r.width() >= 1.0 && r.height() >= 1.0) { + let p = ui.painter_at(*r); + let xf = tile_xf(*r, tile); + p.rect_filled(*r, 0.0, t.canvas); + draw_grid(app, &p, &xf); + if let Some(list) = &app.canvas.list { + draw_list(&p, &xf, list, bg, app.session.settings.lwdisplay); + } + viewport_label(&p, *r); + } + let p = ui.painter(); + for (r, tile) in tiles { + let (w, c) = if tile.active { (2.0, t.accent) } else { (1.0, t.border) }; + p.rect_stroke(*r, 0.0, Stroke::new(w, c), egui::StrokeKind::Outside); + } +} + +/// A small padlock, drawn in code: body plus shackle. +pub(super) fn draw_lock(p: &egui::Painter, c: Pos2, color: Color32) { + p.rect_filled(Rect::from_center_size(c + vec2(0.0, 1.5), vec2(7.0, 5.0)), 1.0, color); + p.add(Shape::line( + vec![pos2(c.x - 2.0, c.y - 1.0), pos2(c.x - 2.0, c.y - 4.0), pos2(c.x + 2.0, c.y - 4.0), pos2(c.x + 2.0, c.y - 1.0)], + Stroke::new(1.2, color), + )); +} + +pub(super) fn draw_ucs_icon(p: &egui::Painter, rect: Rect) { + let t = Tokens::get(); + let o = pos2(rect.left() + 36.0, rect.bottom() - 34.0); + let st = Stroke::new(1.0, t.canvas_ink); + p.line_segment([o, o + vec2(60.0, 0.0)], st); + p.line_segment([o, o + vec2(0.0, -60.0)], st); + p.rect_stroke(Rect::from_center_size(o, vec2(9.0, 9.0)), 0.0, st, egui::StrokeKind::Middle); + let f = egui::FontId::proportional(13.0); + p.text(o + vec2(70.0, 0.0), egui::Align2::LEFT_CENTER, crate::tl!("X"), f.clone(), t.canvas_ink); + p.text(o + vec2(0.0, -70.0), egui::Align2::CENTER_BOTTOM, crate::tl!("Y"), f, t.canvas_ink); +} + +pub(super) fn viewport_label(p: &egui::Painter, rect: Rect) { + let t = Tokens::get(); + let at = pos2(rect.left() + 10.0, rect.top() + 8.0); + p.text(at, egui::Align2::LEFT_TOP, crate::tl!("+ | Top | 2D Wireframe"), crate::theme::small(), t.canvas_ink); +} + +pub(super) fn draw_viewcube(app: &mut CadApp, ui: &mut egui::Ui, rect: Rect) { + let l = crate::viewcube::Layout::new(rect); + let (c, ring) = (l.center, l.ring); + let p = ui.painter_at(rect); + crate::viewcube::show(app, ui, &l); + // WCS pill: the label and a down chevron, drawn as lines rather than a "⌄" glyph that a UI + // font may lack (it showed as a box, #1541). + let pill = Rect::from_center_size(c + vec2(0.0, ring + 26.0), vec2(56.0, 16.0)); + p.rect_filled(pill, 8.0, Color32::from_rgb(0x48, 0x50, 0x5c)); + let ink = Tokens::DARK.canvas_ink; + let label = p.layout_no_wrap(crate::tl!("WCS").to_string(), crate::theme::small(), ink); + let (gap, chevron) = (4.0, 6.0); + let left = pill.center().x - (label.size().x + gap + chevron) / 2.0; + let mid = pill.center().y; + p.galley(pos2(left, mid - label.size().y / 2.0), label.clone(), ink); + let x = left + label.size().x + gap; + p.line(vec![pos2(x, mid - 1.5), pos2(x + chevron / 2.0, mid + 1.5), pos2(x + chevron, mid - 1.5)], Stroke::new(1.2, ink)); +} diff --git a/crates/ui-egui/src/capture.rs b/crates/ui-egui/src/capture.rs index 4bca367b4..0a31ac2f0 100644 --- a/crates/ui-egui/src/capture.rs +++ b/crates/ui-egui/src/capture.rs @@ -566,481 +566,4 @@ pub(crate) fn request_hidden_paint(app: &CadApp, ctx: &egui::Context) { } #[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); - } -} +mod tests; diff --git a/crates/ui-egui/src/capture/tests.rs b/crates/ui-egui/src/capture/tests.rs new file mode 100644 index 000000000..6e70aea7b --- /dev/null +++ b/crates/ui-egui/src/capture/tests.rs @@ -0,0 +1,469 @@ +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 0e79f8444..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.scope_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)); diff --git a/crates/ui-egui/src/credits.rs b/crates/ui-egui/src/credits.rs index 6c3276c91..28d08269a 100644 --- a/crates/ui-egui/src/credits.rs +++ b/crates/ui-egui/src/credits.rs @@ -223,7 +223,7 @@ pub fn contributors_ui(ui: &mut egui::Ui) { } ui.separator(); ui.label(crate::tl!("Sort")); - egui::ComboBox::from_id_salt("credits_sort").selected_text(crate::i18n::t(v.key.label().0)).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("credits_sort").selected_text(crate::i18n::t(v.key.label().0)).show_ui(ui, |ui| { for k in SortKey::ALL { if ui.selectable_label(v.key == k, crate::i18n::t(k.label().0)).clicked() { v.key = k; diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 37de4f197..acdddb552 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -86,7 +86,7 @@ fn dsettings(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { let mut deg = current; ui.horizontal(|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| { + let combo = craft_ui::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}")); } @@ -231,7 +231,7 @@ const INSERTION_UNITS: [&str; 25] = [ /// A combo box over `(value, label)` options. fn pick(ui: &mut egui::Ui, salt: &str, value: &mut i64, options: &[(i64, String)]) { let current = options.iter().find(|o| o.0 == *value).map(|o| o.1.clone()).unwrap_or_default(); - egui::ComboBox::from_id_salt(salt).selected_text(current).width(180.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt(salt).selected_text(current).width(180.0).show_ui(ui, |ui| { for (k, label) in options { ui.selectable_value(value, *k, label); } @@ -431,7 +431,7 @@ 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::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| { + craft_ui::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() { let _ = app.run("ui.language", serde_json::json!({"lang": preference.code()})); diff --git a/crates/ui-egui/src/docking.rs b/crates/ui-egui/src/docking.rs index 6c1e624b5..bbbf08902 100644 --- a/crates/ui-egui/src/docking.rs +++ b/crates/ui-egui/src/docking.rs @@ -2,8 +2,8 @@ //! Document contents remain owned by their existing view implementations. use craft_ui::docking::{ - DockArea, DockCommand, DockOperation, DockStyle, FloatSpace, Layout, Location, Node, PanelLimits, Permissions, Placement, StackEntry, UiDock, - Zone, + 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}; @@ -43,6 +43,18 @@ impl Panel { Self::ExternalRefs => "External References", } } + /// The icon a palette shows in a column collapsed to icons. + pub fn icon(self) -> Option { + use crate::icons::Icon; + match self { + 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]; } @@ -63,31 +75,106 @@ fn split(axis: SplitAxis, size: SplitSize, first: Node, second: Node; -/// Tool Sets on the left, the drawing, and the Layers and Properties accordion on the right. +/// 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)] } +} + +/// 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(220.0), - tabs(Panel::ToolSets), + SplitSize::FixedFirst(TOOL_SETS_WIDTH), + dock(Side::Start, TOOL_SETS_WIDTH, &[Panel::ToolSets]), split( SplitAxis::Horizontal, - SplitSize::FixedSecond(300.0), + SplitSize::FixedSecond(PALETTES_WIDTH), tabs(Panel::Canvas), - Node::Stack { - entries: vec![ - StackEntry { panel: Panel::Layers, open: true, height: Some(280.0) }, - StackEntry { panel: Panel::Properties, open: true, height: None }, - ], - }, + 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)) + } +} + +/// 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 }] +} + /// craft-ui's checks plus the drawing view's: a saved or hand-edited file cannot hide, float, -/// group or collapse it, or restore a palette over it. +/// 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) { @@ -124,11 +211,17 @@ pub fn validate(workspace: &Workspace) -> Result<(), String> { Ok(()) } -/// Show a panel and select it at its recorded place (craft-ui places a panel it has no place -/// for), keeping the drawing view's rules. +/// 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(); - next.set_visible(panel, true, None, permissions).map_err(|error| error.to_string())?; + 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(()) @@ -188,7 +281,7 @@ fn sync(workspace: &mut Workspace, app: &crate::CadApp) -> Result<(), String> { 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. + // Reopen the palette that closed last before the one recorded beside it. set_visible(workspace, Panel::Properties, true)?; set_visible(workspace, Panel::Layers, true)?; } @@ -207,6 +300,10 @@ pub fn command(app: &mut crate::CadApp, reset: bool, params: &Value) -> Result { - ui.painter().rect_filled(ui.max_rect(), 0.0, t.canvas); - crate::canvas::show(app, ui); + 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); } - Panel::ToolSets => crate::palettes::toolsets(app, ui), - Panel::QuickCalc => crate::palettes::quickcalc::show(app, ui), - Panel::Layers => { - egui::ScrollArea::vertical() - .id_salt("dock-layers") - .auto_shrink([false, false]) - .show(ui, |ui| crate::palettes::layers_section(app, ui)); - } - Panel::Properties => { - egui::ScrollArea::vertical().id_salt("dock-properties").auto_shrink([false, false]).show(ui, |ui| { - crate::shortcut_menus::palette(app, ui, crate::palettes::properties_section, crate::shortcut_menus::properties_entries) - }); - } - 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(); @@ -290,6 +378,64 @@ pub fn show(app: &mut crate::CadApp, ui: &mut egui::Ui) { } } +/// 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, crate::theme::Tokens::get().canvas); + crate::canvas::show(app, ui); + } + Panel::ToolSets => crate::palettes::toolsets(app, ui), + Panel::QuickCalc => crate::palettes::quickcalc::show(app, ui), + Panel::Layers => { + egui::ScrollArea::vertical() + .id_salt("dock-layers") + .auto_shrink([false, false]) + .show(ui, |ui| crate::palettes::layers_section(app, ui)); + } + Panel::Properties => { + egui::ScrollArea::vertical().id_salt("dock-properties").auto_shrink([false, false]).show(ui, |ui| { + crate::shortcut_menus::palette(app, ui, crate::palettes::properties_section, crate::shortcut_menus::properties_entries) + }); + } + Panel::ExternalRefs => { + egui::ScrollArea::both().id_salt("dock-xrefs").auto_shrink([false, false]).show(ui, |ui| crate::xrefs::section(app, ui)); + } + } + } + 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 { let (min, max_width) = match panel { Panel::Canvas => (egui::vec2(300.0, 180.0), 1_000_000.0), diff --git a/crates/ui-egui/src/hatchdlg.rs b/crates/ui-egui/src/hatchdlg.rs index 1fff4c627..ff1c72251 100644 --- a/crates/ui-egui/src/hatchdlg.rs +++ b/crates/ui-egui/src/hatchdlg.rs @@ -498,7 +498,7 @@ fn fill_ui(ui: &mut egui::Ui, f: &mut Fill, action: &mut Action) { } ui.end_row(); ui.label(crate::tl!("Shape")); - egui::ComboBox::from_id_salt("hatch_shape").selected_text(shape_label(&f.shape)).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("hatch_shape").selected_text(shape_label(&f.shape)).show_ui(ui, |ui| { for name in SHAPES { ui.selectable_value(&mut f.shape, name.to_string(), shape_label(name)); } @@ -545,7 +545,7 @@ fn fill_ui(ui: &mut egui::Ui, f: &mut Fill, action: &mut Action) { f.origin = None; } ui.add_enabled_ui(f.origin.is_none(), |ui| { - egui::ComboBox::from_id_salt("hatch_origin_mode").selected_text(origin_label(&f.origin_mode)).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("hatch_origin_mode").selected_text(origin_label(&f.origin_mode)).show_ui(ui, |ui| { for m in ORIGIN_MODES { ui.selectable_value(&mut f.origin_mode, m.to_string(), origin_label(m)); } @@ -601,426 +601,4 @@ fn options_ui(ui: &mut egui::Ui, st: &mut HatchDlgState, action: &mut Action) { } #[cfg(test)] -mod tests { - use cadcraft_color::Color; - use cadcraft_doc::Handle; - use cadcraft_engine::geom::Vec2; - use cadcraft_engine::{HatchPicks, PickMode, Session}; - use serde_json::json; - - use super::*; - - fn session_with_rect() -> (Session, Handle) { - let mut s = Session::new(); - let r = s.execute("rectang", &json!({ "p1": [0, 0], "p2": [10, 10] })).unwrap(); - (s, Handle::parse_hex(r["handle"].as_str().unwrap()).unwrap()) - } - - use crate::{CadApp, Services}; - - fn app_with_rect() -> (CadApp, Handle) { - let (s, r) = session_with_rect(); - (CadApp::new(s, Services::default()), r) - } - - #[test] - fn pick_points_hides_the_dialog_and_brings_it_back_with_the_points() { - let (mut app, _) = app_with_rect(); - open_create(&mut app, false); - start_pick(&mut app, PickMode::Points); - assert_eq!(app.ui.dialog, None, "hidden while picking"); - assert!(app.session.prompt_text().contains("Pick internal point")); - resume(&mut app); - assert_eq!(app.ui.dialog, None, "still picking"); - app.session.input(cadcraft_engine::Input::Point(Vec2::new(5.0, 5.0))).unwrap(); - app.session.input(cadcraft_engine::Input::Enter).unwrap(); - resume(&mut app); - assert_eq!(app.ui.dialog.as_deref(), Some("hatch")); - let st = app.hatch_dlg.clone().unwrap(); - assert_eq!((st.points, st.picking), (vec![[5.0, 5.0]], None)); - } - - #[test] - fn escape_brings_the_dialog_back_unchanged() { - let (mut app, r) = app_with_rect(); - open_create(&mut app, false); - if let Some(st) = app.hatch_dlg.as_mut() { - st.handles.push(r); - } - let before = app.hatch_dlg.clone(); - start_pick(&mut app, PickMode::Points); - app.session.input(cadcraft_engine::Input::Point(Vec2::new(5.0, 5.0))).unwrap(); - app.session.cancel(); - resume(&mut app); - assert_eq!(app.ui.dialog.as_deref(), Some("hatch")); - assert_eq!(app.hatch_dlg, before); - } - - #[test] - fn switching_drawings_drops_the_dialog() { - let (mut app, _) = app_with_rect(); - open_create(&mut app, false); - start_pick(&mut app, PickMode::Points); - app.session.input(cadcraft_engine::Input::Point(Vec2::new(5.0, 5.0))).unwrap(); - app.session.input(cadcraft_engine::Input::Enter).unwrap(); - app.session.new_drawing(false); - resume(&mut app); - assert_eq!(app.ui.dialog, None); - assert_eq!(app.hatch_dlg, None); - } - - #[test] - fn hatch_pick_opens_edit_mode_or_nothing() { - let (mut app, _) = app_with_rect(); - let hx = app.session.execute("hatch", &json!({ "points": [[5, 5]] })).unwrap()["handle"].as_str().unwrap().to_string(); - open_create(&mut app, false); - start_pick(&mut app, PickMode::Hatch); - app.session.input(cadcraft_engine::Input::Pick(vec![Handle::parse_hex(&hx).unwrap()])).unwrap(); - resume(&mut app); - assert_eq!(app.ui.dialog.as_deref(), Some("hatch")); - assert_eq!(app.hatch_dlg.clone().unwrap().edit, vec![Handle::parse_hex(&hx).unwrap()]); - // Nothing picked: no dialog. - start_pick(&mut app, PickMode::Hatch); - app.session.input(cadcraft_engine::Input::Enter).unwrap(); - resume(&mut app); - assert_eq!((app.ui.dialog.clone(), app.hatch_dlg.clone()), (None, None)); - } - - #[test] - fn create_state_reads_the_header_and_survives_bad_values() { - let (mut s, _) = session_with_rect(); - for (k, v) in [("HPNAME", json!("BRICK")), ("HPSCALE", json!(2.5)), ("HPANG", json!(30)), ("HPSEPARATE", json!(1)), ("HPASSOC", json!(0))] { - s.execute("setvar", &json!({ "name": k, "value": v })).unwrap(); - } - let st = create_state(s.doc().unwrap(), 7, false); - let f = &st.fill; - assert_eq!((f.pattern.as_str(), f.scale, f.associative, st.separate, st.uid), ("BRICK", 2.5, false, true, 7)); - assert!((f.angle - 30.0).abs() < 1e-9); - assert!(st.edit.is_empty() && st.points.is_empty() && st.handles.is_empty()); - assert_eq!(st.fill, st.opened); - // Hostile header values from a file. - let h = &mut s.doc_mut().unwrap().header; - h.set_f64("HPSCALE", f64::NAN); - h.set_f64("HPANG", f64::INFINITY); - h.set_i64("HPORIGINMODE", i64::MIN); - h.set_i64("HPISLANDDETECTION", -5); - let st = create_state(s.doc().unwrap(), 7, true); - // An HPORIGINMODE out of 0–5 is "use current", as HATCH reads it. - assert_eq!((st.fill.scale, st.fill.angle, st.fill.style, st.fill.specified), (1.0, 0.0, 0, false)); - assert!(ORIGIN_MODES.contains(&st.fill.origin_mode.as_str())); - assert!(st.fill.gradient); - s.doc_mut().unwrap().header.set_f64("HPSCALE", 0.0); - s.doc_mut().unwrap().header.set_i64("HPORIGINMODE", 99); - let st = create_state(s.doc().unwrap(), 7, false); - assert_eq!((st.fill.scale, st.fill.specified), (1.0, false)); - assert!(ORIGIN_MODES.contains(&st.fill.origin_mode.as_str())); - s.doc_mut().unwrap().header.set_i64("HPORIGINMODE", 3); - let st = create_state(s.doc().unwrap(), 7, false); - assert_eq!((st.fill.specified, st.fill.origin_mode.as_str()), (true, "topRight")); - } - - #[test] - fn create_ok_hatches_points_and_objects_in_one_call() { - let (s, r) = session_with_rect(); - let mut st = create_state(s.doc().unwrap(), 1, false); - assert_eq!(ok_call(&st), None, "no boundary yet"); - st.points.push([5.0, 5.0]); - st.handles.push(r); - st.fill.pattern = "ANSI37".into(); - st.fill.scale = 2.0; - st.fill.angle = 15.0; - st.fill.style = 1; - let (cmd, p) = ok_call(&st).unwrap(); - assert_eq!(cmd, "hatch"); - assert_eq!(p["points"], json!([[5.0, 5.0]])); - assert_eq!(p["handles"], json!([r.hex()])); - assert_eq!((p["pattern"].as_str(), p["scale"].as_f64(), p["angle"].as_f64()), (Some("ANSI37"), Some(2.0), Some(15.0))); - assert_eq!((p["style"].as_str(), p["originMode"].as_str()), (Some("outer"), Some("current"))); - // A clicked origin, then a corner of the extents. - st.fill.specified = true; - st.fill.origin = Some([1.0, 2.0]); - assert_eq!(ok_call(&st).unwrap().1["origin"], json!([1.0, 2.0])); - st.fill.origin = None; - st.fill.origin_mode = "topRight".into(); - assert_eq!(ok_call(&st).unwrap().1["originMode"], json!("topRight")); - // Gradient. - st.fill.gradient = true; - st.fill.shape = "SPHERICAL".into(); - st.fill.inverted = true; - st.fill.color1 = Color::Index(1); - st.fill.centered = false; - let (cmd, p) = ok_call(&st).unwrap(); - assert_eq!(cmd, "gradient"); - assert_eq!((p["name"].as_str(), p["color1"].as_str(), p["centered"].as_bool()), (Some("INVSPHERICAL"), Some("Red"), Some(false))); - assert!(p.get("pattern").is_none()); - } - - #[test] - fn create_ok_runs_and_makes_one_hatch() { - let (mut s, _) = session_with_rect(); - s.execute("circle", &json!({ "center": [30, 5], "radius": 3 })).unwrap(); - let c = s.doc().unwrap().model.iter().last().unwrap().handle; - let mut st = create_state(s.doc().unwrap(), 1, false); - st.points.push([5.0, 5.0]); - st.handles.push(c); - let (cmd, p) = ok_call(&st).unwrap(); - s.execute(cmd, &p).unwrap(); - let n = s.doc().unwrap().model.iter().filter(|e| matches!(e.kind, cadcraft_doc::EntityKind::Hatch(_))).count(); - assert_eq!(n, 1); - } - - #[test] - fn edit_ok_sends_only_what_changed() { - let (mut s, _) = session_with_rect(); - let h = s.execute("hatch", &json!({ "points": [[5, 5]], "pattern": "ANSI31", "scale": 2 })).unwrap()["handle"].as_str().unwrap().to_string(); - let h = Handle::parse_hex(&h).unwrap(); - let mut st = edit_state(s.doc().unwrap(), 1, vec![h]).unwrap(); - assert_eq!((st.fill.pattern.as_str(), st.fill.scale, st.edit.clone()), ("ANSI31", 2.0, vec![h])); - assert_eq!(ok_call(&st), None, "nothing changed: OK runs nothing"); - st.fill.scale = 3.0; - let (cmd, p) = ok_call(&st).unwrap(); - assert_eq!(cmd, "hatchedit"); - assert_eq!(p, json!({ "handles": [h.hex()], "scale": 3.0 })); - // To a gradient: the whole gradient, no pattern fields. - st.fill.scale = 2.0; - st.fill.gradient = true; - let (_, p) = ok_call(&st).unwrap(); - assert_eq!(p["gradient"]["name"], json!("LINEAR")); - assert!(p.get("pattern").is_none() && p.get("scale").is_none()); - s.execute("hatchedit", &p).unwrap(); - // Back from a gradient hatch: the pattern is sent even if its name didn't change. - let mut st = edit_state(s.doc().unwrap(), 1, vec![h]).unwrap(); - assert!(st.fill.gradient); - st.fill.gradient = false; - st.fill.pattern = "ANSI31".into(); - assert_eq!(ok_call(&st).unwrap().1["pattern"], json!("ANSI31")); - // Not a hatch: no edit state. - let line = s.execute("line", &json!({ "points": [[20, 0], [30, 0]] })).unwrap()["handles"][0].as_str().unwrap().to_string(); - assert!(edit_state(s.doc().unwrap(), 1, vec![Handle::parse_hex(&line).unwrap()]).is_none()); - } - - #[test] - fn edit_dialog_sends_boundary_additions_and_removals() { - let (mut s, outer) = session_with_rect(); - let island = s.execute("rectang", &json!({ "p1": [2, 2], "p2": [4, 4] })).unwrap()["handle"].as_str().unwrap().to_string(); - let h = s.execute("hatch", &json!({ "handles": [outer.hex()] })).unwrap()["handle"].as_str().unwrap().to_string(); - let h = Handle::parse_hex(&h).unwrap(); - let mut st = edit_state(s.doc().unwrap(), 1, vec![h]).unwrap(); - assert_eq!(st.loop_outer, vec![true]); - merge_picks(&mut st, HatchPicks { mode: PickMode::Objects, handles: vec![Handle::parse_hex(&island).unwrap()], ..HatchPicks::default() }); - let (cmd, p) = ok_call(&st).unwrap(); - assert_eq!(cmd, "hatchedit"); - assert_eq!(p["addBoundary"], json!([island])); - s.execute(cmd, &p).unwrap(); - assert_eq!(edit_state(s.doc().unwrap(), 1, vec![h]).unwrap().loop_outer, vec![true, false]); - - let mut st = edit_state(s.doc().unwrap(), 1, vec![h]).unwrap(); - st.remove_loops.push(1); - let (_, p) = ok_call(&st).unwrap(); - assert_eq!(p["removeLoops"], json!([1])); - s.execute("hatchedit", &p).unwrap(); - assert_eq!(edit_state(s.doc().unwrap(), 1, vec![h]).unwrap().loop_outer, vec![true]); - } - - #[test] - fn picks_merge_into_the_state() { - let (s, r) = session_with_rect(); - let mut st = create_state(s.doc().unwrap(), 1, false); - st.error = Some("old".into()); - merge_picks(&mut st, HatchPicks { mode: PickMode::Points, points: vec![Vec2::new(5.0, 5.0)], ..HatchPicks::default() }); - merge_picks(&mut st, HatchPicks { mode: PickMode::Objects, handles: vec![r, r], ..HatchPicks::default() }); - merge_picks(&mut st, HatchPicks { mode: PickMode::Objects, handles: vec![r], ..HatchPicks::default() }); - assert_eq!((st.points.clone(), st.handles.clone(), st.error.clone()), (vec![[5.0, 5.0]], vec![r], None)); - merge_picks(&mut st, HatchPicks { mode: PickMode::Origin, origin: Some(Vec2::new(1.0, 2.0)), ..HatchPicks::default() }); - assert_eq!((st.fill.specified, st.fill.origin), (true, Some([1.0, 2.0]))); - } - - fn frames(app: &mut CadApp) -> egui::Context { - let ctx = egui::Context::default(); - app.logic(&ctx); - let input = - egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 1000.0))), ..Default::default() }; - // Enough frames for a window that grows each frame to show it. - for _ in 0..30 { - let mut output = ctx.run_ui(input.clone(), |ui| app.ui(ui)); - output.textures_delta.clear(); - } - ctx - } - - #[test] - fn the_dialog_fits_on_screen_with_its_buttons() { - for gradient in [false, true] { - 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::unique("hatch_dialog"))).unwrap(); - // The screen is 1000 points high: the window keeps to its content. - assert!(r.height() < 700.0, "gradient {gradient}: {r:?}"); - } - } - - #[test] - fn the_dialog_draws_both_modes_and_tabs_and_stays_open() { - let (mut app, _) = app_with_rect(); - // Opened by name with no state: create mode from the header. - app.ui.dialog = Some("hatch".into()); - frames(&mut app); - assert_eq!(app.ui.dialog.as_deref(), Some("hatch")); - assert!(app.hatch_dlg.as_ref().is_some_and(|s| s.edit.is_empty())); - if let Some(st) = app.hatch_dlg.as_mut() { - st.fill.gradient = true; - } - frames(&mut app); - assert_eq!(app.ui.dialog.as_deref(), Some("hatch")); - let hx = app.session.execute("hatch", &json!({ "points": [[5, 5]] })).unwrap()["handle"].as_str().unwrap().to_string(); - let st = app.session.state().unwrap(); - app.hatch_dlg = edit_state(&st.doc, st.uid, vec![Handle::parse_hex(&hx).unwrap()]); - frames(&mut app); - assert_eq!(app.ui.dialog.as_deref(), Some("hatch")); - } - - #[test] - fn ok_makes_the_hatch_and_closes() { - let (mut app, _) = app_with_rect(); - open_create(&mut app, false); - if let Some(st) = app.hatch_dlg.as_mut() { - st.points.push([5.0, 5.0]); - } - assert!(ok(&mut app)); - assert_eq!(app.hatch_dlg, None); - assert!(app.session.doc().unwrap().model.iter().any(|e| matches!(e.kind, cadcraft_doc::EntityKind::Hatch(_)))); - } - - #[test] - fn a_failed_ok_keeps_the_dialog_open_with_the_error() { - let (mut app, r) = app_with_rect(); - open_create(&mut app, false); - if let Some(st) = app.hatch_dlg.as_mut() { - st.handles.push(r); - } - // The picked boundary is erased before OK. - app.session.execute("erase", &json!({ "handles": [r.hex()] })).unwrap(); - let n = app.session.doc().unwrap().model.len(); - assert!(!ok(&mut app)); - let st = app.hatch_dlg.clone().unwrap(); - assert!(st.error.is_some_and(|e| !e.is_empty())); - assert_eq!(app.session.doc().unwrap().model.len(), n, "drawing unchanged"); - } - - #[test] - fn typed_and_menu_hatch_open_the_dialog_but_dash_and_json_do_not() { - let (mut app, _) = app_with_rect(); - app.cmdline("hatch"); - assert_eq!(app.ui.dialog.as_deref(), Some("hatch")); - assert!(app.session.running.is_none()); - assert!(!app.hatch_dlg.clone().unwrap().fill.gradient); - for (typed, gradient) in [("h", false), ("bh", false), ("gradient", true), ("gd", true)] { - app.ui.dialog = None; - app.cmdline(typed); - assert_eq!(app.ui.dialog.as_deref(), Some("hatch"), "{typed}"); - assert_eq!(app.hatch_dlg.clone().unwrap().fill.gradient, gradient, "{typed}"); - } - app.ui.dialog = None; - app.start("hatch"); - assert_eq!(app.ui.dialog.as_deref(), Some("hatch"), "menu"); - // -HATCH: the command line. - app.ui.dialog = None; - app.cmdline("-hatch"); - assert_eq!(app.ui.dialog, None); - assert!(app.session.prompt_text().contains("Pick internal point"), "{}", app.session.prompt_text()); - app.session.cancel(); - // JSON: runs, never opens. - app.run("hatch", json!({ "points": [[5, 5]] })).unwrap(); - assert_eq!(app.ui.dialog, None); - app.cmdline("hatch {\"points\": [[5, 5]]}"); - assert_eq!(app.ui.dialog, None); - // HPDLGMODE 0: the command line. - app.run("setvar", json!({ "name": "HPDLGMODE", "value": 0 })).unwrap(); - app.cmdline("hatch"); - assert_eq!(app.ui.dialog, None); - assert!(app.session.running.is_some()); - } - - #[test] - fn hatchedit_opens_edit_mode_on_the_selection_or_asks_for_a_hatch() { - let (mut app, _) = app_with_rect(); - let hx = app.session.execute("hatch", &json!({ "points": [[5, 5]] })).unwrap()["handle"].as_str().unwrap().to_string(); - let h = Handle::parse_hex(&hx).unwrap(); - // Nothing selected: pick one first. - app.cmdline("hatchedit"); - assert_eq!(app.ui.dialog, None); - assert!(app.session.prompt_text().contains("Select hatch object"), "{}", app.session.prompt_text()); - app.session.input(cadcraft_engine::Input::Pick(vec![h])).unwrap(); - resume(&mut app); - assert_eq!(app.ui.dialog.as_deref(), Some("hatch")); - assert_eq!(app.hatch_dlg.clone().unwrap().edit, vec![h]); - // Selected (right-click Hatch Edit, typed HE), also with HPDLGMODE 0. - app.ui.dialog = None; - app.hatch_dlg = None; - app.run("setvar", json!({ "name": "HPDLGMODE", "value": 0 })).unwrap(); - app.session.set_selection(vec![h]); - app.start("he"); - assert_eq!(app.ui.dialog.as_deref(), Some("hatch")); - // OK applies the change. - if let Some(st) = app.hatch_dlg.as_mut() { - st.fill.pattern = "BRICK".into(); - } - assert!(ok(&mut app)); - let d = app.session.doc().unwrap(); - assert!(matches!(&d.entity(h).unwrap().kind, cadcraft_doc::EntityKind::Hatch(x) if x.pattern == "BRICK")); - // -HATCHEDIT: the command line (the window, not `ok`, closes the dialog). - app.ui.dialog = None; - app.cmdline("-hatchedit"); - assert_eq!(app.ui.dialog, None); - assert!(app.session.running.is_some()); - } - - #[test] - fn edit_mode_on_an_out_of_range_hatch_changes_nothing_by_itself() { - let (mut app, _) = app_with_rect(); - let hx = app.session.execute("hatch", &json!({ "points": [[5, 5]], "scale": 5.0e6, "angle": 400 })).unwrap()["handle"] - .as_str() - .unwrap() - .to_string(); - app.session.set_selection(vec![Handle::parse_hex(&hx).unwrap()]); - open_edit(&mut app); - frames(&mut app); - assert_eq!(app.ui.dialog.as_deref(), Some("hatch")); - assert_eq!(app.hatch_dlg.as_ref().and_then(ok_call), None, "the widgets must not clamp the hatch's own values"); - } - - #[test] - fn switching_to_a_layout_drops_the_picks() { - let (mut app, r) = app_with_rect(); - open_create(&mut app, false); - start_pick(&mut app, PickMode::Objects); - app.session.input(cadcraft_engine::Input::Pick(vec![r])).unwrap(); - app.session.input(cadcraft_engine::Input::Enter).unwrap(); - app.run("layout", json!({ "option": "set", "name": "Layout1" })).unwrap(); - resume(&mut app); - assert_eq!((app.ui.dialog.clone(), app.hatch_dlg.clone()), (None, None), "model-space picks never hatch a layout"); - // An open dialog closes too. - app.run("layout", json!({ "option": "set", "name": "Model" })).unwrap(); - open_create(&mut app, false); - app.run("layout", json!({ "option": "set", "name": "Layout1" })).unwrap(); - frames(&mut app); - assert_eq!(app.hatch_dlg, None); - } - - #[test] - fn a_gradient_hatch_offers_the_current_pattern_on_the_pattern_tab() { - let (mut s, _) = session_with_rect(); - let h = s.execute("gradient", &json!({ "points": [[5, 5]] })).unwrap()["handle"].as_str().unwrap().to_string(); - let h = Handle::parse_hex(&h).unwrap(); - let mut st = edit_state(s.doc().unwrap(), 1, vec![h]).unwrap(); - assert_eq!(st.fill.pattern, "ANSI31", "HPNAME, not the gradient's SOLID"); - st.fill.gradient = false; - let (cmd, p) = ok_call(&st).unwrap(); - s.execute(cmd, &p).unwrap(); - match &s.doc().unwrap().entity(h).unwrap().kind { - cadcraft_doc::EntityKind::Hatch(x) => assert!(x.gradient.is_none() && x.pattern == "ANSI31"), - k => panic!("{k:?}"), - } - } -} +mod tests; diff --git a/crates/ui-egui/src/hatchdlg/tests.rs b/crates/ui-egui/src/hatchdlg/tests.rs new file mode 100644 index 000000000..249c49812 --- /dev/null +++ b/crates/ui-egui/src/hatchdlg/tests.rs @@ -0,0 +1,418 @@ +use cadcraft_color::Color; +use cadcraft_doc::Handle; +use cadcraft_engine::geom::Vec2; +use cadcraft_engine::{HatchPicks, PickMode, Session}; +use serde_json::json; + +use super::*; + +fn session_with_rect() -> (Session, Handle) { + let mut s = Session::new(); + let r = s.execute("rectang", &json!({ "p1": [0, 0], "p2": [10, 10] })).unwrap(); + (s, Handle::parse_hex(r["handle"].as_str().unwrap()).unwrap()) +} + +use crate::{CadApp, Services}; + +fn app_with_rect() -> (CadApp, Handle) { + let (s, r) = session_with_rect(); + (CadApp::new(s, Services::default()), r) +} + +#[test] +fn pick_points_hides_the_dialog_and_brings_it_back_with_the_points() { + let (mut app, _) = app_with_rect(); + open_create(&mut app, false); + start_pick(&mut app, PickMode::Points); + assert_eq!(app.ui.dialog, None, "hidden while picking"); + assert!(app.session.prompt_text().contains("Pick internal point")); + resume(&mut app); + assert_eq!(app.ui.dialog, None, "still picking"); + app.session.input(cadcraft_engine::Input::Point(Vec2::new(5.0, 5.0))).unwrap(); + app.session.input(cadcraft_engine::Input::Enter).unwrap(); + resume(&mut app); + assert_eq!(app.ui.dialog.as_deref(), Some("hatch")); + let st = app.hatch_dlg.clone().unwrap(); + assert_eq!((st.points, st.picking), (vec![[5.0, 5.0]], None)); +} + +#[test] +fn escape_brings_the_dialog_back_unchanged() { + let (mut app, r) = app_with_rect(); + open_create(&mut app, false); + if let Some(st) = app.hatch_dlg.as_mut() { + st.handles.push(r); + } + let before = app.hatch_dlg.clone(); + start_pick(&mut app, PickMode::Points); + app.session.input(cadcraft_engine::Input::Point(Vec2::new(5.0, 5.0))).unwrap(); + app.session.cancel(); + resume(&mut app); + assert_eq!(app.ui.dialog.as_deref(), Some("hatch")); + assert_eq!(app.hatch_dlg, before); +} + +#[test] +fn switching_drawings_drops_the_dialog() { + let (mut app, _) = app_with_rect(); + open_create(&mut app, false); + start_pick(&mut app, PickMode::Points); + app.session.input(cadcraft_engine::Input::Point(Vec2::new(5.0, 5.0))).unwrap(); + app.session.input(cadcraft_engine::Input::Enter).unwrap(); + app.session.new_drawing(false); + resume(&mut app); + assert_eq!(app.ui.dialog, None); + assert_eq!(app.hatch_dlg, None); +} + +#[test] +fn hatch_pick_opens_edit_mode_or_nothing() { + let (mut app, _) = app_with_rect(); + let hx = app.session.execute("hatch", &json!({ "points": [[5, 5]] })).unwrap()["handle"].as_str().unwrap().to_string(); + open_create(&mut app, false); + start_pick(&mut app, PickMode::Hatch); + app.session.input(cadcraft_engine::Input::Pick(vec![Handle::parse_hex(&hx).unwrap()])).unwrap(); + resume(&mut app); + assert_eq!(app.ui.dialog.as_deref(), Some("hatch")); + assert_eq!(app.hatch_dlg.clone().unwrap().edit, vec![Handle::parse_hex(&hx).unwrap()]); + // Nothing picked: no dialog. + start_pick(&mut app, PickMode::Hatch); + app.session.input(cadcraft_engine::Input::Enter).unwrap(); + resume(&mut app); + assert_eq!((app.ui.dialog.clone(), app.hatch_dlg.clone()), (None, None)); +} + +#[test] +fn create_state_reads_the_header_and_survives_bad_values() { + let (mut s, _) = session_with_rect(); + for (k, v) in [("HPNAME", json!("BRICK")), ("HPSCALE", json!(2.5)), ("HPANG", json!(30)), ("HPSEPARATE", json!(1)), ("HPASSOC", json!(0))] { + s.execute("setvar", &json!({ "name": k, "value": v })).unwrap(); + } + let st = create_state(s.doc().unwrap(), 7, false); + let f = &st.fill; + assert_eq!((f.pattern.as_str(), f.scale, f.associative, st.separate, st.uid), ("BRICK", 2.5, false, true, 7)); + assert!((f.angle - 30.0).abs() < 1e-9); + assert!(st.edit.is_empty() && st.points.is_empty() && st.handles.is_empty()); + assert_eq!(st.fill, st.opened); + // Hostile header values from a file. + let h = &mut s.doc_mut().unwrap().header; + h.set_f64("HPSCALE", f64::NAN); + h.set_f64("HPANG", f64::INFINITY); + h.set_i64("HPORIGINMODE", i64::MIN); + h.set_i64("HPISLANDDETECTION", -5); + let st = create_state(s.doc().unwrap(), 7, true); + // An HPORIGINMODE out of 0–5 is "use current", as HATCH reads it. + assert_eq!((st.fill.scale, st.fill.angle, st.fill.style, st.fill.specified), (1.0, 0.0, 0, false)); + assert!(ORIGIN_MODES.contains(&st.fill.origin_mode.as_str())); + assert!(st.fill.gradient); + s.doc_mut().unwrap().header.set_f64("HPSCALE", 0.0); + s.doc_mut().unwrap().header.set_i64("HPORIGINMODE", 99); + let st = create_state(s.doc().unwrap(), 7, false); + assert_eq!((st.fill.scale, st.fill.specified), (1.0, false)); + assert!(ORIGIN_MODES.contains(&st.fill.origin_mode.as_str())); + s.doc_mut().unwrap().header.set_i64("HPORIGINMODE", 3); + let st = create_state(s.doc().unwrap(), 7, false); + assert_eq!((st.fill.specified, st.fill.origin_mode.as_str()), (true, "topRight")); +} + +#[test] +fn create_ok_hatches_points_and_objects_in_one_call() { + let (s, r) = session_with_rect(); + let mut st = create_state(s.doc().unwrap(), 1, false); + assert_eq!(ok_call(&st), None, "no boundary yet"); + st.points.push([5.0, 5.0]); + st.handles.push(r); + st.fill.pattern = "ANSI37".into(); + st.fill.scale = 2.0; + st.fill.angle = 15.0; + st.fill.style = 1; + let (cmd, p) = ok_call(&st).unwrap(); + assert_eq!(cmd, "hatch"); + assert_eq!(p["points"], json!([[5.0, 5.0]])); + assert_eq!(p["handles"], json!([r.hex()])); + assert_eq!((p["pattern"].as_str(), p["scale"].as_f64(), p["angle"].as_f64()), (Some("ANSI37"), Some(2.0), Some(15.0))); + assert_eq!((p["style"].as_str(), p["originMode"].as_str()), (Some("outer"), Some("current"))); + // A clicked origin, then a corner of the extents. + st.fill.specified = true; + st.fill.origin = Some([1.0, 2.0]); + assert_eq!(ok_call(&st).unwrap().1["origin"], json!([1.0, 2.0])); + st.fill.origin = None; + st.fill.origin_mode = "topRight".into(); + assert_eq!(ok_call(&st).unwrap().1["originMode"], json!("topRight")); + // Gradient. + st.fill.gradient = true; + st.fill.shape = "SPHERICAL".into(); + st.fill.inverted = true; + st.fill.color1 = Color::Index(1); + st.fill.centered = false; + let (cmd, p) = ok_call(&st).unwrap(); + assert_eq!(cmd, "gradient"); + assert_eq!((p["name"].as_str(), p["color1"].as_str(), p["centered"].as_bool()), (Some("INVSPHERICAL"), Some("Red"), Some(false))); + assert!(p.get("pattern").is_none()); +} + +#[test] +fn create_ok_runs_and_makes_one_hatch() { + let (mut s, _) = session_with_rect(); + s.execute("circle", &json!({ "center": [30, 5], "radius": 3 })).unwrap(); + let c = s.doc().unwrap().model.iter().last().unwrap().handle; + let mut st = create_state(s.doc().unwrap(), 1, false); + st.points.push([5.0, 5.0]); + st.handles.push(c); + let (cmd, p) = ok_call(&st).unwrap(); + s.execute(cmd, &p).unwrap(); + let n = s.doc().unwrap().model.iter().filter(|e| matches!(e.kind, cadcraft_doc::EntityKind::Hatch(_))).count(); + assert_eq!(n, 1); +} + +#[test] +fn edit_ok_sends_only_what_changed() { + let (mut s, _) = session_with_rect(); + let h = s.execute("hatch", &json!({ "points": [[5, 5]], "pattern": "ANSI31", "scale": 2 })).unwrap()["handle"].as_str().unwrap().to_string(); + let h = Handle::parse_hex(&h).unwrap(); + let mut st = edit_state(s.doc().unwrap(), 1, vec![h]).unwrap(); + assert_eq!((st.fill.pattern.as_str(), st.fill.scale, st.edit.clone()), ("ANSI31", 2.0, vec![h])); + assert_eq!(ok_call(&st), None, "nothing changed: OK runs nothing"); + st.fill.scale = 3.0; + let (cmd, p) = ok_call(&st).unwrap(); + assert_eq!(cmd, "hatchedit"); + assert_eq!(p, json!({ "handles": [h.hex()], "scale": 3.0 })); + // To a gradient: the whole gradient, no pattern fields. + st.fill.scale = 2.0; + st.fill.gradient = true; + let (_, p) = ok_call(&st).unwrap(); + assert_eq!(p["gradient"]["name"], json!("LINEAR")); + assert!(p.get("pattern").is_none() && p.get("scale").is_none()); + s.execute("hatchedit", &p).unwrap(); + // Back from a gradient hatch: the pattern is sent even if its name didn't change. + let mut st = edit_state(s.doc().unwrap(), 1, vec![h]).unwrap(); + assert!(st.fill.gradient); + st.fill.gradient = false; + st.fill.pattern = "ANSI31".into(); + assert_eq!(ok_call(&st).unwrap().1["pattern"], json!("ANSI31")); + // Not a hatch: no edit state. + let line = s.execute("line", &json!({ "points": [[20, 0], [30, 0]] })).unwrap()["handles"][0].as_str().unwrap().to_string(); + assert!(edit_state(s.doc().unwrap(), 1, vec![Handle::parse_hex(&line).unwrap()]).is_none()); +} + +#[test] +fn edit_dialog_sends_boundary_additions_and_removals() { + let (mut s, outer) = session_with_rect(); + let island = s.execute("rectang", &json!({ "p1": [2, 2], "p2": [4, 4] })).unwrap()["handle"].as_str().unwrap().to_string(); + let h = s.execute("hatch", &json!({ "handles": [outer.hex()] })).unwrap()["handle"].as_str().unwrap().to_string(); + let h = Handle::parse_hex(&h).unwrap(); + let mut st = edit_state(s.doc().unwrap(), 1, vec![h]).unwrap(); + assert_eq!(st.loop_outer, vec![true]); + merge_picks(&mut st, HatchPicks { mode: PickMode::Objects, handles: vec![Handle::parse_hex(&island).unwrap()], ..HatchPicks::default() }); + let (cmd, p) = ok_call(&st).unwrap(); + assert_eq!(cmd, "hatchedit"); + assert_eq!(p["addBoundary"], json!([island])); + s.execute(cmd, &p).unwrap(); + assert_eq!(edit_state(s.doc().unwrap(), 1, vec![h]).unwrap().loop_outer, vec![true, false]); + + let mut st = edit_state(s.doc().unwrap(), 1, vec![h]).unwrap(); + st.remove_loops.push(1); + let (_, p) = ok_call(&st).unwrap(); + assert_eq!(p["removeLoops"], json!([1])); + s.execute("hatchedit", &p).unwrap(); + assert_eq!(edit_state(s.doc().unwrap(), 1, vec![h]).unwrap().loop_outer, vec![true]); +} + +#[test] +fn picks_merge_into_the_state() { + let (s, r) = session_with_rect(); + let mut st = create_state(s.doc().unwrap(), 1, false); + st.error = Some("old".into()); + merge_picks(&mut st, HatchPicks { mode: PickMode::Points, points: vec![Vec2::new(5.0, 5.0)], ..HatchPicks::default() }); + merge_picks(&mut st, HatchPicks { mode: PickMode::Objects, handles: vec![r, r], ..HatchPicks::default() }); + merge_picks(&mut st, HatchPicks { mode: PickMode::Objects, handles: vec![r], ..HatchPicks::default() }); + assert_eq!((st.points.clone(), st.handles.clone(), st.error.clone()), (vec![[5.0, 5.0]], vec![r], None)); + merge_picks(&mut st, HatchPicks { mode: PickMode::Origin, origin: Some(Vec2::new(1.0, 2.0)), ..HatchPicks::default() }); + assert_eq!((st.fill.specified, st.fill.origin), (true, Some([1.0, 2.0]))); +} + +fn frames(app: &mut CadApp) -> egui::Context { + let ctx = egui::Context::default(); + app.logic(&ctx); + let input = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 1000.0))), ..Default::default() }; + // Enough frames for a window that grows each frame to show it. + for _ in 0..30 { + let mut output = ctx.run_ui(input.clone(), |ui| app.ui(ui)); + output.textures_delta.clear(); + } + ctx +} + +#[test] +fn the_dialog_fits_on_screen_with_its_buttons() { + for gradient in [false, true] { + 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::unique("hatch_dialog"))).unwrap(); + // The screen is 1000 points high: the window keeps to its content. + assert!(r.height() < 700.0, "gradient {gradient}: {r:?}"); + } +} + +#[test] +fn the_dialog_draws_both_modes_and_tabs_and_stays_open() { + let (mut app, _) = app_with_rect(); + // Opened by name with no state: create mode from the header. + app.ui.dialog = Some("hatch".into()); + frames(&mut app); + assert_eq!(app.ui.dialog.as_deref(), Some("hatch")); + assert!(app.hatch_dlg.as_ref().is_some_and(|s| s.edit.is_empty())); + if let Some(st) = app.hatch_dlg.as_mut() { + st.fill.gradient = true; + } + frames(&mut app); + assert_eq!(app.ui.dialog.as_deref(), Some("hatch")); + let hx = app.session.execute("hatch", &json!({ "points": [[5, 5]] })).unwrap()["handle"].as_str().unwrap().to_string(); + let st = app.session.state().unwrap(); + app.hatch_dlg = edit_state(&st.doc, st.uid, vec![Handle::parse_hex(&hx).unwrap()]); + frames(&mut app); + assert_eq!(app.ui.dialog.as_deref(), Some("hatch")); +} + +#[test] +fn ok_makes_the_hatch_and_closes() { + let (mut app, _) = app_with_rect(); + open_create(&mut app, false); + if let Some(st) = app.hatch_dlg.as_mut() { + st.points.push([5.0, 5.0]); + } + assert!(ok(&mut app)); + assert_eq!(app.hatch_dlg, None); + assert!(app.session.doc().unwrap().model.iter().any(|e| matches!(e.kind, cadcraft_doc::EntityKind::Hatch(_)))); +} + +#[test] +fn a_failed_ok_keeps_the_dialog_open_with_the_error() { + let (mut app, r) = app_with_rect(); + open_create(&mut app, false); + if let Some(st) = app.hatch_dlg.as_mut() { + st.handles.push(r); + } + // The picked boundary is erased before OK. + app.session.execute("erase", &json!({ "handles": [r.hex()] })).unwrap(); + let n = app.session.doc().unwrap().model.len(); + assert!(!ok(&mut app)); + let st = app.hatch_dlg.clone().unwrap(); + assert!(st.error.is_some_and(|e| !e.is_empty())); + assert_eq!(app.session.doc().unwrap().model.len(), n, "drawing unchanged"); +} + +#[test] +fn typed_and_menu_hatch_open_the_dialog_but_dash_and_json_do_not() { + let (mut app, _) = app_with_rect(); + app.cmdline("hatch"); + assert_eq!(app.ui.dialog.as_deref(), Some("hatch")); + assert!(app.session.running.is_none()); + assert!(!app.hatch_dlg.clone().unwrap().fill.gradient); + for (typed, gradient) in [("h", false), ("bh", false), ("gradient", true), ("gd", true)] { + app.ui.dialog = None; + app.cmdline(typed); + assert_eq!(app.ui.dialog.as_deref(), Some("hatch"), "{typed}"); + assert_eq!(app.hatch_dlg.clone().unwrap().fill.gradient, gradient, "{typed}"); + } + app.ui.dialog = None; + app.start("hatch"); + assert_eq!(app.ui.dialog.as_deref(), Some("hatch"), "menu"); + // -HATCH: the command line. + app.ui.dialog = None; + app.cmdline("-hatch"); + assert_eq!(app.ui.dialog, None); + assert!(app.session.prompt_text().contains("Pick internal point"), "{}", app.session.prompt_text()); + app.session.cancel(); + // JSON: runs, never opens. + app.run("hatch", json!({ "points": [[5, 5]] })).unwrap(); + assert_eq!(app.ui.dialog, None); + app.cmdline("hatch {\"points\": [[5, 5]]}"); + assert_eq!(app.ui.dialog, None); + // HPDLGMODE 0: the command line. + app.run("setvar", json!({ "name": "HPDLGMODE", "value": 0 })).unwrap(); + app.cmdline("hatch"); + assert_eq!(app.ui.dialog, None); + assert!(app.session.running.is_some()); +} + +#[test] +fn hatchedit_opens_edit_mode_on_the_selection_or_asks_for_a_hatch() { + let (mut app, _) = app_with_rect(); + let hx = app.session.execute("hatch", &json!({ "points": [[5, 5]] })).unwrap()["handle"].as_str().unwrap().to_string(); + let h = Handle::parse_hex(&hx).unwrap(); + // Nothing selected: pick one first. + app.cmdline("hatchedit"); + assert_eq!(app.ui.dialog, None); + assert!(app.session.prompt_text().contains("Select hatch object"), "{}", app.session.prompt_text()); + app.session.input(cadcraft_engine::Input::Pick(vec![h])).unwrap(); + resume(&mut app); + assert_eq!(app.ui.dialog.as_deref(), Some("hatch")); + assert_eq!(app.hatch_dlg.clone().unwrap().edit, vec![h]); + // Selected (right-click Hatch Edit, typed HE), also with HPDLGMODE 0. + app.ui.dialog = None; + app.hatch_dlg = None; + app.run("setvar", json!({ "name": "HPDLGMODE", "value": 0 })).unwrap(); + app.session.set_selection(vec![h]); + app.start("he"); + assert_eq!(app.ui.dialog.as_deref(), Some("hatch")); + // OK applies the change. + if let Some(st) = app.hatch_dlg.as_mut() { + st.fill.pattern = "BRICK".into(); + } + assert!(ok(&mut app)); + let d = app.session.doc().unwrap(); + assert!(matches!(&d.entity(h).unwrap().kind, cadcraft_doc::EntityKind::Hatch(x) if x.pattern == "BRICK")); + // -HATCHEDIT: the command line (the window, not `ok`, closes the dialog). + app.ui.dialog = None; + app.cmdline("-hatchedit"); + assert_eq!(app.ui.dialog, None); + assert!(app.session.running.is_some()); +} + +#[test] +fn edit_mode_on_an_out_of_range_hatch_changes_nothing_by_itself() { + let (mut app, _) = app_with_rect(); + let hx = + app.session.execute("hatch", &json!({ "points": [[5, 5]], "scale": 5.0e6, "angle": 400 })).unwrap()["handle"].as_str().unwrap().to_string(); + app.session.set_selection(vec![Handle::parse_hex(&hx).unwrap()]); + open_edit(&mut app); + frames(&mut app); + assert_eq!(app.ui.dialog.as_deref(), Some("hatch")); + assert_eq!(app.hatch_dlg.as_ref().and_then(ok_call), None, "the widgets must not clamp the hatch's own values"); +} + +#[test] +fn switching_to_a_layout_drops_the_picks() { + let (mut app, r) = app_with_rect(); + open_create(&mut app, false); + start_pick(&mut app, PickMode::Objects); + app.session.input(cadcraft_engine::Input::Pick(vec![r])).unwrap(); + app.session.input(cadcraft_engine::Input::Enter).unwrap(); + app.run("layout", json!({ "option": "set", "name": "Layout1" })).unwrap(); + resume(&mut app); + assert_eq!((app.ui.dialog.clone(), app.hatch_dlg.clone()), (None, None), "model-space picks never hatch a layout"); + // An open dialog closes too. + app.run("layout", json!({ "option": "set", "name": "Model" })).unwrap(); + open_create(&mut app, false); + app.run("layout", json!({ "option": "set", "name": "Layout1" })).unwrap(); + frames(&mut app); + assert_eq!(app.hatch_dlg, None); +} + +#[test] +fn a_gradient_hatch_offers_the_current_pattern_on_the_pattern_tab() { + let (mut s, _) = session_with_rect(); + let h = s.execute("gradient", &json!({ "points": [[5, 5]] })).unwrap()["handle"].as_str().unwrap().to_string(); + let h = Handle::parse_hex(&h).unwrap(); + let mut st = edit_state(s.doc().unwrap(), 1, vec![h]).unwrap(); + assert_eq!(st.fill.pattern, "ANSI31", "HPNAME, not the gradient's SOLID"); + st.fill.gradient = false; + let (cmd, p) = ok_call(&st).unwrap(); + s.execute(cmd, &p).unwrap(); + match &s.doc().unwrap().entity(h).unwrap().kind { + cadcraft_doc::EntityKind::Hatch(x) => assert!(x.gradient.is_none() && x.pattern == "ANSI31"), + k => panic!("{k:?}"), + } +} diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n.rs similarity index 91% rename from crates/ui-egui/src/i18n/mod.rs rename to crates/ui-egui/src/i18n.rs index c0ce1d25c..ed252f88b 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n.rs @@ -190,44 +190,50 @@ pub struct Language { pub static LANGUAGES: [Language; 16] = [ Language { code: "en", name: "English", source: "", plural: plural_en, plural_forms: 2, catalog: OnceLock::new() }, // Greek: one for exactly 1; other for zero and all other integer counts. - Language { code: "el", name: "Ελληνικά", source: include_str!("el.tsv"), plural: plural_en, plural_forms: 2, catalog: OnceLock::new() }, Language { - code: "uk", name: "Українська", source: include_str!("uk.tsv"), plural: plural_uk, plural_forms: 3, catalog: OnceLock::new() + code: "el", name: "Ελληνικά", source: include_str!("i18n/el.tsv"), plural: plural_en, plural_forms: 2, catalog: OnceLock::new() + }, + Language { + code: "uk", name: "Українська", source: include_str!("i18n/uk.tsv"), plural: plural_uk, plural_forms: 3, catalog: OnceLock::new() }, Language { code: "pt-BR", name: "Português (Brasil)", - source: include_str!("pt_br.tsv"), + source: include_str!("i18n/pt_br.tsv"), plural: plural_pt, plural_forms: 2, catalog: OnceLock::new(), }, // German plurals follow the English rule: one (1), other. - Language { code: "de", name: "Deutsch", source: include_str!("de.tsv"), plural: plural_en, plural_forms: 2, catalog: OnceLock::new() }, + Language { code: "de", name: "Deutsch", source: include_str!("i18n/de.tsv"), plural: plural_en, plural_forms: 2, catalog: OnceLock::new() }, // Spanish integer plurals follow the English rule too. - Language { code: "es", name: "Español", source: include_str!("es.tsv"), plural: plural_en, plural_forms: 2, catalog: OnceLock::new() }, - Language { code: "cs", name: "Čeština", source: include_str!("cs.tsv"), plural: plural_cs, plural_forms: 3, catalog: OnceLock::new() }, + Language { code: "es", name: "Español", source: include_str!("i18n/es.tsv"), plural: plural_en, plural_forms: 2, catalog: OnceLock::new() }, + Language { code: "cs", name: "Čeština", source: include_str!("i18n/cs.tsv"), plural: plural_cs, plural_forms: 3, catalog: OnceLock::new() }, // Russian integer plurals follow the Ukrainian rule: one (1, 21), few (2–4), many. - Language { code: "ru", name: "Русский", source: include_str!("ru.tsv"), plural: plural_uk, plural_forms: 3, catalog: OnceLock::new() }, + Language { + code: "ru", name: "Русский", source: include_str!("i18n/ru.tsv"), plural: plural_uk, plural_forms: 3, catalog: OnceLock::new() + }, // French integer plurals (CLDR `fr`): one (0, 1), other, the Portuguese rule. - Language { code: "fr", name: "Français", source: include_str!("fr.tsv"), plural: plural_pt, plural_forms: 2, catalog: OnceLock::new() }, + Language { code: "fr", name: "Français", source: include_str!("i18n/fr.tsv"), plural: plural_pt, plural_forms: 2, catalog: OnceLock::new() }, // Chinese has no grammatical plural: both `@plural` forms read the same. Its glyphs come from // an installed CJK font (`theme::cjk_font`); without one the interface stays English. - Language { code: "zh", name: "简体中文", source: include_str!("zh.tsv"), plural: plural_en, plural_forms: 2, catalog: OnceLock::new() }, + Language { code: "zh", name: "简体中文", source: include_str!("i18n/zh.tsv"), plural: plural_en, plural_forms: 2, catalog: OnceLock::new() }, // Italian integer plurals (CLDR `it`) follow the English rule: one (1), other. - Language { code: "it", name: "Italiano", source: include_str!("it.tsv"), plural: plural_en, plural_forms: 2, catalog: OnceLock::new() }, + Language { code: "it", name: "Italiano", source: include_str!("i18n/it.tsv"), plural: plural_en, plural_forms: 2, catalog: OnceLock::new() }, // Korean has one integer plural form and needs a Hangul-capable interface font. - Language { code: "ko", name: "한국어", source: include_str!("ko.tsv"), plural: plural_ko, plural_forms: 1, catalog: OnceLock::new() }, + Language { code: "ko", name: "한국어", source: include_str!("i18n/ko.tsv"), plural: plural_ko, plural_forms: 1, catalog: OnceLock::new() }, // Swedish plurals follow the English rule: one (1), other. - Language { code: "sv", name: "Svenska", source: include_str!("sv.tsv"), plural: plural_en, plural_forms: 2, catalog: OnceLock::new() }, + Language { code: "sv", name: "Svenska", source: include_str!("i18n/sv.tsv"), plural: plural_en, plural_forms: 2, catalog: OnceLock::new() }, // العربية (Arabic) is right-to-left; an installed Arabic font (`theme::arabic_font`) // covers its glyphs, and the shell keeps egui's left-to-right run order. Six plural // forms: zero, one, two, few, many, other. - Language { code: "ar", name: "العربية", source: include_str!("ar.tsv"), plural: plural_arabic, plural_forms: 6, catalog: OnceLock::new() }, + Language { + code: "ar", name: "العربية", source: include_str!("i18n/ar.tsv"), plural: plural_arabic, plural_forms: 6, catalog: OnceLock::new() + }, // Hungarian uses the singular after a numeral, so one form covers every count (the Korean rule). - Language { code: "hu", name: "Magyar", source: include_str!("hu.tsv"), plural: plural_ko, plural_forms: 1, catalog: OnceLock::new() }, + Language { code: "hu", name: "Magyar", source: include_str!("i18n/hu.tsv"), plural: plural_ko, plural_forms: 1, catalog: OnceLock::new() }, // Turkish keeps a noun singular after a number ("3 commit"), so like Korean it has one form. - Language { code: "tr", name: "Türkçe", source: include_str!("tr.tsv"), plural: plural_ko, plural_forms: 1, catalog: OnceLock::new() }, + Language { code: "tr", name: "Türkçe", source: include_str!("i18n/tr.tsv"), plural: plural_ko, plural_forms: 1, catalog: OnceLock::new() }, ]; /// BCP 47 and POSIX locale tags, including `uk-UA`, `uk_UA.UTF-8` and `pt_BR.UTF-8`. diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 9ada44442..40e14cdb5 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -96,6 +96,7 @@ Blend دمج تدريجي Block كتلة Block Attribute Manager... مدير سمات الكتلة... + Block Description... وصف الكتلة... Block Editor محرر الكتل Block Reference مرجع كتلة Block... كتلة... @@ -587,6 +588,7 @@ Name الاسم Name (A–Z) الاسم (أ–ي) Named مسمى + Named Viewports... أطر العرض المسماة... Named Views... طرق عرض مسماة... Nearest الأقرب New جديد diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 6df6a4168..c979cd786 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -92,6 +92,7 @@ Blend Spojit křivky Block Blok Block Attribute Manager... Správce atributů bloků... + Block Description... Popis bloku... Block Editor Editor bloků Block Reference Reference bloku Block... Blok... @@ -537,6 +538,7 @@ N S Name Název Name (A–Z) Název (A–Z) + Named Viewports... Pojmenované výřezy... Named Views... Pojmenované pohledy... Nearest Nejbližší New Nový diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 860b23136..84b944529 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -67,6 +67,7 @@ Blend Übergang Block Block Block Attribute Manager... Blockattribut-Manager... + Block Description... Blockbeschreibung... Block Editor Blockeditor Block Reference Blockreferenz Block... Block... @@ -457,6 +458,7 @@ N N Name Name Name (A–Z) Name (A–Z) + Named Viewports... Benannte Ansichtsfenster... Nearest Nächstgelegener New Neu New (metric) Neu (metrisch) diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index fe09face6..2825e8fd2 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -100,6 +100,7 @@ Blend Ομαλή σύνδεση (Blend) Block Μπλοκ (Block) Block Attribute Manager... Διαχείριση ιδιοτήτων μπλοκ (Block Attributes)... + Block Description... Περιγραφή μπλοκ... Block Editor Επεξεργαστής μπλοκ (Block Editor) Block Reference Αναφορά μπλοκ (Block Reference) Block to create or edit Μπλοκ προς δημιουργία ή επεξεργασία @@ -640,6 +641,7 @@ Name Όνομα Name (A–Z) Όνομα (Α–Ω) Named Με όνομα + Named Viewports... Αποθηκευμένα παράθυρα προβολής... Named Views... Αποθηκευμένες όψεις... Nearest Πλησιέστερο (Nearest) New Νέο diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index afa3c90a5..2dde91eac 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -87,6 +87,7 @@ Blend Fusión de curvas Block Bloque Block Attribute Manager... Administrador de atributos de bloque... + Block Description... Descripción de bloque... Block Editor Editor de bloques Block Reference Referencia de bloque Block... Bloque... @@ -567,6 +568,7 @@ N N Name Nombre Name (A–Z) Nombre (A–Z) + Named Viewports... Ventanas gráficas con nombre... Named Views... Vistas con nombre... Nearest Cercano New Nuevo diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 30639fe83..42ad7ee48 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -67,6 +67,7 @@ Blend Raccord lissé Block Bloc Block Attribute Manager... Gestionnaire des attributs de bloc... + Block Description... Description de bloc... Block Editor Éditeur de blocs Block Reference Référence de bloc Block... Bloc... @@ -452,6 +453,7 @@ N N Name Nom Name (A–Z) Nom (A–Z) + Named Viewports... Fenêtres nommées... Nearest Plus proche New Nouveau New (metric) Nouveau (métrique) diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 67769707e..fc27bd7ce 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -114,6 +114,7 @@ Blend Átmenetgörbe Block Blokk Block Attribute Manager... Blokkattribútum-kezelő... + Block Description... Blokkleírás... Block Editor Blokkszerkesztő Block Reference Blokkhivatkozás Block to create or edit Létrehozandó vagy szerkesztendő blokk @@ -650,6 +651,7 @@ Name Név Name (A–Z) Név (A–Z) Named Elnevezett + Named Viewports... Elnevezett nézetablakok... Named Views... Elnevezett nézetek... Nearest Legközelebbi New Új diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 27786b2e5..634249093 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -97,6 +97,7 @@ menu Modify Edita Blend Fondi Block Blocco Block Attribute Manager... Gestore attributi blocco... + Block Description... Descrizione blocco... Block Editor Editor blocchi Block Reference Riferimento di blocco Block... Blocco... @@ -549,6 +550,7 @@ menu Modify Edita N N Name Nome Name (A–Z) Nome (A–Z) + Named Viewports... Finestre con nome... Named Views... Viste con nome... Nearest Vicino New Nuovo diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 7c5c9016c..1bd7e0fd5 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -92,6 +92,7 @@ Blend 곡선 연결 Block 블록 Block Attribute Manager... 블록 속성 관리자... + Block Description... 블록 설명... Block Editor 블록 편집기 Block Reference 블록 참조 Block... 블록... @@ -539,6 +540,7 @@ N 북 Name 이름 Name (A–Z) 이름(A–Z) + Named Viewports... 명명된 뷰포트... Named Views... 명명된 뷰... Nearest 근처점 New 새로 만들기 diff --git a/crates/ui-egui/src/i18n/pt_br.tsv b/crates/ui-egui/src/i18n/pt_br.tsv index 04fafff2f..8f6533d51 100644 --- a/crates/ui-egui/src/i18n/pt_br.tsv +++ b/crates/ui-egui/src/i18n/pt_br.tsv @@ -86,6 +86,7 @@ Blend Curva de transição Block Bloco Block Attribute Manager... Gerenciador de atributos de bloco... + Block Description... Descrição do bloco... Block Editor Editor de blocos Block Reference Referência de bloco Block... Bloco... @@ -565,6 +566,7 @@ N N Name Nome Name (A–Z) Nome (A–Z) + Named Viewports... Viewports nomeados... Named Views... Vistas nomeadas... Nearest Mais próximo New Novo diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index a5feb4589..b8f97bf73 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -67,6 +67,7 @@ Blend Сгладить Block Блок Block Attribute Manager... Диспетчер атрибутов блоков... + Block Description... Описание блока... Block Editor Редактор блоков Block Reference Вхождение блока Block... Блок... @@ -452,6 +453,7 @@ N С Name Название Name (A–Z) Название (А–Я) + Named Viewports... Именованные видовые экраны... Nearest Ближайшая точка New Создать New (metric) Создать (метрический) diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 2896520d3..6bea4c3da 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -90,6 +90,7 @@ Blend Mjuk övergång Block Block Block Attribute Manager... Hantera blockattribut... + Block Description... Blockbeskrivning... Block Editor Blockredigerare Block Reference Blockreferens Block... Block... @@ -569,6 +570,7 @@ N N Name Namn Name (A–Z) Namn (A–Ö) + Named Viewports... Namngivna vyfönster... Named Views... Namngivna vyer... Nearest Närmaste New Ny diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 3bb2690a1..9524b2e9f 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -83,30 +83,33 @@ fn missing_commands_menus_tools_and_snap_labels(language: &str) -> Vec { fn missing_marked_ui_literals(language: &str) -> BTreeSet { let catalog = catalog(language).unwrap(); - let sources = [ - include_str!("../xrefs.rs"), - include_str!("../canvas.rs"), - include_str!("../chrome.rs"), - include_str!("../cmdline.rs"), - include_str!("../credits.rs"), - include_str!("../palettes/quickcalc.rs"), - include_str!("../palettes/patterns.rs"), - include_str!("../dialogs.rs"), - include_str!("../hatchdlg.rs"), - include_str!("../aliasdlg.rs"), - include_str!("../mtext_editor.rs"), - include_str!("../layers.rs"), - include_str!("../canvas/compare.rs"), - include_str!("../canvas/blockedit.rs"), - include_str!("../bedit.rs"), - include_str!("../palettes.rs"), - include_str!("../parametric.rs"), - include_str!("../quick.rs"), - include_str!("../vpscale.rs"), - include_str!("../options/dialog.rs"), + let modules = [ + "xrefs", + "canvas", + "chrome", + "cmdline", + "credits", + "dialogs", + "hatchdlg", + "aliasdlg", + "mtext_editor", + "layers", + "bedit", + "palettes", + "parametric", + "quick", + "vpscale", + "options/dialog", ]; + let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let mut files = Vec::new(); + for module in modules { + files.push(src.join(format!("{module}.rs"))); + module_files(&src.join(module), &mut files); + } let mut missing = BTreeSet::new(); - for source in sources { + for file in files { + let source = std::fs::read_to_string(&file).unwrap(); for marker in ["crate::tl!(", "crate::tf!("] { for rest in source.split(marker).skip(1) { // These macros require a quoted literal. Use JSON's string decoding for escapes. @@ -140,6 +143,20 @@ fn missing_marked_ui_literals(language: &str) -> BTreeSet { missing } +/// Every `.rs` file under `dir`, at any depth, so code split into submodules stays scanned. +fn module_files(dir: &std::path::Path, files: &mut Vec) { + let Ok(entries) = std::fs::read_dir(dir) else { return }; + let mut paths: Vec<_> = entries.flatten().map(|e| e.path()).collect(); + paths.sort(); + for path in paths { + if path.is_dir() { + module_files(&path, files); + } else if path.extension().is_some_and(|x| x == "rs") { + files.push(path); + } + } +} + #[test] fn locale_and_saved_preference_resolution() { for tag in ["uk", "uk-UA", "uk_UA.UTF-8", "UK-ua", " uk-UA ", "uk_UA@euro"] { diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index cdf4ec36f..11369e751 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -114,6 +114,7 @@ Blend Harmanla Block Blok Block Attribute Manager... Blok Öznitelik Yöneticisi... + Block Description... Blok Açıklaması... Block Editor Blok Düzenleyici Block Reference Blok Referansı Block to create or edit Oluşturulacak ya da düzenlenecek blok @@ -651,6 +652,7 @@ Name Ad Name (A–Z) Ad (A–Z) Named Adlandırılmış + Named Viewports... Adlandırılmış Görünüm Pencereleri... Named Views... Adlandırılmış Görünümler... Nearest En Yakın New Yeni diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index f75d7c529..d71b3ccd7 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -94,6 +94,7 @@ Blend Плавне з'єднання Block Блок Block Attribute Manager... Диспетчер атрибутів блоків... + Block Description... Опис блока... Block Editor Редактор блоків Block Reference Посилання на блок Block... Блок... @@ -553,6 +554,7 @@ N Пн Name Назва Name (A–Z) Назва (А–Я) + Named Viewports... Іменовані видові екрани... Named Views... Іменовані види... Nearest Найближча точка New Створити diff --git a/crates/ui-egui/src/i18n/zh.tsv b/crates/ui-egui/src/i18n/zh.tsv index 80183d064..4d8da1698 100644 --- a/crates/ui-egui/src/i18n/zh.tsv +++ b/crates/ui-egui/src/i18n/zh.tsv @@ -67,6 +67,7 @@ Blend 过渡 Block 块 Block Attribute Manager... 块属性管理器... + Block Description... 块描述... Block Editor 块编辑器 Block Reference 块参照 Block... 块... @@ -1121,6 +1122,7 @@ Open Recent 打开最近的图形 Sheet Books to PDF... 图纸册转 PDF... Drawing Recovery Manager... 图形恢复管理器... + Named Viewports... 命名视口... Named Views... 命名视图... Polygonal Viewport 多边形视口 From Template... 来自模板... diff --git a/crates/ui-egui/src/layers.rs b/crates/ui-egui/src/layers.rs index d9bc0d833..8a95138eb 100644 --- a/crates/ui-egui/src/layers.rs +++ b/crates/ui-egui/src/layers.rs @@ -281,7 +281,7 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { // Layer states. ui.label(crate::tl!("Layer State")); let label = st.state_sel.clone().unwrap_or_else(|| crate::tl!("Unsaved Layer State").into()); - egui::ComboBox::from_id_salt("lpm_states").selected_text(label).width(170.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("lpm_states").selected_text(label).width(170.0).show_ui(ui, |ui| { for s in &states { if ui.selectable_label(st.state_sel.as_deref() == Some(s), s).clicked() { st.state_sel = Some(s.clone()); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 000e158f3..017cf5a42 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -235,6 +235,15 @@ pub struct CadApp { /// 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 @@ -303,6 +312,10 @@ impl CadApp { 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"))] @@ -604,6 +617,9 @@ impl CadApp { if ctx.current_pass_index() != 0 { return; } + if self.restore_modifiers.is_some() { + ctx.request_repaint(); + } // 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() { @@ -625,13 +641,56 @@ impl CadApp { capture::request_hidden_paint(self, ctx); } - /// Input hook: refresh the raw surface snapshot and release at most one synthetic batch. + /// 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; } diff --git a/crates/ui-egui/src/managers.rs b/crates/ui-egui/src/managers.rs index 712c02a61..42266d3b6 100644 --- a/crates/ui-egui/src/managers.rs +++ b/crates/ui-egui/src/managers.rs @@ -1,5 +1,6 @@ //! Style and settings dialogs opened from the menus: Linetype Manager, Point Style, Text Style, -//! Table Style, Multileader Style, Page Setup Manager, Plot and Constraint Settings. +//! Table Style, Multileader Style, Page Setup Manager, Plot, Constraint Settings and Named +//! Viewports. //! //! Typed or chosen from a menu (no parameters) these commands open their dialog; JSON calls run //! the command. The dialogs read the drawing and change it only through the same commands @@ -16,6 +17,8 @@ use crate::theme::Tokens; /// The Block Attribute Manager (BATTMAN). mod battman; +/// Named Viewports (View > Viewports > Named Viewports...). +mod named_vports; /// Open the dialog for a typed or menu-invoked command. `None` if `id` has no dialog here or the /// call carries parameters. @@ -32,6 +35,7 @@ pub fn route(app: &mut CadApp, id: &str, params: &Value) -> Option "mleaderstyle", "pagesetup" => "pagesetup", "constraintsettings" | "csettings" => "csettings", + "vports.named" => "namedvports", "plot" | "print" => { PLOT_GEN.fetch_add(1, Ordering::Relaxed); "plot" @@ -53,6 +57,7 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, name: &str, open: &mut bool "mleaderstyle" => mleader_style(app, ctx, open), "pagesetup" => page_setup(app, ctx, open), "csettings" => constraint_settings(app, ctx, open), + "namedvports" => named_vports::dialog(app, ctx, open), "plot" => plot(app, ctx, open), _ => { *open = false; @@ -118,7 +123,7 @@ fn linetype(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action { let mut action = None; egui::Window::new("Linetype Manager").open(open).default_size(vec2(520.0, 360.0)).resizable(true).show(ctx, |ui| { ui.horizontal(|ui| { - egui::ComboBox::from_id_salt("ltmgr_load").selected_text("Load...").width(110.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("ltmgr_load").selected_text("Load...").width(110.0).show_ui(ui, |ui| { if library.is_empty() { ui.label("Every library linetype is loaded."); } else if ui.selectable_label(false, "All").clicked() { @@ -445,7 +450,7 @@ fn style_manager(ctx: &egui::Context, open: &mut bool, title: &str, cmd: &'stati Field::Choice(key, label, v, choices) => { ui.label(*label); let mut picked = None; - egui::ComboBox::from_id_salt((cmd, *key)).selected_text(v.as_str()).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt((cmd, *key)).selected_text(v.as_str()).show_ui(ui, |ui| { for c in choices { if ui.selectable_label(c == v, c).clicked() && c != v { picked = Some(c.clone()); @@ -599,7 +604,7 @@ fn page_rows( ) -> Option<(&'static str, Value)> { let mut change = None; ui.label("Printer/plotter"); - egui::ComboBox::from_id_salt(salt.with("device")).selected_text(device_label(page)).width(190.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt(salt.with("device")).selected_text(device_label(page)).width(190.0).show_ui(ui, |ui| { if ui.selectable_label(device_label(page) == "None", "None").on_hover_text("PDF on the paper chosen below").clicked() && device_label(page) != "None" { @@ -618,7 +623,7 @@ fn page_rows( // The device's papers, else the standard sizes. let device = choices.devices.iter().find(|dv| dv.name.eq_ignore_ascii_case(&page.device)); ui.label("Paper size"); - egui::ComboBox::from_id_salt(salt.with("paper")).selected_text(page.paper.as_str()).width(190.0).show_ui(ui, |ui| match device { + craft_ui::ComboBox::from_id_salt(salt.with("paper")).selected_text(page.paper.as_str()).width(190.0).show_ui(ui, |ui| match device { Some(dev) => { for p in &dev.papers { if ui.selectable_label(p.name == page.paper, &p.name).clicked() && p.name != page.paper { @@ -647,7 +652,7 @@ fn page_rows( ui.end_row(); ui.label("Plot area"); ui.add_enabled_ui(rows.areas.len() > 1, |ui| { - egui::ComboBox::from_id_salt(salt.with("area")).selected_text(area_label(&page.plot_area)).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt(salt.with("area")).selected_text(area_label(&page.plot_area)).show_ui(ui, |ui| { for a in rows.areas { if ui.selectable_label(page.plot_area == *a, area_label(a)).clicked() && page.plot_area != *a { change = Some(("plotArea", json!(a))); @@ -723,7 +728,7 @@ fn page_rows( } ui.end_row(); ui.label("Plot style table"); - egui::ComboBox::from_id_salt(salt.with("styles")).selected_text(style_label(page)).width(190.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt(salt.with("styles")).selected_text(style_label(page)).width(190.0).show_ui(ui, |ui| { if ui.selectable_label(style_label(page) == "None", "None").clicked() && style_label(page) != "None" { change = Some(("plotStyleTable", json!("None"))); } @@ -941,7 +946,7 @@ fn plot(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action { egui::Window::new("Plot").open(open).collapsible(false).resizable(false).pivot(egui::Align2::CENTER_CENTER).default_pos(center).show(ctx, |ui| { egui::Grid::new("plot_target").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { ui.label("Layout"); - egui::ComboBox::from_id_salt("plot_layout").selected_text(st.target.as_str()).width(190.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("plot_layout").selected_text(st.target.as_str()).width(190.0).show_ui(ui, |ui| { for n in &targets { if ui.selectable_label(*n == st.target, n).clicked() && *n != st.target { st.page = plot_page(d, n); diff --git a/crates/ui-egui/src/managers/battman.rs b/crates/ui-egui/src/managers/battman.rs index 7ec42e414..82f8e1d97 100644 --- a/crates/ui-egui/src/managers/battman.rs +++ b/crates/ui-egui/src/managers/battman.rs @@ -103,7 +103,7 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Option< } ui.horizontal(|ui| { ui.label("Block:"); - egui::ComboBox::from_id_salt("battman_blocks").selected_text(block.as_str()).width(180.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("battman_blocks").selected_text(block.as_str()).width(180.0).show_ui(ui, |ui| { for b in &blocks { if ui.selectable_label(*b == block, b).clicked() { block = b.clone(); @@ -189,7 +189,7 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Option< } ui.end_row(); ui.label("Layer"); - egui::ComboBox::from_id_salt("battman_layer").selected_text(r.layer.as_str()).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("battman_layer").selected_text(r.layer.as_str()).show_ui(ui, |ui| { for l in &layers { if ui.selectable_label(*l == r.layer, l).clicked() && *l != r.layer { action = act(edit("layer", json!(l)), apply); diff --git a/crates/ui-egui/src/managers/named_vports.rs b/crates/ui-egui/src/managers/named_vports.rs new file mode 100644 index 000000000..6bffa5c4e --- /dev/null +++ b/crates/ui-egui/src/managers/named_vports.rs @@ -0,0 +1,112 @@ +//! Named Viewports (View > Viewports > Named Viewports...): the drawing's saved model space +//! viewport configurations, with the selected one's tiles drawn as a preview. Restore runs +//! `vports.named` in the Model tab; in a layout it starts MVIEW Restore with the name, so the +//! viewports' corners are picked next. Delete runs `vports.named` with `delete`. + +use cadcraft_doc::{Space, VportConfig, VportTile}; +use cadcraft_engine::Input; +use egui::{RichText, Sense, Stroke, vec2}; +use serde_json::{Value, json}; + +use super::{set_temp, temp}; +use crate::CadApp; +use crate::theme::Tokens; + +/// Draws the tiles of a configuration in a 180 x 120 box; tile coordinates (0, 0) are the bottom left. +fn preview(ui: &mut egui::Ui, tiles: &[VportTile], t: &Tokens) { + let (rect, _) = ui.allocate_exact_size(vec2(180.0, 120.0), Sense::hover()); + let p = ui.painter_at(rect); + p.rect_filled(rect, 0.0, t.panel); + let at = |x: f64, y: f64| egui::pos2(rect.min.x + x.clamp(0.0, 1.0) as f32 * rect.width(), rect.max.y - y.clamp(0.0, 1.0) as f32 * rect.height()); + for tile in tiles.iter().filter(|t| t.min.is_finite() && t.max.is_finite()) { + let r = egui::Rect::from_two_pos(at(tile.min.x, tile.min.y), at(tile.max.x, tile.max.y)); + p.rect_stroke(r, 0.0, Stroke::new(1.0, t.text_dim), egui::StrokeKind::Inside); + } + p.rect_stroke(rect, 0.0, Stroke::new(1.0, t.border), egui::StrokeKind::Inside); +} + +/// Show the dialog; returns the command to run. +pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Option<(&'static str, Value)> { + let t = Tokens::get(); + let Ok(d) = app.session.doc() else { + egui::Window::new("Named Viewports").open(open).collapsible(false).resizable(false).show(ctx, |ui| { + ui.label("Open a drawing first."); + }); + return None; + }; + let configs: Vec<(String, Vec)> = d.vports.iter().filter(|c| !c.is_active()).map(|c| (c.name.clone(), c.tiles.clone())).collect(); + let model = app.session.layout_space() == Space::Model; + let current = if model { app.session.viewport_configuration() } else { None }; + let mut sel = temp::(ctx, "namedvp_sel") + .filter(|s| configs.iter().any(|c| c.0 == *s)) + .or_else(|| current.clone()) + .or_else(|| configs.first().map(|c| c.0.clone())); + let mut action: Option<(&'static str, Value)> = None; + let mut restore = false; + let mut close = false; + egui::Window::new("Named Viewports").open(open).default_width(400.0).resizable(false).show(ctx, |ui| { + if model { + ui.label(format!("Current name: {}", current.as_deref().unwrap_or(VportConfig::ACTIVE))); + } else { + ui.label(RichText::new("Saved model space configurations to place in this layout").color(t.text_dim)); + } + ui.separator(); + if configs.is_empty() { + ui.label("This drawing has no named viewport configurations."); + } + ui.horizontal_top(|ui| { + egui::ScrollArea::vertical().id_salt("namedvp_list").max_height(120.0).min_scrolled_width(180.0).show(ui, |ui| { + ui.set_min_width(180.0); + for (name, _) in &configs { + let r = ui.selectable_label(sel.as_deref() == Some(name.as_str()), name); + if r.clicked() { + sel = Some(name.clone()); + } + if r.double_clicked() { + sel = Some(name.clone()); + restore = true; + } + } + }); + let tiles = sel.as_ref().and_then(|s| configs.iter().find(|c| c.0 == *s)).map(|c| c.1.as_slice()).unwrap_or_default(); + preview(ui, tiles, &t); + }); + ui.separator(); + ui.horizontal(|ui| { + let hint = if model { "Show this configuration's viewports" } else { "Place this configuration's viewports in the layout" }; + if ui.add_enabled(sel.is_some(), egui::Button::new("Restore")).on_hover_text(hint).clicked() { + restore = true; + } + if ui.add_enabled(sel.is_some(), egui::Button::new("Delete")).on_hover_text("Delete the saved configuration").clicked() + && let Some(n) = &sel + { + action = Some(("vports.named", json!({ "name": n, "delete": true }))); + } + if ui.button("Close").clicked() { + close = true; + } + }); + }); + if let Some(n) = sel.clone() { + set_temp(ctx, "namedvp_sel", n); + } + if close { + *open = false; + } + let Some(name) = sel.filter(|_| restore) else { return action }; + *open = false; + if model { + return Some(("vports.named", json!({ "name": name }))); + } + // The viewports' corners are picked in the layout, as for MVIEW Restore. + app.start("mview"); + if app.session.running.as_ref().is_some_and(|r| r.id == "mview") { + for i in [Input::Keyword("Restore".into()), Input::Text(name)] { + if let Err(e) = app.session.input(i) { + app.session.echo(e.to_string()); + break; + } + } + } + None +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 480d5563d..43b135d19 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -172,7 +172,7 @@ fn run_ui_command_inner(app: &mut CadApp, id: &str, params: &Value) -> Option 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/mtext_editor.rs b/crates/ui-egui/src/mtext_editor.rs index 8258a2986..5b9813e85 100644 --- a/crates/ui-egui/src/mtext_editor.rs +++ b/crates/ui-egui/src/mtext_editor.rs @@ -231,7 +231,7 @@ 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") + craft_ui::ComboBox::from_id_salt("mtext_font") .width(120.0) .selected_text(shown.clone()) .show_ui(ui, |ui| { diff --git a/crates/ui-egui/src/options/mod.rs b/crates/ui-egui/src/options.rs similarity index 100% rename from crates/ui-egui/src/options/mod.rs rename to crates/ui-egui/src/options.rs diff --git a/crates/ui-egui/src/options/dialog.rs b/crates/ui-egui/src/options/dialog.rs index cfc1833f0..93f051fd9 100644 --- a/crates/ui-egui/src/options/dialog.rs +++ b/crates/ui-egui/src/options/dialog.rs @@ -141,7 +141,7 @@ fn display(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map) { grid(ui, "options_display", |ui| { let theme_caption = ui.label(crate::tl!("Interface theme")); let mut theme = app.ui.theme; - let theme_combo = egui::ComboBox::from_id_salt("options_theme").selected_text(theme_label(theme)).show_ui(ui, |ui| { + let theme_combo = craft_ui::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)); } @@ -154,7 +154,7 @@ fn display(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map) { let animations_caption = ui.label(crate::tl!("Animation effects")); let mut animations = app.ui.animations; - let animations_combo = egui::ComboBox::from_id_salt("options_animations").selected_text(animations_label(animations)).show_ui(ui, |ui| { + let animations_combo = craft_ui::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)); } @@ -168,7 +168,7 @@ fn display(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map) { let scale_caption = ui.label(crate::tl!("Interface scale")); let scale = app.options.interface_scale; let percent = |s: f32| format!("{} %", (s * 100.0).round()); - let scale_combo = egui::ComboBox::from_id_salt("options_interface_scale").selected_text(percent(scale)).show_ui(ui, |ui| { + let scale_combo = craft_ui::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 { @@ -217,73 +217,15 @@ fn display(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map) { /// 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(); - - 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())); - } + 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(); - - 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)); - } + autosave_row(ui, changes); ui.end_row(); - - 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())); - } - } + 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).")); @@ -291,6 +233,76 @@ 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 = craft_ui::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(); @@ -519,7 +531,7 @@ fn preferences(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map grid(ui, "options_preferences", |ui| { let language_caption = ui.label(crate::tl!("Interface language")); let mut lang = app.ui.interface_language; - let language_combo = egui::ComboBox::from_id_salt("options_language").selected_text(lang.name()).show_ui(ui, |ui| { + let language_combo = craft_ui::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()); } diff --git a/crates/ui-egui/src/palettes.rs b/crates/ui-egui/src/palettes.rs index 78e2d7e93..ecf65b0d6 100644 --- a/crates/ui-egui/src/palettes.rs +++ b/crates/ui-egui/src/palettes.rs @@ -727,6 +727,22 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) { let tb_styles: Vec = d.table_styles.iter().map(|l| l.name.clone()).collect(); let mut action: Option<(&str, Value)> = None; let mut act = |c: &'static str, p: Value| action = Some((c, p)); + // In the Block Editor, the edited block's name and description come first. + let edited = app.session.state().ok().and_then(|st| { + let be = st.block_edit.as_ref().filter(|be| !be.current)?; + let b = d.block(&be.name).or_else(|| be.host.doc.block(&be.name)); + Some((be.name.clone(), b.map(|b| b.description.clone()).unwrap_or_default())) + }); + if let Some((name, description)) = edited { + prop_row(ui, "Name", |ui| { + value_box(ui, &name, false, false).on_hover_text(crate::tl!("Read-only")); + }); + prop_row(ui, "Description", |ui| { + if let Some(text) = edit_field(ui, ui.id().with("block_description"), description) { + act("blocks.description", json!({ "description": text })); + } + }); + } prop_row(ui, "Color", |ui| { ui.menu_button(format!("■ {}", crate::i18n::t(&Color::from_aci(h.i64("CECOLOR", 256) as i16).name())), |ui| { for (n, c) in [ diff --git a/crates/ui-egui/src/palettes/quickcalc.rs b/crates/ui-egui/src/palettes/quickcalc.rs index a4cabe1fd..5f601b2fa 100644 --- a/crates/ui-egui/src/palettes/quickcalc.rs +++ b/crates/ui-egui/src/palettes/quickcalc.rs @@ -305,7 +305,7 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { egui::Grid::new("qc-units-grid").num_columns(2).spacing(vec2(6.0, 4.0)).show(ui, |ui| { ui.label(crate::tl!("Type")); let kind_label = KINDS.get(st.kind).map_or("", |k| k.1); - egui::ComboBox::from_id_salt("qc-kind").selected_text(crate::i18n::t(kind_label)).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("qc-kind").selected_text(crate::i18n::t(kind_label)).show_ui(ui, |ui| { for (i, (_, label)) in KINDS.iter().enumerate() { if ui.selectable_label(st.kind == i, crate::i18n::t(label)).clicked() { st.kind = i; @@ -320,7 +320,7 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { ui.label(label); let sel = if which == 0 { &mut st.from } else { &mut st.to }; let shown = list.get(*sel).map(|u| u.1.clone()).unwrap_or_default(); - egui::ComboBox::from_id_salt(salt).selected_text(shown).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt(salt).selected_text(shown).show_ui(ui, |ui| { for (i, (_, sym)) in list.iter().enumerate() { if ui.selectable_label(*sel == i, sym).clicked() { *sel = i; diff --git a/crates/ui-egui/src/plotstyles.rs b/crates/ui-egui/src/plotstyles.rs index 54727162c..31fd24072 100644 --- a/crates/ui-egui/src/plotstyles.rs +++ b/crates/ui-egui/src/plotstyles.rs @@ -69,7 +69,7 @@ fn window(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action { 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| { + craft_ui::ComboBox::from_id_salt("pstyle_table").selected_text(sel.as_str()).width(200.0).show_ui(ui, |ui| { for n in &names { if ui.selectable_label(n.eq_ignore_ascii_case(&sel), n).clicked() { sel = n.clone(); @@ -160,7 +160,7 @@ fn window(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action { action = set(&table.name, style("screening", json!(screen))); } let lt = st.linetype.clone().unwrap_or_else(|| crate::tl!("Use object linetype").to_string()); - egui::ComboBox::from_id_salt(("pstyle_lt", &st.name)).selected_text(lt).width(140.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt(("pstyle_lt", &st.name)).selected_text(lt).width(140.0).show_ui(ui, |ui| { if ui.selectable_label(st.linetype.is_none(), crate::tl!("Use object linetype")).clicked() { action = set(&table.name, style("linetype", Value::Null)); } @@ -174,7 +174,7 @@ fn window(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action { .lineweight .map(|w| format!("{:.2} mm", f64::from(w) / 100.0)) .unwrap_or_else(|| crate::tl!("Use object lineweight").to_string()); - egui::ComboBox::from_id_salt(("pstyle_lw", &st.name)).selected_text(lw).width(140.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt(("pstyle_lw", &st.name)).selected_text(lw).width(140.0).show_ui(ui, |ui| { if ui.selectable_label(st.lineweight.is_none(), crate::tl!("Use object lineweight")).clicked() { action = set(&table.name, style("lineweight", Value::Null)); } diff --git a/crates/ui-egui/src/quick.rs b/crates/ui-egui/src/quick.rs index 6eb7c45b9..f9fcd7c6a 100644 --- a/crates/ui-egui/src/quick.rs +++ b/crates/ui-egui/src/quick.rs @@ -86,7 +86,7 @@ pub fn qselect_dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { .show(ctx, |ui| { egui::Grid::new("qs_grid").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { ui.label(crate::tl!("Apply to:")); - egui::ComboBox::from_id_salt("qs_apply") + craft_ui::ComboBox::from_id_salt("qs_apply") .width(220.0) .selected_text(crate::i18n::t(if st.apply_selection { "Current selection" } else { "Entire drawing" })) .show_ui(ui, |ui| { @@ -107,7 +107,7 @@ pub fn qselect_dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { .collect() }) .unwrap_or_default(); - egui::ComboBox::from_id_salt("qs_type").width(220.0).selected_text(crate::i18n::t(&st.ty)).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("qs_type").width(220.0).selected_text(crate::i18n::t(&st.ty)).show_ui(ui, |ui| { ui.selectable_value(&mut st.ty, "Multiple".to_string(), crate::tl!("Multiple")); for (n, c) in &types { ui.selectable_value(&mut st.ty, n.clone(), format!("{} ({c})", crate::i18n::t(n))); @@ -136,7 +136,7 @@ pub fn qselect_dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { ui.end_row(); ui.label(crate::tl!("Operator:")); let op_label = OPERATORS.iter().find(|o| o.0 == st.operator).map(|o| o.1).unwrap_or("= Equals"); - egui::ComboBox::from_id_salt("qs_op").width(220.0).selected_text(crate::i18n::t(op_label)).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("qs_op").width(220.0).selected_text(crate::i18n::t(op_label)).show_ui(ui, |ui| { for (o, l) in OPERATORS { ui.selectable_value(&mut st.operator, o.to_string(), crate::i18n::t(l)); } @@ -317,7 +317,7 @@ pub fn quick_properties(app: &mut CadApp, ctx: &egui::Context) { } ui.end_row(); ui.label(RichText::new(crate::tl!("Layer")).color(Tokens::get().text_dim)); - egui::ComboBox::from_id_salt("qp_layer").width(150.0).selected_text(layer.clone()).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("qp_layer").width(150.0).selected_text(layer.clone()).show_ui(ui, |ui| { for l in &layers { if ui.selectable_label(*l == layer, l).clicked() { set = Some(("layer".into(), json!(l))); @@ -326,7 +326,7 @@ pub fn quick_properties(app: &mut CadApp, ctx: &egui::Context) { }); ui.end_row(); ui.label(RichText::new(crate::tl!("Linetype")).color(Tokens::get().text_dim)); - egui::ComboBox::from_id_salt("qp_lt").width(150.0).selected_text(linetype.clone()).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("qp_lt").width(150.0).selected_text(linetype.clone()).show_ui(ui, |ui| { for l in std::iter::once("ByLayer".to_string()) .chain(std::iter::once("ByBlock".to_string())) .chain(linetypes.iter().filter(|l| !["ByLayer", "ByBlock"].contains(&l.as_str())).cloned()) diff --git a/crates/ui-egui/src/xrefs.rs b/crates/ui-egui/src/xrefs.rs index f142ab1f2..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 diff --git a/crates/ui-egui/tests/it/block_description_palette.rs b/crates/ui-egui/tests/it/block_description_palette.rs new file mode 100644 index 000000000..bcf4259ef --- /dev/null +++ b/crates/ui-egui/tests/it/block_description_palette.rs @@ -0,0 +1,49 @@ +//! Properties in the Block Editor: with nothing selected it shows the edited block's name and +//! description, and a description typed there is saved into the block by BSAVE. + +use cadcraft_engine::Session; +use cadcraft_ui_egui::{CadApp, Services}; +use egui::{Key, vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::{Queryable, by}; +use serde_json::json; + +/// Block "Door", described "Hinged door", open in the Block Editor. +fn palettes() -> Harness<'static, CadApp> { + let mut app = CadApp::new(Session::default(), Services::default()); + let l = app.run("line", json!({ "points": [[0, 0], [2, 0]] })).unwrap(); + app.run("block", json!({ "name": "Door", "base": [0, 0], "handles": l["handles"], "description": "Hinged door" })).unwrap(); + app.run("bedit", json!({ "name": "Door" })).unwrap(); + let mut h = Harness::builder().with_size(vec2(400.0, 1400.0)).build_ui_state( + |ui, app: &mut CadApp| { + app.logic(&ui.ctx().clone()); + cadcraft_ui_egui::palettes::right_palettes(app, ui); + }, + app, + ); + h.run_steps(4); + h +} + +fn field<'h>(h: &'h Harness<'static, CadApp>, shown: &'h str) -> egui_kittest::Node<'h> { + h.get(by().role(egui::accesskit::Role::TextInput).value(shown)) +} + +#[test] +fn the_block_editor_shows_and_changes_the_block_description() { + let mut h = palettes(); + field(&h, "Hinged door").focus(); + h.run_steps(2); + h.key_press_modifiers(egui::Modifiers::COMMAND, Key::A); + h.run_steps(1); + field(&h, "Hinged door").type_text("Sliding door"); + h.run_steps(2); + h.key_press(Key::Enter); + h.run_steps(3); + field(&h, "Sliding door"); + + let app = h.state_mut(); + assert!(app.session.state().unwrap().block_unsaved()); + app.run("bclose", json!({ "save": true })).unwrap(); + assert_eq!(app.session.doc().unwrap().block("Door").unwrap().description, "Sliding door"); +} diff --git a/crates/ui-egui/tests/it/docking_workflows.rs b/crates/ui-egui/tests/it/docking_workflows.rs index 18410ceae..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); @@ -94,9 +199,11 @@ fn invalid_requests_leave_document_and_panel_layout_unchanged() { 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); } @@ -131,48 +238,39 @@ 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); } @@ -219,6 +317,67 @@ fn palettes_off_and_on_reopen_a_floating_group_as_one_window() { 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); @@ -276,15 +435,8 @@ 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(); @@ -310,34 +462,27 @@ fn reset_typed_in_canvas_preserves_authoritative_workspace_replacement() { } #[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::unique("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)); @@ -346,10 +491,10 @@ fn canvas_reset_during_splitter_drag_discards_old_resize_and_ends_gesture() { h.run_steps(1); 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); @@ -386,35 +531,65 @@ fn collapsed_tool_sets_leave_a_visible_control_that_expands_them() { } #[test] -fn group_drag_bar_moves_the_whole_group_beside_the_drawing() { - use craft_ui::docking::{DockArea, Hit, Node}; +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 = |h: &Harness<'_, CadApp>| { - DockArea::::hit_rects(&h.ctx, egui::Id::unique("cadcraft-docking")) - .into_iter() - .find(|(hit, _)| matches!(hit, Hit::DragBar(panel) if *panel == Panel::ToolSets || *panel == Panel::Layers)) - .map(|(_, rect)| rect) - }; - let from = bar(&h).expect("the Tool Sets group draws a drag bar").center(); - let drawing = h.state().canvas.rect.expect("the drawing view is drawn"); - let to = egui::pos2(drawing.right() - 10.0, drawing.center().y); + 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, from, to); + drag(&mut h, bar.center(), to); let layout = &h.state().ui.docking.layout; - assert!(layout.floating.is_empty(), "a drop beside the drawing docks the group"); - // The drawing's frame splits: the drawing on the left, the whole group on the right. - let mut nodes: Vec<&Node> = layout.root.iter().collect(); - let mut beside = false; - while let Some(node) = nodes.pop() { - if let Node::Split { first, second, .. } = node { - beside |= matches!((&**first, &**second), (Node::Tabs { panels: left, .. }, Node::Tabs { panels: right, .. }) - if left == &[Panel::Canvas] && right == &[Panel::ToolSets, Panel::Layers]); - nodes.push(first); - nodes.push(second); - } - } - assert!(beside, "the group moves as one to the drawing's right: {:?}", layout.root); + 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/main.rs b/crates/ui-egui/tests/it/main.rs index 5faf3135b..c32012e1a 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -1,4 +1,5 @@ mod animation_effects; +mod block_description_palette; mod chrome_geometry; mod context_menu; mod context_menus; @@ -17,6 +18,9 @@ mod lisp_files; mod measure_distance_snapping; mod menubar_hover; mod mspace_viewport_view; +mod named_viewports_dialog; +mod native_menu_text_keys; +mod native_windows; mod options_dialog; mod parameters_rename; mod pickdrag; diff --git a/crates/ui-egui/tests/it/named_viewports_dialog.rs b/crates/ui-egui/tests/it/named_viewports_dialog.rs new file mode 100644 index 000000000..19b0a6e67 --- /dev/null +++ b/crates/ui-egui/tests/it/named_viewports_dialog.rs @@ -0,0 +1,74 @@ +//! View > Viewports > Named Viewports... opens a dialog that lists the saved viewport +//! configurations: Restore shows one in the Model tab or starts placing it in a layout, and Delete +//! deletes it. + +use cadcraft_engine::Session; +use cadcraft_ui_egui::{CadApp, Services, dialogs, menus}; +use egui::vec2; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +/// A drawing with "Two" (two tiles side by side) saved, showing one view, and the dialog open. +fn dialog() -> Harness<'static, CadApp> { + let mut app = CadApp::new(Session::default(), Services::default()); + app.run("line", json!({ "points": [[0, 0], [100, 50]] })).unwrap(); + app.run("-vports", json!({ "option": "2", "arrangement": "vertical" })).unwrap(); + app.run("-vports", json!({ "option": "save", "name": "Two" })).unwrap(); + app.run("-vports", json!({ "option": "single" })).unwrap(); + menus::activate(&mut app, "vports.named"); + assert_eq!(app.ui.dialog.as_deref(), Some("namedvports")); + let mut h = Harness::builder().with_size(vec2(900.0, 700.0)).build_ui_state( + |ui, app: &mut CadApp| { + app.logic(&ui.ctx().clone()); + dialogs::show(app, ui.ctx()); + }, + app, + ); + h.run_steps(3); + h +} + +#[test] +fn restore_and_delete_in_the_model_tab() { + let mut h = dialog(); + h.get_by_label("Current name: *Active"); + h.get_by_label("Two").click(); + h.run_steps(2); + h.get_by_label("Restore").click(); + h.run_steps(3); + let app = h.state_mut(); + assert_eq!(app.session.model_tiles().len(), 2); + assert_eq!(app.ui.dialog, None, "Restore closes the dialog"); + + menus::activate(app, "vports.named"); + h.run_steps(3); + h.get_by_label("Current name: Two"); + h.get_by_label("Delete").click(); + h.run_steps(3); + let app = h.state_mut(); + assert!(app.session.doc().unwrap().vports.iter().all(|c| c.name != "Two")); + assert_eq!(app.ui.dialog.as_deref(), Some("namedvports"), "Delete keeps the dialog open"); + h.get_by_label("This drawing has no named viewport configurations."); +} + +#[test] +fn restore_in_a_layout_asks_where_the_viewports_go() { + let mut h = dialog(); + let app = h.state_mut(); + app.ui.dialog = None; + app.run("layout.set", json!({ "name": "Layout1" })).unwrap(); + menus::activate(app, "vports.named"); + h.run_steps(3); + h.get_by_label("Two").click(); + h.run_steps(2); + h.get_by_label("Restore").click(); + h.run_steps(3); + let app = h.state_mut(); + assert!(app.session.prompt_text().contains("Specify first corner"), "{}", app.session.prompt_text()); + let before = app.session.doc().unwrap().layout("Layout1").unwrap().entities.iter().count(); + app.cmdline(""); + assert!(app.session.running.is_none()); + let after = app.session.doc().unwrap().layout("Layout1").unwrap().entities.iter().count(); + assert_eq!(after, before + 2, "Enter fits the two viewports to the printable area"); +} 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/docs/file-format-parity.md b/docs/file-format-parity.md index 7c7ef1ec7..26e5aff37 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-24 · **Change:** minor (re-audit against main 41d9b5e95: acadrust 0.6.5, ACIS data, dynamic block, SCRIPT and SHX rows, io test count; previously: DIMASSOC objects for angular, diametric and radial dimensions) · **Target:** Autodesk AutoCAD 2027 +> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-25 · **Change:** minor (LAYOUT SAveas writes a layout as a DWT template; previously: re-audit against main 41d9b5e95: acadrust 0.6.5, ACIS data, dynamic block, SCRIPT and SHX rows, io test count; previously: DIMASSOC objects for angular, diametric and radial dimensions) · **Target:** Autodesk AutoCAD 2027 Every format AutoCAD 2027 reads or writes, with CADCraft's support, fidelity and tests. Work items are in [gaps.md](gaps.md#file-formats); the weighted score feeds @@ -108,7 +108,7 @@ on read with a note, so such files no longer exceed the DXF limit (#1482). **DWG | Format | AutoCAD 2027 | CADCraft | Hours to parity (est.) | |---|---|---|---| -| DWT templates | open, save | NEW/QNEW from `.dwt` and `.dxf` templates, SAVEAS `.dwt` (#552) | 0–1 | +| DWT templates | open, save | NEW/QNEW from `.dwt` and `.dxf` templates, SAVEAS `.dwt` (#552); LAYOUT SAveas writes one layout and the named objects it uses as a `.dwt` (`layout.saveas`) | 0–1 | | DWS standards | open, save, CHECKSTANDARDS | no | 6–10 | | PDF output (PLOT, EXPORTPDF) | multi-sheet, layers, searchable text, hyperlinks | vector PDF with lineweights, real text in embedded TrueType subsets, layers (OCG), several sheets per file and hyperlinks (#567, #571); object transparency as PDF alpha when "Plot transparency" is on (the option isn't saved in DXF/DWG: no public plot layout flag) | 1–2 | | PDF import (PDFIMPORT) and PDF underlays (PDFATTACH) | yes | no | 15–25 | @@ -140,6 +140,7 @@ our output in AutoCAD black-box. | Date | Change | Summary | |---|---|---| +| 2026-10-25 | minor | LAYOUT SAveas (`layout.saveas`) writes a layout as a drawing template: its page setup and paper space objects, no model space objects or other layouts, and only the named objects they use | | 2026-10-24 | minor | Re-audit against main 41d9b5e95: acadrust 0.6.5, ACIS data, dynamic block, SCRIPT and SHX rows, io test count | | 2026-10-22 | minor | DXF/DWG save: angular, diametric and radial dimensions get standard DIMASSOC objects (one centre reference to the circle for radial/diametric, the definition points for angular), so AutoCAD follows their geometry; the standard objects of those kinds are read back too, without CADCraft's xdata (#1651) | | 2026-10-19 | minor | DXF/DWG save: angular dimension block text honors DIMDSEP, including per-dimension overrides (#1646) | diff --git a/docs/gaps.md b/docs/gaps.md index 78b942945..83f7d9f3f 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where CADCraft falls short of AutoCAD -> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-25 · **Change:** minor (DIMTFILL limits in the `*D` block, EXPLODE and DXF: F7 updated; re-audit against main 41d9b5e95: CI gate (#6, S1), MVIEW Restore (F15, U11), open-PR and catalog counts, file-test count; previously: Command Aliases dialog for ALIASEDIT: U12 and E5 narrowed) · **Target:** Autodesk AutoCAD 2027 +> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-25 · **Change:** minor (Named Viewports dialog: U11 narrowed; MVIEW Restore and PURGE of multiline styles had landed: F15 and F21 corrected; previously: DIMTFILL limits in the `*D` block, EXPLODE and DXF: F7 updated; re-audit against main 41d9b5e95: CI gate (#6, S1), MVIEW Restore (F15, U11), open-PR and catalog counts, file-test count; previously: Command Aliases dialog for ALIASEDIT: U12 and E5 narrowed) · **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 @@ -37,12 +37,12 @@ do them first. | F12 | Annotative scaling done (#468): scale list (SCALELISTEDIT), CANNOSCALE, OBJECTSCALE, ANNOALLVISIBLE/ANNOAUTOSCALE, viewport scale, DXF, status bar list. Left: per-scale positions (ANNORESET is a no-op), a viewport annotation scale apart from its viewport scale, annotative objects inside blocks and old LEADERs, AutoCAD files' per-object scale contexts (read as not annotative) | `cmd/annoscale.rs`, `doc/src/anno.rs` | Low-medium | 4–8 | | F14 | **[alpha blocker]** Plotting leftovers: binary `.ctb`/`.stb`/`.pc3` files can't be read; no system printers (H3); the page setup's "Plot transparency" is not saved in DXF/DWG (the public DXF Reference has no plot layout flag for it), so it lasts for the session and in JSON only. The Plot dialog (#160), Plot Preview and PUBLISH (#186), plot styles (CTB and STB, [plot-styles.md](plot-styles.md), #410) with their end, join and fill styles, plotter devices with paper sizes and printable areas ([plotters.md](plotters.md), #564), and plotted transparency (page setup and Plot dialog option, PLOTTRANSPARENCYOVERRIDE) landed | `io/src/plotter.rs`, `cmd/plotters.rs` | Medium | 4–8 | | F13 | Hatch: 23 patterns (AutoCAD ~85; ours must stay original); the Hatch and Gradient and Hatch Edit dialogs have no Preview or Inherit Properties (HPDLGMODE 0 keeps HATCH on the command line); no MPolygon; HATCHEDIT and -HATCHEDIT add boundaries from picked points or closed objects and remove chosen loops, but a hatch copied with its boundary isn't linked to the copy; HPGAPTOL closes gaps between curve ends only | `doc/src/library.rs`, `cmd/hatch.rs`, `cmd/hatch/edit.rs` | Medium | 6–10 | -| F15 | Viewports: a clipped viewport and its clip boundary move and scale separately. Polygonal, object and clipped viewports, VPCLIP, MVIEW ON/OFF/Lock/2/3/4 and the status-bar scale list landed (#453), and MVIEW Restore puts a saved viewport configuration into a layout (#633) | `cmd/layout/viewports.rs` | Low | 2–4 | +| F15 | Viewports: a clipped viewport and its clip boundary move and scale separately. Polygonal, object and clipped viewports, VPCLIP, MVIEW ON/OFF/Lock/2/3/4 and the status-bar scale list landed (#453), MVIEW Restore in #633 | `cmd/layout/viewports.rs` | Low | 2–4 | | F16 | Groups: no group bounding box or single grip when a group is selected (GROUPDISPLAYMODE), copying a whole group does not make a new group, no Object Grouping dialog (GROUP, GROUPEDIT, UNGROUP, -GROUP, PICKSTYLE group selection and the DXF/DWG round trip landed, #417); PICKSTYLE's associative-hatch bit is stored but unused | `cmd/groups.rs` | Low | 2–4 | | F17 | Attributes (ATTSYNC, global -ATTEDIT, BATTMAN editing and DATAEXTRACTION/EATTEXT landed, #433): the Verify, Preset and Lock position modes are kept in files and Lock position is applied by INSERT and ATTSYNC (#876), but INSERT doesn't act on Verify or Preset, and multiline attributes (read, drawn and saved since 2026-10-12) can't be created or switched in BATTMAN; data extraction covers block references and attribute values (CSV or a table) only — no wizard, nested blocks, other objects' properties, `.dxe` templates or spreadsheet output | `cmd/blocks/battman.rs`, `cmd/blocks/extract.rs` | Low | 4–6 | | 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 | +| 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) | `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 (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 | @@ -63,7 +63,7 @@ or allow clicks and drag (2, default), saved in the user profile. Drag-only mode | U6 | Selection: lasso; the CLass keyword; isolation saved with the drawing (OBJECTISOLATIONMODE 1) and the status-bar isolate button. Press-drag windows (#63), the W, C, F, WP and CP keywords (#226), BOX, AUto, SIngle, Multiple, Group, Add, Remove and Undo, SELECTSIMILAR, ISOLATEOBJECTS/HIDEOBJECTS/UNISOLATEOBJECTS (#428) and selection cycling (#1143) landed | Medium | 3–5 | | U7 | Right-click menus: no in-place layout tab rename or Move or Copy dialog, no Select All Layouts, no Paste to command line; palette menus are on the Layers rows and the Properties background only. The command menu (held right-click: Enter, Cancel, Recent Input, options, Snap Overrides, Pan/Zoom), the selection menu with object edits, command line, drawing tab, layout tab and palette menus landed (#512) | Low | 1–2 | | U10 | SETVAR covers about 150 of ~1,000 variables (UNDO options, MREDO and UNDOCTL, and header variables such as CIRCLERAD, OFFSETDIST, POLYSIDES, HPNAME and the dimension variables in SETVAR ? landed, #461; typing a bare variable name as a command runs SETVAR on it) | Low-medium | 3–5 | -| U11 | VIEW Orthographic, UCS and background settings; view twist is kept but not shown (Zoom Realtime and Dynamic, ZOOMFACTOR, named views (VIEW) and tiled model viewports with saved configurations (VPORTS) landed in #463; MVIEW Restore, #633) | Low | 1–2 | +| U11 | VIEW Orthographic, UCS and background settings; view twist is kept but not shown (Zoom Realtime and Dynamic, ZOOMFACTOR, named views (VIEW) and tiled model viewports with saved configurations (VPORTS) landed in #463, MVIEW Restore in #633, and the Named Viewports dialog, which restores or deletes a saved configuration) | Low | 1–2 | | U12 | Workspaces, navigation bar, CUI customisation, ViewCube drag (#7) (the Command Aliases dialog landed, #1381) | Low-medium | 13–23 | | U13 | `.dxf` from Finder fails to open on macOS (#158) (window controls fixed, #161) | Medium | 1–2 | @@ -150,6 +150,7 @@ Detail: [localization-parity.md](localization-parity.md). | Date | Change | Summary | |---|---|---| +| 2026-10-25 | minor | Named Viewports (View ▸ Viewports ▸ Named Viewports..., `vports.named`) lists the saved viewport configurations with a preview and the current one's name, and restores or deletes one; in a layout Restore places it as MVIEW Restore does: U11 narrowed. F15 and U11 no longer list MVIEW Restore (#633), and F21 no longer says PURGE skips multiline styles | | 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 | Re-audit against main 41d9b5e95: CI gate (rows 6 and S1, #1987), MVIEW Restore (F15, U11, #633), PDF margins (F14), open-PR, catalog and file-test counts re-checked against main 41d9b5e95 | | 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 | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 9c2186c90..a6250d5dc 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -4,19 +4,19 @@ 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: 296 / 491 items (60%)** · 441 registered commands. +**Menu breadth: 297 / 491 items (60%)** · 442 registered commands. | Menu | Items | Covered | % | |---|---|---|---| | File | 22 | 13 | 59% | | Edit | 13 | 12 | 92% | -| View | 75 | 36 | 48% | +| View | 75 | 37 | 49% | | Insert | 11 | 5 | 45% | | Format | 27 | 26 | 96% | | Tools | 85 | 66 | 78% | | Draw | 87 | 55 | 63% | | Dimension | 28 | 28 | 100% | -| Modify | 113 | 52 | 46% | +| Modify | 113 | 53 | 47% | | Window | 22 | 3 | 14% | | Help | 8 | 0 | 0% | @@ -37,7 +37,6 @@ Menu-breadth parity: a reference menu item counts as covered when a CADCraft com - View > Orbit > Free Orbit - View > Camera > Adjust Distance - View > Camera > Swivel -- View > Viewports > Named Viewports... - View > 3D Views > Viewpoint - View > 3D Views > Plan View > Current UCS - View > 3D Views > Plan View > World UCS @@ -134,7 +133,6 @@ Menu-breadth parity: a reference menu item counts as covered when a CADCraft com - Modify > Object > Image > Quality - Modify > Object > Image > Transparency - Modify > Object > Image > Frame -- Modify > Object > Block Description... - Modify > Clip > Image - Modify > Clip > Xref - Modify > 3D Operations > 3D Move