mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-29 05:16:49 -06:00
Compare commits
211
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5e5645cf6 | ||
|
|
7d2ca90783 | ||
|
|
af9798e200 | ||
|
|
c042e98abb | ||
|
|
994af5cac9 | ||
|
|
970e6bb9d4 | ||
|
|
736a83464a | ||
|
|
aa5dfc2099 | ||
|
|
f12b7570b7 | ||
|
|
795eb62762 | ||
|
|
e7b92c5b8e | ||
|
|
6028cdf187 | ||
|
|
172f9ca44c | ||
|
|
c5e7db78e4 | ||
|
|
31a04e3dbe | ||
|
|
627ffb8d7e | ||
|
|
18f47e021f | ||
|
|
21d80b3231 | ||
|
|
62deed7880 | ||
|
|
45bcfa6f77 | ||
|
|
627b5a3350 | ||
|
|
1006e487da | ||
|
|
fcd4a0c9eb | ||
|
|
dbd2bf5dad | ||
|
|
d7915c5f3b | ||
|
|
1bbc5ec76b | ||
|
|
012732136c | ||
|
|
273303134e | ||
|
|
48d25a0414 | ||
|
|
6021d96628 | ||
|
|
ad329273f2 | ||
|
|
f3796d1f7a | ||
|
|
3a64a190ea | ||
|
|
5074d2a54c | ||
|
|
bccba7261e | ||
|
|
c8ee56a44f | ||
|
|
d3ae8a6af0 | ||
|
|
2a2d4e2582 | ||
|
|
d1cf06cbea | ||
|
|
7d33d9d4dd | ||
|
|
53080b1748 | ||
|
|
21c48cc05b | ||
|
|
47237bd4dd | ||
|
|
2521605713 | ||
|
|
f56be09993 | ||
|
|
1b6861698c | ||
|
|
f24af47ffb | ||
|
|
055b06a2f4 | ||
|
|
660a7881d1 | ||
|
|
3d8301b965 | ||
|
|
cb8b1709e9 | ||
|
|
66ecce06f5 | ||
|
|
004287d919 | ||
|
|
656dd4d09a | ||
|
|
876de8ad2f | ||
|
|
4feccf0abd | ||
|
|
dfddd13656 | ||
|
|
2f82ecdbf1 | ||
|
|
7fc9f2756d | ||
|
|
754b609d09 | ||
|
|
d6e712db41 | ||
|
|
d73e379fd2 | ||
|
|
8343045863 | ||
|
|
2528671106 | ||
|
|
3f92d57b79 | ||
|
|
c3c455fbbf | ||
|
|
d0a1ea5268 | ||
|
|
2994b52e03 | ||
|
|
0de051049c | ||
|
|
cddf09beb2 | ||
|
|
1c85d339c3 | ||
|
|
63a5569970 | ||
|
|
5acec7d0be | ||
|
|
8f7421737d | ||
|
|
422d42411a | ||
|
|
0c15f7106c | ||
|
|
31de470926 | ||
|
|
0f60269868 | ||
|
|
3e6e1e534d | ||
|
|
52aae9c1fb | ||
|
|
a89be5f2dd | ||
|
|
a4a3a342a0 | ||
|
|
36fcc896f7 | ||
|
|
a0a691e1f7 | ||
|
|
097c53bee3 | ||
|
|
5ec3a18aa7 | ||
|
|
b37b7ceb7b | ||
|
|
1d9ddd2406 | ||
|
|
0a14fc7d3a | ||
|
|
bbbd9eec51 | ||
|
|
f2b35e957e | ||
|
|
1fcf654ea8 | ||
|
|
e86d895ba1 | ||
|
|
c6c3aefe3f | ||
|
|
b34477d580 | ||
|
|
3d7e298b7d | ||
|
|
23d44cd9c8 | ||
|
|
8296bbc642 | ||
|
|
b90d68ec34 | ||
|
|
c42763ba0f | ||
|
|
642c3a59a3 | ||
|
|
53049d82e0 | ||
|
|
e23a362c54 | ||
|
|
f46a220375 | ||
|
|
8171c9be17 | ||
|
|
4dbcbfdaaa | ||
|
|
429554b389 | ||
|
|
17952af3fb | ||
|
|
1b0cefce9c | ||
|
|
e1cab10d01 | ||
|
|
05cad7db49 | ||
|
|
4e73fb5bb3 | ||
|
|
84e3478863 | ||
|
|
fac2d18df6 | ||
|
|
23a0bd4fdb | ||
|
|
75c54ce35d | ||
|
|
313fab8827 | ||
|
|
bda4a9924c | ||
|
|
3e64cc5d06 | ||
|
|
20087b4b5f | ||
|
|
5685085add | ||
|
|
32be8a655e | ||
|
|
b901656669 | ||
|
|
1271b06b02 | ||
|
|
6dc336160d | ||
|
|
8a64e42100 | ||
|
|
fcfdf33001 | ||
|
|
898b539630 | ||
|
|
bfa30e2192 | ||
|
|
432cbfb106 | ||
|
|
596ea36465 | ||
|
|
dc081c6f7f | ||
|
|
746942613c | ||
|
|
1b8be0b77d | ||
|
|
7fe832b284 | ||
|
|
1fcdfc87b4 | ||
|
|
5a82f090df | ||
|
|
41d9b5e957 | ||
|
|
6fe715e11c | ||
|
|
028b98d71c | ||
|
|
b9f96d3dd3 | ||
|
|
39e9b62829 | ||
|
|
4aed7a2267 | ||
|
|
63394e872d | ||
|
|
e59195edb8 | ||
|
|
5e39af933f | ||
|
|
c66f6f45b8 | ||
|
|
b062030a03 | ||
|
|
7aa952b8da | ||
|
|
5cfe1a4149 | ||
|
|
9f2060d674 | ||
|
|
9fc626a33b | ||
|
|
fef32761af | ||
|
|
9562d951df | ||
|
|
f05fa9cec3 | ||
|
|
73e7486dc9 | ||
|
|
fde62a39ab | ||
|
|
7db656fdc7 | ||
|
|
9d9e4c3b0d | ||
|
|
c92bb5cdad | ||
|
|
1c35d7f8c1 | ||
|
|
a94fc0898b | ||
|
|
a0dced6d45 | ||
|
|
532c4ccc1c | ||
|
|
88a73b7618 | ||
|
|
0fc8e5241c | ||
|
|
cff2eb7262 | ||
|
|
ab6bff5e4e | ||
|
|
1a76e10c20 | ||
|
|
941ea341bc | ||
|
|
71882cf5e6 | ||
|
|
64c35cadff | ||
|
|
63dec85749 | ||
|
|
1c3c25035c | ||
|
|
2bc3b02ea1 | ||
|
|
6842b8774b | ||
|
|
78c1653f50 | ||
|
|
fda82da3df | ||
|
|
2c59eb19c5 | ||
|
|
0299a3eaa0 | ||
|
|
1c7d3f0fae | ||
|
|
4f282b30cc | ||
|
|
b9d59c64ab | ||
|
|
4c327cccd0 | ||
|
|
587d1dcd69 | ||
|
|
e04056f714 | ||
|
|
25faf34d28 | ||
|
|
dadd1f227f | ||
|
|
1428174159 | ||
|
|
f686a595e6 | ||
|
|
7d2fbd959a | ||
|
|
829909274a | ||
|
|
f8f13c3f4e | ||
|
|
f119610287 | ||
|
|
620e6a664c | ||
|
|
8dd8635e10 | ||
|
|
4251f1e253 | ||
|
|
9a32aa5613 | ||
|
|
6ee6e652fd | ||
|
|
76f7cea83f | ||
|
|
47e978b58e | ||
|
|
a68d0a458b | ||
|
|
60265a1eee | ||
|
|
20b0c9c11a | ||
|
|
b517e0749d | ||
|
|
5c911ad824 | ||
|
|
15147260c1 | ||
|
|
bbbf4465c4 | ||
|
|
7bf5905ac9 | ||
|
|
d0e83aefa6 | ||
|
|
cda7381c3d |
@@ -22,7 +22,7 @@ jobs:
|
||||
uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
|
||||
with:
|
||||
# Same craft-fonts commit as release.yml.
|
||||
craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
|
||||
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
|
||||
timeout-minutes: 90
|
||||
packages: >-
|
||||
pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcb1-dev libxcursor-dev
|
||||
|
||||
@@ -29,4 +29,4 @@ jobs:
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
|
||||
with:
|
||||
craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
|
||||
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
|
||||
|
||||
@@ -11,7 +11,7 @@ on:
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
@@ -59,12 +59,15 @@ jobs:
|
||||
|
||||
release:
|
||||
needs: git-sources
|
||||
permissions:
|
||||
# The reusable release workflow tags and uploads the draft release.
|
||||
contents: write
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
|
||||
with:
|
||||
app-name: cadcraft
|
||||
app-title: CADCraft
|
||||
craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
|
||||
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
|
||||
version: ${{ inputs.version }}
|
||||
secrets:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
|
||||
@@ -23,6 +23,11 @@ Generated-in-code assets are original and have no file to list:
|
||||
|
||||
| Asset | Author | Source | Licence | Notes |
|
||||
|---|---|---|---|---|
|
||||
| `epaint_default_fonts::EGUI_ICONS`, `epaint_default_fonts::EMOJI_ICON` (dependency font inputs) | John Slegers | [egui 173cc737](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [emoji-icon-font](https://github.com/jslegers/emoji-icon-font) | MIT (Copyright (c) 2014 John Slegers; full upstream text in `NOTICE`) | egui-icons.ttf is the five-glyph subset; emoji-icon-font.ttf is the full monochrome fallback. Registered by the production default font definitions; dependency assets unmodified here, no font file copied into this repository. |
|
||||
| `krilla 0.8.2 / icc/sGrey-v2-magic.icc` (dependency-embedded) | Clinton Ingram | [craft-libs 9f89fe8 vendor/krilla/icc/sGrey-v2-magic.icc](https://github.com/storytold/craft-libs/blob/9f89fe8f6d72ab4db4cc06f186381a6a254463ab/vendor/krilla/icc/sGrey-v2-magic.icc) (Krilla 0.8.2 upstream 3ffdf0588cf98050aad6edba51ca70162e1fb5b5) | CC0-1.0 | Embedded by the pinned PDF writer (`craft-pdf` 0.1.0, Krilla 0.8.2); unmodified dependency asset, no file copied into this repository. SHA-256 `4110169287e5f1b5149ca9f1da1814d32943905bba8030e849d1a3b729a19481`. |
|
||||
| `krilla 0.8.2 / icc/sGrey-v4.icc` (dependency-embedded) | Clinton Ingram | [craft-libs 9f89fe8 vendor/krilla/icc/sGrey-v4.icc](https://github.com/storytold/craft-libs/blob/9f89fe8f6d72ab4db4cc06f186381a6a254463ab/vendor/krilla/icc/sGrey-v4.icc) (Krilla 0.8.2 upstream 3ffdf0588cf98050aad6edba51ca70162e1fb5b5) | CC0-1.0 | Embedded by the pinned PDF writer (`craft-pdf` 0.1.0, Krilla 0.8.2); unmodified dependency asset, no file copied into this repository. SHA-256 `00c0f94e09127520a17dc0e1d9264b5702081d96dbdb1549ee88e3631ce42a9d`. |
|
||||
| `krilla 0.8.2 / icc/sRGB-v2-magic.icc` (dependency-embedded) | Clinton Ingram | [craft-libs 9f89fe8 vendor/krilla/icc/sRGB-v2-magic.icc](https://github.com/storytold/craft-libs/blob/9f89fe8f6d72ab4db4cc06f186381a6a254463ab/vendor/krilla/icc/sRGB-v2-magic.icc) (Krilla 0.8.2 upstream 3ffdf0588cf98050aad6edba51ca70162e1fb5b5) | CC0-1.0 | Embedded by the pinned PDF writer (`craft-pdf` 0.1.0, Krilla 0.8.2); unmodified dependency asset, no file copied into this repository. SHA-256 `af4efe28f6d311799865f325ba39184a2e978b113ca74124d60af7de22b105f4`. |
|
||||
| `krilla 0.8.2 / icc/sRGB-v4.icc` (dependency-embedded) | Clinton Ingram | [craft-libs 9f89fe8 vendor/krilla/icc/sRGB-v4.icc](https://github.com/storytold/craft-libs/blob/9f89fe8f6d72ab4db4cc06f186381a6a254463ab/vendor/krilla/icc/sRGB-v4.icc) (Krilla 0.8.2 upstream 3ffdf0588cf98050aad6edba51ca70162e1fb5b5) | CC0-1.0 | Embedded by the pinned PDF writer (`craft-pdf` 0.1.0, Krilla 0.8.2); unmodified dependency asset, no file copied into this repository. SHA-256 `c56e1685d888f5edb92fe07f2750f387f8fe8e91b32ff8fb0b56bfbbb9458353`. |
|
||||
| `assets/app-icon/hicolor/128x128/apps/ai.storyteller.cadcraft.png` | CADCraft contributors | generated from `assets/app-icon/cadcraft-1024.png` | MIT OR Apache-2.0 | Linux hicolor icon |
|
||||
| `assets/app-icon/hicolor/16x16/apps/ai.storyteller.cadcraft.png` | CADCraft contributors | generated from `assets/app-icon/cadcraft-1024.png` | MIT OR Apache-2.0 | Linux hicolor icon |
|
||||
| `assets/app-icon/hicolor/24x24/apps/ai.storyteller.cadcraft.png` | CADCraft contributors | generated from `assets/app-icon/cadcraft-1024.png` | MIT OR Apache-2.0 | Linux hicolor icon |
|
||||
|
||||
Generated
+1060
-226
File diff suppressed because it is too large
Load Diff
+42
-9
@@ -4,10 +4,10 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
# default-members
|
||||
|
||||
[workspace.package]
|
||||
version = "0.12.0"
|
||||
version = "0.13.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
rust-version = "1.95"
|
||||
repository = "https://github.com/storytold/cadcraft"
|
||||
|
||||
[workspace.dependencies]
|
||||
@@ -25,7 +25,10 @@ cadcraft-engine = { path = "crates/engine" }
|
||||
cadcraft-ui-egui = { path = "crates/ui-egui" }
|
||||
cadcraft-mcp = { path = "crates/mcp" }
|
||||
# Which paths stored in a drawing (xrefs, data links) may be opened.
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "9f89fe8f6d72ab4db4cc06f186381a6a254463ab" }
|
||||
# PDF writing (krilla) for plots, and reading them back in tests. The root patches below pin
|
||||
# craft-pdf's patched backends to the same revision (Cargo ignores dependency-local patches).
|
||||
craft-pdf = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "9f89fe8f6d72ab4db4cc06f186381a6a254463ab", default-features = false }
|
||||
|
||||
# foundation
|
||||
thiserror = "2"
|
||||
@@ -50,11 +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 = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
|
||||
egui = "0.36"
|
||||
egui_kittest = "0.36"
|
||||
eframe = { version = "0.36", default-features = false }
|
||||
egui-wgpu = "0.36"
|
||||
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "9f89fe8f6d72ab4db4cc06f186381a6a254463ab", features = ["serde"] }
|
||||
craft-window = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "9f89fe8f6d72ab4db4cc06f186381a6a254463ab", features = ["egui"] }
|
||||
egui = { version = "0.37", default-features = false, features = ["default_fonts", "monochrome_emoji_fonts"] }
|
||||
egui_kittest = "0.37"
|
||||
eframe = { version = "0.37", default-features = false }
|
||||
egui-wgpu = "0.37"
|
||||
# Monotonic time on native and web (performance.now on wasm).
|
||||
web-time = "1.1"
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "9f89fe8f6d72ab4db4cc06f186381a6a254463ab", features = ["egui"] }
|
||||
rfd = "0.17"
|
||||
arboard = { version = "3.6", default-features = false }
|
||||
|
||||
@@ -62,9 +69,11 @@ arboard = { version = "3.6", default-features = false }
|
||||
unsafe_code = "forbid"
|
||||
|
||||
[workspace.lints.clippy]
|
||||
cognitive_complexity = "deny"
|
||||
too_many_arguments = "deny"
|
||||
wildcard_imports = "deny"
|
||||
dbg_macro = "warn"
|
||||
todo = "warn"
|
||||
too_many_arguments = "allow"
|
||||
type_complexity = "allow"
|
||||
|
||||
[profile.dev.package."*"]
|
||||
@@ -80,3 +89,27 @@ opt-level = 3
|
||||
lto = "thin"
|
||||
codegen-units = 16
|
||||
debug = 0
|
||||
|
||||
# PDF backends for craft-pdf: pinned to the same reviewed craft-libs revision as craft-pdf itself
|
||||
# (Cargo ignores dependency-local patches).
|
||||
[patch.crates-io]
|
||||
krilla = { version = "=0.8.2", git = "https://github.com/storytold/craft-libs", rev = "9f89fe8f6d72ab4db4cc06f186381a6a254463ab" }
|
||||
hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "9f89fe8f6d72ab4db4cc06f186381a6a254463ab" }
|
||||
hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "9f89fe8f6d72ab4db4cc06f186381a6a254463ab" }
|
||||
|
||||
# Keep the complete egui family and native event bridge on reviewed fork commits.
|
||||
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" }
|
||||
|
||||
@@ -15,10 +15,36 @@ Mozilla Public License 2.0 and is used unmodified as a separate library (crates/
|
||||
is available from crates.io and the repository above.
|
||||
|
||||
|
||||
None: every bundled asset is original to CADCraft (icons, drafting font, hatch patterns and
|
||||
linetypes are drawn or defined in code). The per-file list is in ATTRIBUTION.md. Builds made with
|
||||
the optional craft-fonts input (CRAFT_FONTS_DIR) embed the fonts listed in its ATTRIBUTION.md,
|
||||
each under the SIL Open Font License 1.1.
|
||||
CADCraft's icons, drafting font, hatch patterns and linetypes are drawn or defined in code.
|
||||
The UI also bundles the egui font inputs described below. The per-file list is in ATTRIBUTION.md.
|
||||
Builds made with the optional craft-fonts input (CRAFT_FONTS_DIR) embed the fonts listed in its
|
||||
ATTRIBUTION.md, each under the SIL Open Font License 1.1.
|
||||
|
||||
CADCraft is an independent project and is not affiliated with, sponsored by or endorsed by
|
||||
Autodesk, Inc. AutoCAD and DWG are trademarks of Autodesk, Inc.
|
||||
|
||||
|
||||
The PDF writer in the pinned craft-pdf 0.1.0 / Krilla 0.8.2 dependency embeds four Compact ICC
|
||||
Profiles by Clinton Ingram, under CC0-1.0. They are unmodified dependency assets; no profile file
|
||||
is copied into this repository. Per-file sources and SHA-256 hashes are in ATTRIBUTION.md.
|
||||
Source: https://github.com/LaurenzV/krilla/tree/3ffdf0588cf98050aad6edba51ca70162e1fb5b5/crates/krilla/icc
|
||||
(pinned through https://github.com/storytold/craft-libs/tree/9f89fe8f6d72ab4db4cc06f186381a6a254463ab/vendor/krilla/icc).
|
||||
CC0 1.0 Universal: https://creativecommons.org/publicdomain/zero/1.0/
|
||||
Pinned licence text: https://github.com/storytold/craft-libs/blob/9f89fe8f6d72ab4db4cc06f186381a6a254463ab/vendor/krilla/icc/LICENSE
|
||||
|
||||
The UI's egui-icons.ttf subset and emoji-icon-font.ttf fallback are provided by epaint_default_fonts
|
||||
and registered through egui's default font definitions. They derive from emoji-icon-font by
|
||||
John Slegers, under the MIT License. The subset and full face share the following upstream notice.
|
||||
Source: https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts
|
||||
Original: https://github.com/jslegers/emoji-icon-font
|
||||
The following text is reproduced verbatim from emoji-icon-font-mit-license.txt:
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2014 John Slegers
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
@@ -123,8 +123,7 @@ numbers):
|
||||
|
||||
## Quick start
|
||||
|
||||
Desktop and web UI builds require Rust 1.95 or later. The core workspace's declared minimum
|
||||
remains Rust 1.90.
|
||||
CADCraft requires Rust 1.95 or later.
|
||||
|
||||
```sh
|
||||
git clone https://github.com/storytold/cadcraft
|
||||
@@ -141,7 +140,7 @@ offset 0.25 then pick the circle and a side
|
||||
zoom e zoom to extents
|
||||
```
|
||||
|
||||
The web build: `cd apps/cadcraft-web && trunk serve` (needs [trunk](https://trunkrs.dev)).
|
||||
The web build: `cd apps/cadcraft-web && trunk serve` (needs [trunk](https://github.com/trunk-rs/trunk)).
|
||||
|
||||
### Fonts
|
||||
|
||||
@@ -219,6 +218,10 @@ cadcraft-cli convert drawing.dxf drawing.svg
|
||||
cadcraft-cli convert drawing.dwg detail.png --window 0,0,20000,15000
|
||||
cadcraft-cli run drawing.dwg --cmd 'zoom {"mode":"window","p1":[0,0],"p2":[20000,15000]}' --export detail.png
|
||||
cadcraft-cli run drawing.dwg --cmd 'select {"window":[[0,0],[20000,15000]]}' --cmd 'zoom {"mode":"object"}' --export sel.png
|
||||
|
||||
# Act on what an earlier --cmd made: {last} is the last handle it returned.
|
||||
cadcraft-cli run --metric --cmd 'layout.set {"name":"Layout1"}' \
|
||||
--cmd 'mview {"p1":[20,20],"p2":[200,150]}' --cmd 'viewport.set {"handle":"{last}","scale":1}'
|
||||
```
|
||||
|
||||
How image exports (PNG 2400×1600, SVG, PDF) are framed: `convert` fits the model-space extents.
|
||||
|
||||
+3
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: pre-alpha** · next: alpha, ~2 points (38% → 40% ready for real work) and ~125–190 Opus 5.5 hours away (the four failing core workflows of the [alpha gate](docs/roadmap.md#alpha-gate))
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (HATCHEDIT boundary add/remove progress) · **Target:** Autodesk AutoCAD 2027
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** trivial (progress log: proxy objects keep their class through a save, #1812; previously minor: HATCHEDIT boundary add/remove progress) · **Target:** Autodesk AutoCAD 2027
|
||||
|
||||
CADCraft targets full parity with AutoCAD (2D drafting first, then annotation, layouts and
|
||||
plotting, DWG, parametrics and 3D), plus what AutoCAD doesn't have: agent control over MCP, a
|
||||
@@ -119,6 +119,7 @@ Ranked; detail and the full beta checklist in [docs/roadmap.md](docs/roadmap.md)
|
||||
|
||||
| Date | Entry |
|
||||
|---|---|
|
||||
| 2026-10-21 | DXF save keeps each proxy object's and proxy entity's class: its class number (group 91) is renumbered to the rewritten CLASSES instead of naming another class, so AutoCAD's visual styles in an R14 file no longer come back as SCALE or MLEADERSTYLE (#1812) |
|
||||
| 2026-10-14 | HATCHEDIT adds boundaries from internal points or closed objects and removes selected loops in the dialog, command line and JSON command; associative links and undo are covered by tests. |
|
||||
| 2026-10-12 | TRIM: a pick after a closed polyline's last cut keeps only the complementary run instead of an extra circuit of the outline (#726) |
|
||||
| 2026-10-12 | HATCHEDIT refuses an explicit bad `scale` or `angle` before it edits anything, so a rejected request leaves the drawing, undo and redo as they were (#724) |
|
||||
@@ -135,6 +136,7 @@ Ranked; detail and the full beta checklist in [docs/roadmap.md](docs/roadmap.md)
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-21 | trivial | Progress log: proxy objects keep their class through a save (#1812) |
|
||||
| 2026-10-14 | minor | Progress log: HATCHEDIT boundary add/remove |
|
||||
| 2026-10-12 | trivial | Progress log: closed-polyline TRIM (#726) |
|
||||
| 2026-10-12 | trivial | Progress log: HATCHEDIT numeric validation (#724) |
|
||||
|
||||
@@ -28,7 +28,7 @@ macro_rules! outln {
|
||||
|
||||
use std::process::ExitCode;
|
||||
|
||||
use cadcraft_engine::doc::Space;
|
||||
use cadcraft_engine::doc::{Handle, Space};
|
||||
use cadcraft_engine::geom::{Bounds2, Vec2};
|
||||
use cadcraft_engine::{Session, View};
|
||||
use serde_json::{Value, json};
|
||||
@@ -73,7 +73,15 @@ image framing (png 2400x1600, svg, pdf; always model space):
|
||||
scaled to fit the image without distortion.
|
||||
--view extents always fit the drawing extents.
|
||||
--view current the current view (as last set by zoom/pan/view.set; a freshly
|
||||
opened drawing's view is its extents plus a 10% margin).";
|
||||
opened drawing's view is its extents plus a 10% margin).
|
||||
|
||||
--cmd chaining (run):
|
||||
A --cmd parameter value of exactly \"{last}\" is replaced with the last object handle an
|
||||
earlier --cmd in the same run returned, so a command can act on what the previous one made:
|
||||
--cmd 'mview {\"p1\":[20,20],\"p2\":[200,150]}' prints {\"viewports\":[\"100\"]}
|
||||
--cmd 'viewport.set {\"handle\":\"{last}\",\"scale\":1}' changes viewport 100
|
||||
It works in any parameter, at any depth (e.g. \"handles\":[\"{last}\"]); every other string is
|
||||
passed unchanged. With no earlier returned handle the run fails and says so.";
|
||||
|
||||
/// What part of model space an image export shows.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
@@ -218,6 +226,12 @@ fn info(path: &str) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write `bytes` to `out` through the engine's atomic writer (an exclusively created sibling file
|
||||
/// renamed over `out`), so a failed or interrupted write leaves the previous file untouched.
|
||||
fn write_atomic(out: &str, bytes: &[u8]) -> std::io::Result<()> {
|
||||
cadcraft_engine::atomic_write::write_atomic(std::path::Path::new(out), bytes)
|
||||
}
|
||||
|
||||
fn export(s: &Session, out: &str) -> Result<(), String> {
|
||||
export_framed(s, out, None)
|
||||
}
|
||||
@@ -225,7 +239,7 @@ fn export(s: &Session, out: &str) -> Result<(), String> {
|
||||
fn export_framed(s: &Session, out: &str, window: Option<Bounds2>) -> Result<(), String> {
|
||||
let d = s.doc().map_err(|e| e.to_string())?;
|
||||
let bytes = cadcraft_io::write_framed(d, out, window).map_err(|e| e.to_string())?;
|
||||
std::fs::write(out, &bytes).map_err(|e| format!("{out}: {e}"))?;
|
||||
write_atomic(out, &bytes).map_err(|e| format!("{out}: {e}"))?;
|
||||
eprintln!("wrote {out} ({} bytes)", bytes.len());
|
||||
if cadcraft_engine::cmd::file::drops_unknown(out) {
|
||||
let lost = cadcraft_engine::cmd::file::not_saved(d);
|
||||
@@ -244,7 +258,7 @@ fn sample(args: &[String]) -> Result<(), String> {
|
||||
other => return Err(format!("unknown sample `{other}` (bracket, floorplan)")),
|
||||
};
|
||||
let bytes = cadcraft_io::write(&d, out).map_err(|e| e.to_string())?;
|
||||
std::fs::write(out, &bytes).map_err(|e| format!("{out}: {e}"))?;
|
||||
write_atomic(out, &bytes).map_err(|e| format!("{out}: {e}"))?;
|
||||
eprintln!("wrote {out} ({} bytes)", bytes.len());
|
||||
Ok(())
|
||||
}
|
||||
@@ -314,6 +328,133 @@ fn skipped_message(d: &cadcraft_engine::doc::Drawing, path: &str) -> Option<Stri
|
||||
Some(format!("{path}: {n} {records} could not be read and are left out ({})", list.join(", ")))
|
||||
}
|
||||
|
||||
/// A `--cmd` parameter value of exactly `{last}` is filled in with the last object handle an
|
||||
/// earlier `--cmd` in the same run returned: `mview` answers `{"viewports":["100"]}`, so
|
||||
/// `viewport.set {"handle":"{last}","scale":1}` changes viewport 100. Any other string is passed
|
||||
/// through unchanged, and `--script` text is never rewritten.
|
||||
const LAST_HANDLE: &str = "{last}";
|
||||
|
||||
/// How deep [`result_handles`] walks a command result (results are the engine's own JSON).
|
||||
const RESULT_DEPTH: usize = 16;
|
||||
|
||||
/// Result keys whose values are object references: the engine's result convention for handles.
|
||||
/// A handle can be a string (`handle`, `viewport`, `insert`…) or a list of them (`handles`,
|
||||
/// `viewports`, `members`…). Every other field may hold a user-defined name, a message or a
|
||||
/// count, and is never read as a handle — a group named `100` is a name, not object `100`.
|
||||
fn handle_field(key: &str) -> bool {
|
||||
matches!(
|
||||
key,
|
||||
"handle"
|
||||
| "handles"
|
||||
| "viewport"
|
||||
| "viewports"
|
||||
| "boundary"
|
||||
| "clip"
|
||||
| "insert"
|
||||
| "members"
|
||||
| "source"
|
||||
| "objects"
|
||||
| "entities"
|
||||
| "selection"
|
||||
| "parts"
|
||||
| "refs"
|
||||
| "matches"
|
||||
| "line"
|
||||
| "arc"
|
||||
| "blocks"
|
||||
)
|
||||
}
|
||||
|
||||
/// Collect the handles of `v`, a value under a handle field: a handle string, an array of them,
|
||||
/// or objects that carry handle fields of their own (`entities`, `parts`).
|
||||
fn collect_result_handles(s: &Session, v: &Value, depth: usize, out: &mut Vec<Handle>) {
|
||||
if depth > RESULT_DEPTH {
|
||||
return;
|
||||
}
|
||||
match v {
|
||||
Value::String(t) => {
|
||||
if let Some(h) = Handle::parse_hex(t)
|
||||
&& s.doc().is_ok_and(|d| d.entity(h).is_some())
|
||||
{
|
||||
out.push(h);
|
||||
}
|
||||
}
|
||||
Value::Array(a) => a.iter().for_each(|x| collect_result_handles(s, x, depth + 1, out)),
|
||||
Value::Object(m) => {
|
||||
for (k, x) in m {
|
||||
if handle_field(k) {
|
||||
collect_result_handles(s, x, depth + 1, out);
|
||||
} else {
|
||||
find_result_handles(s, x, depth + 1, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Find handle fields nested in containers that are not handles themselves (`groups` in
|
||||
/// `groups.list`, whose members are handles): strings here are names, never references.
|
||||
fn find_result_handles(s: &Session, v: &Value, depth: usize, out: &mut Vec<Handle>) {
|
||||
if depth > RESULT_DEPTH {
|
||||
return;
|
||||
}
|
||||
match v {
|
||||
Value::Array(a) => a.iter().for_each(|x| find_result_handles(s, x, depth + 1, out)),
|
||||
Value::Object(m) => {
|
||||
for (k, x) in m {
|
||||
if handle_field(k) {
|
||||
collect_result_handles(s, x, depth + 1, out);
|
||||
} else {
|
||||
find_result_handles(s, x, depth + 1, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// The object handles a command result mentions, in the order it lists them. A handle-only result
|
||||
/// (a handle string or an array of them) counts as it is; otherwise only handle fields do.
|
||||
fn result_handles(s: &Session, v: &Value, depth: usize, out: &mut Vec<Handle>) {
|
||||
match v {
|
||||
Value::String(_) => collect_result_handles(s, v, depth, out),
|
||||
Value::Array(a) if a.iter().all(Value::is_string) => collect_result_handles(s, v, depth, out),
|
||||
Value::Array(_) | Value::Object(_) => find_result_handles(s, v, depth, out),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// The last handle `r` returned: what a later `{last}` stands for.
|
||||
fn returned_handle(s: &Session, r: &Value) -> Option<Handle> {
|
||||
let mut hs = Vec::new();
|
||||
result_handles(s, r, 0, &mut hs);
|
||||
hs.last().copied()
|
||||
}
|
||||
|
||||
/// The active drawing's identity ([`cadcraft_engine::DocState::uid`]): a handle is only meaningful
|
||||
/// in the drawing it came from, and `new`, `open`, `document.switch` and `close` can make another
|
||||
/// drawing current.
|
||||
fn active_drawing(s: &Session) -> Option<u64> {
|
||||
s.docs.get(s.active).map(|st| st.uid)
|
||||
}
|
||||
|
||||
/// Every `{last}` in `p`, at any depth, replaced by `h`. With no handle to fill in it is an
|
||||
/// error, never a token reaching the command as a (bad) handle.
|
||||
fn fill_last(p: &Value, h: Option<Handle>) -> Result<Value, String> {
|
||||
match p {
|
||||
Value::String(t) if t.as_str() == LAST_HANDLE => match h {
|
||||
Some(h) => Ok(Value::String(h.hex())),
|
||||
None => Err(format!("`{LAST_HANDLE}` needs an earlier --cmd in this run to have returned a handle")),
|
||||
},
|
||||
Value::Array(a) => a.iter().map(|x| fill_last(x, h)).collect::<Result<Vec<_>, _>>().map(Value::Array),
|
||||
Value::Object(m) => {
|
||||
m.iter().map(|(k, x)| Ok((k.clone(), fill_last(x, h)?))).collect::<Result<serde_json::Map<String, Value>, _>>().map(Value::Object)
|
||||
}
|
||||
other => Ok(other.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
fn run(args: &[String]) -> Result<(), String> {
|
||||
let mut s = Session::empty();
|
||||
let mut it = args.iter();
|
||||
@@ -349,13 +490,31 @@ fn run(args: &[String]) -> Result<(), String> {
|
||||
}
|
||||
check_framed_output(framing, exp.as_deref())?;
|
||||
let before = model_views(&s);
|
||||
let mut last_handle: Option<Handle> = None;
|
||||
for (kind, body) in steps {
|
||||
// A handle belongs to the drawing that returned it: if this step makes another drawing
|
||||
// current (`new`, `open`, `document.switch`, `close`, or a script running one of them),
|
||||
// the remembered handle is stale. A handle the step itself returns is taken afterwards,
|
||||
// so it is a handle of the drawing it made current.
|
||||
let drawing = active_drawing(&s);
|
||||
match kind.as_str() {
|
||||
"script" => s.script(&body).map_err(|e| e.to_string())?,
|
||||
"script" => {
|
||||
s.script(&body).map_err(|e| e.to_string())?;
|
||||
if active_drawing(&s) != drawing {
|
||||
last_handle = None;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let (id, json) = body.split_once(' ').unwrap_or((&body, "{}"));
|
||||
let params: Value = serde_json::from_str(json).map_err(|e| format!("--cmd {id}: {e}"))?;
|
||||
let params = fill_last(¶ms, last_handle).map_err(|e| format!("--cmd {id}: {e}"))?;
|
||||
let r = s.execute(id, ¶ms).map_err(|e| e.to_string())?;
|
||||
if active_drawing(&s) != drawing {
|
||||
last_handle = None;
|
||||
}
|
||||
if let Some(h) = returned_handle(&s, &r) {
|
||||
last_handle = Some(h);
|
||||
}
|
||||
if !r.is_null() {
|
||||
outln!("{r}");
|
||||
}
|
||||
@@ -709,6 +868,37 @@ fn cli() -> ExitCode {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn scratch_dir(tag: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("cadcraft-cli-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("create scratch dir");
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_atomic_replaces_file_and_leaves_no_staging_files() {
|
||||
let dir = scratch_dir("replace");
|
||||
let out = dir.join("a.dxf");
|
||||
let out = out.to_str().expect("utf-8 path");
|
||||
write_atomic(out, b"old").expect("create");
|
||||
write_atomic(out, b"new").expect("overwrite");
|
||||
assert_eq!(std::fs::read(out).expect("read"), b"new");
|
||||
assert_eq!(std::fs::read_dir(&dir).expect("list").count(), 1);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_atomic_failure_keeps_destination_and_cleans_up() {
|
||||
let dir = scratch_dir("fail");
|
||||
let out = dir.join("target");
|
||||
std::fs::create_dir(&out).expect("dir as destination");
|
||||
std::fs::write(out.join("keep"), b"x").expect("seed");
|
||||
assert!(write_atomic(out.to_str().expect("utf-8 path"), b"new").is_err());
|
||||
assert_eq!(std::fs::read(out.join("keep")).expect("kept"), b"x");
|
||||
assert_eq!(std::fs::read_dir(&dir).expect("list").count(), 1);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
fn b(x0: f64, y0: f64, x1: f64, y1: f64) -> Bounds2 {
|
||||
Bounds2::new(Vec2::new(x0, y0), Vec2::new(x1, y1))
|
||||
}
|
||||
@@ -780,6 +970,40 @@ mod tests {
|
||||
assert!(check_framed_output(Framing::Auto, Some("a.dxf")).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_placeholder_takes_the_previous_handle() {
|
||||
let mut s = sample_session();
|
||||
let r = s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap();
|
||||
let h = returned_handle(&s, &r).expect("LINE returns its handle");
|
||||
assert_eq!(fill_last(&json!({"handle": "{last}"}), Some(h)), Ok(json!({"handle": h.hex()})));
|
||||
// Nested, as in a selection list.
|
||||
assert_eq!(fill_last(&json!({"handles": ["{last}", "1"]}), Some(h)), Ok(json!({"handles": [h.hex(), "1"]})));
|
||||
// Only the exact token: a name or a sentence that merely contains it stays as written.
|
||||
for p in [json!("last"), json!("{last} "), json!({"name": "last"})] {
|
||||
assert_eq!(fill_last(&p, Some(h)), Ok(p.clone()));
|
||||
}
|
||||
// Nothing returned yet: an error, not a token passed to the command as a bad handle.
|
||||
assert!(fill_last(&json!({"handle": "{last}"}), None).unwrap_err().contains("{last}"));
|
||||
}
|
||||
|
||||
/// Handles are read from a result by looking the strings up in the drawing: counts and names
|
||||
/// are not mistaken for handles, and `mview`'s `viewports` works like `handle`/`handles`.
|
||||
#[test]
|
||||
fn returned_handle_reads_only_real_handles() {
|
||||
let mut s = Session::empty();
|
||||
s.new_drawing(false);
|
||||
s.execute("layout.set", &json!({"name": "Layout1"})).unwrap();
|
||||
let r = s.execute("mview", &json!({"p1": [20, 20], "p2": [200, 150]})).unwrap();
|
||||
let h = returned_handle(&s, &r).expect("mview returns its viewport handle");
|
||||
assert!(r["viewports"].as_array().is_some_and(|a| a.iter().any(|v| v.as_str() == Some(h.hex().as_str()))), "{r}");
|
||||
assert_eq!(returned_handle(&s, &json!({"changed": 3, "name": "A1"})), None);
|
||||
// The most recent of several.
|
||||
let a = s.execute("line", &json!({"points": [[0, 0], [1, 0]]})).unwrap();
|
||||
let b = s.execute("line", &json!({"points": [[0, 0], [2, 0]]})).unwrap();
|
||||
let (ha, hb) = (returned_handle(&s, &a).unwrap(), returned_handle(&s, &b).unwrap());
|
||||
assert_eq!(returned_handle(&s, &json!({"handles": [ha.hex(), hb.hex()]})), Some(hb));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_framing_is_extents() {
|
||||
// convert: nothing ran, always the extents.
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
//! A `--cmd` can act on a handle an earlier `--cmd` in the same run returned (#1663).
|
||||
//!
|
||||
//! `mview` answers `{"viewports":["100"]}`, but a following `viewport.set` could not use that
|
||||
//! handle: it had to be guessed, selected by point, or activated with `mspace`. A `--cmd` parameter
|
||||
//! value of exactly `{last}` is now filled in with the last handle an earlier `--cmd` returned.
|
||||
|
||||
use std::process::{Command, Output};
|
||||
|
||||
fn run(args: &[&str]) -> Output {
|
||||
Command::new(env!("CARGO_BIN_EXE_cadcraft-cli"))
|
||||
.args(args)
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.output()
|
||||
.expect("run cadcraft-cli")
|
||||
}
|
||||
|
||||
/// The issue's sequence: `viewport.set` straight after `mview`, addressing the new viewport by the
|
||||
/// handle the runner remembers.
|
||||
#[test]
|
||||
fn viewport_set_after_mview_uses_the_returned_handle() {
|
||||
let out = run(&[
|
||||
"run",
|
||||
"--metric",
|
||||
"--cmd",
|
||||
r#"layout.set {"name":"Layout1"}"#,
|
||||
"--cmd",
|
||||
r#"mview {"p1":[20,20],"p2":[200,150]}"#,
|
||||
"--cmd",
|
||||
r#"viewport.set {"handle":"{last}","scale":1}"#,
|
||||
]);
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(out.status.success(), "exit {:?}\nstdout: {stdout}\nstderr: {stderr}", out.status);
|
||||
let mview = stdout.lines().find(|l| l.contains("\"viewports\"")).unwrap_or_else(|| panic!("no mview result in {stdout:?}"));
|
||||
let created: serde_json::Value = serde_json::from_str(mview).expect("mview result is JSON");
|
||||
let handle = created["viewports"].as_array().and_then(|a| a.first()).and_then(|h| h.as_str()).expect("a viewport handle");
|
||||
assert!(handle.eq_ignore_ascii_case("100"), "{mview}");
|
||||
let set = stdout.lines().find(|l| l.contains("\"viewHeight\"")).unwrap_or_else(|| panic!("no viewport.set result in {stdout:?}"));
|
||||
let vp: serde_json::Value = serde_json::from_str(set).expect("viewport.set result is JSON");
|
||||
assert_eq!(vp[0]["handle"].as_str(), Some(handle), "viewport.set changed the viewport `{handle}`: {set}");
|
||||
assert_eq!(vp[0]["scale"].as_f64(), Some(1.0), "{set}");
|
||||
}
|
||||
|
||||
/// `{last}` with no handle to fill in is a clear error, not the token reaching the command.
|
||||
#[test]
|
||||
fn last_without_an_earlier_handle_is_an_error() {
|
||||
let out = run(&["run", "--metric", "--cmd", r#"viewport.set {"handle":"{last}","scale":1}"#]);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert_eq!(out.status.code(), Some(1), "stdout: {:?}\nstderr: {stderr}", String::from_utf8_lossy(&out.stdout));
|
||||
assert!(stderr.contains("{last}"), "the error names the placeholder: {stderr}");
|
||||
assert!(!stderr.contains("panicked"), "{stderr}");
|
||||
}
|
||||
|
||||
/// The last JSON value a run printed: command results are one JSON value a line (`null` is not
|
||||
/// printed).
|
||||
fn last_json(stdout: &str) -> serde_json::Value {
|
||||
let line = stdout
|
||||
.lines()
|
||||
.rev()
|
||||
.find(|l| {
|
||||
let t = l.trim_start();
|
||||
t.starts_with('{') || t.starts_with('[')
|
||||
})
|
||||
.unwrap_or_else(|| panic!("no JSON result in {stdout:?}"));
|
||||
serde_json::from_str(line).expect("command result is JSON")
|
||||
}
|
||||
|
||||
/// `{last}` was not replaced: the error names the placeholder, and the run fails cleanly.
|
||||
fn assert_needs_an_earlier_handle(out: &Output) {
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert_eq!(out.status.code(), Some(1), "stdout: {:?}\nstderr: {stderr}", String::from_utf8_lossy(&out.stdout));
|
||||
assert!(stderr.contains("{last}"), "the error names the placeholder: {stderr}");
|
||||
assert!(!stderr.contains("panicked"), "{stderr}");
|
||||
}
|
||||
|
||||
/// A user-defined name that happens to look like a handle is not a returned handle. `group`
|
||||
/// answers `{"name":"100","members":1}`: `100` is the first line's handle, but the name is not an
|
||||
/// object reference and must not replace the last handle the `line` command returned (`101`).
|
||||
#[test]
|
||||
fn a_group_name_that_looks_like_a_handle_is_not_last() {
|
||||
let out = run(&[
|
||||
"run",
|
||||
"--metric",
|
||||
"--cmd",
|
||||
r#"line {"points":[[0,0],[10,0]]}"#,
|
||||
"--cmd",
|
||||
r#"line {"points":[[0,10],[10,10]]}"#,
|
||||
"--cmd",
|
||||
r#"group {"name":"100","handles":["101"]}"#,
|
||||
"--cmd",
|
||||
r#"erase {"handles":["{last}"]}"#,
|
||||
"--cmd",
|
||||
r#"entities {"type":"LINE"}"#,
|
||||
]);
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(out.status.success(), "exit {:?}\nstdout: {stdout}\nstderr: {stderr}", out.status);
|
||||
let r = last_json(&stdout);
|
||||
assert_eq!(r["count"].as_u64(), Some(1), "one line is left: {stdout}");
|
||||
let left: Vec<&str> = r["entities"].as_array().into_iter().flatten().filter_map(|e| e["handle"].as_str()).collect();
|
||||
assert_eq!(left, ["100"], "the group name 100 did not replace the handle 101 the line command returned: {stdout}");
|
||||
}
|
||||
|
||||
/// `new` makes another drawing current: the handle an earlier `--cmd` returned in the old drawing
|
||||
/// is not a handle of the new one.
|
||||
#[test]
|
||||
fn last_is_cleared_when_new_switches_drawings() {
|
||||
let out = run(&[
|
||||
"run",
|
||||
"--metric",
|
||||
"--cmd",
|
||||
r#"line {"points":[[0,0],[10,0]]}"#,
|
||||
"--cmd",
|
||||
r#"new {"metric":true}"#,
|
||||
"--cmd",
|
||||
r#"erase {"handles":["{last}"]}"#,
|
||||
]);
|
||||
assert_needs_an_earlier_handle(&out);
|
||||
}
|
||||
|
||||
/// `open` makes the opened file the current drawing: a handle from the drawing before it is stale.
|
||||
#[test]
|
||||
fn last_is_cleared_when_open_switches_drawings() {
|
||||
let dir = std::env::temp_dir().join(format!("cc-cli-last-open-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).expect("temp dir");
|
||||
let dxf = dir.join("opened.dxf");
|
||||
let sample = run(&["sample", "bracket", dxf.to_str().expect("temp path")]);
|
||||
assert!(sample.status.success(), "sample failed: {}", String::from_utf8_lossy(&sample.stderr));
|
||||
let open = format!(r#"open {{"path":{}}}"#, serde_json::to_string(dxf.to_str().expect("temp path")).expect("path JSON"));
|
||||
let out = run(&["run", "--metric", "--cmd", r#"line {"points":[[0,0],[10,0]]}"#, "--cmd", &open, "--cmd", r#"erase {"handles":["{last}"]}"#]);
|
||||
assert_needs_an_earlier_handle(&out);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// `document.switch` changes the current drawing without adding one: a handle remembered from the
|
||||
/// drawing left behind must not be used in the one switched to.
|
||||
#[test]
|
||||
fn last_is_cleared_when_document_switch_changes_drawings() {
|
||||
let out = run(&[
|
||||
"run",
|
||||
"--metric",
|
||||
"--cmd",
|
||||
r#"line {"points":[[0,0],[10,0]]}"#,
|
||||
"--cmd",
|
||||
r#"new {"metric":true}"#,
|
||||
"--cmd",
|
||||
r#"line {"points":[[0,10],[10,10]]}"#,
|
||||
"--cmd",
|
||||
r#"document.switch {"index":0}"#,
|
||||
"--cmd",
|
||||
r#"erase {"handles":["{last}"]}"#,
|
||||
]);
|
||||
assert_needs_an_earlier_handle(&out);
|
||||
}
|
||||
|
||||
/// A handle a command returns after a switch belongs to the drawing now current, so it is kept.
|
||||
#[test]
|
||||
fn a_handle_returned_after_a_switch_is_last() {
|
||||
let out = run(&[
|
||||
"run",
|
||||
"--metric",
|
||||
"--cmd",
|
||||
r#"line {"points":[[0,0],[10,0]]}"#,
|
||||
"--cmd",
|
||||
r#"new {"metric":true}"#,
|
||||
"--cmd",
|
||||
r#"document.switch {"index":0}"#,
|
||||
"--cmd",
|
||||
r#"line {"points":[[0,10],[10,10]]}"#,
|
||||
"--cmd",
|
||||
r#"erase {"handles":["{last}"]}"#,
|
||||
"--cmd",
|
||||
r#"entities {"type":"LINE"}"#,
|
||||
]);
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(out.status.success(), "exit {:?}\nstdout: {stdout}\nstderr: {stderr}", out.status);
|
||||
let r = last_json(&stdout);
|
||||
assert_eq!(r["count"].as_u64(), Some(1), "one line is left: {stdout}");
|
||||
let left: Vec<&str> = r["entities"].as_array().into_iter().flatten().filter_map(|e| e["handle"].as_str()).collect();
|
||||
assert_eq!(left, ["100"], "the line created after the switch was erased: {stdout}");
|
||||
}
|
||||
|
||||
/// A script can change the current drawing too (`new {"metric":true}` at a script line), so the
|
||||
/// remembered handle is cleared after a script that changed it.
|
||||
#[test]
|
||||
fn last_is_cleared_by_a_script_that_changes_drawings() {
|
||||
let out = run(&[
|
||||
"run",
|
||||
"--metric",
|
||||
"--cmd",
|
||||
r#"line {"points":[[0,0],[10,0]]}"#,
|
||||
"--script",
|
||||
r#"new {"metric":true}"#,
|
||||
"--cmd",
|
||||
r#"erase {"handles":["{last}"]}"#,
|
||||
]);
|
||||
assert_needs_an_earlier_handle(&out);
|
||||
}
|
||||
|
||||
/// Only the exact token is a placeholder: ordinary JSON strings are left alone.
|
||||
#[test]
|
||||
fn ordinary_values_are_not_placeholders() {
|
||||
let dir = std::env::temp_dir().join(format!("cc-cli-last-placeholder-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).expect("temp dir");
|
||||
let out_dxf = dir.join("text.dxf");
|
||||
let out = run(&[
|
||||
"run",
|
||||
"--metric",
|
||||
"--cmd",
|
||||
r#"layout.set {"name":"Layout1"}"#,
|
||||
"--cmd",
|
||||
r#"mview {"p1":[20,20],"p2":[200,150]}"#,
|
||||
"--cmd",
|
||||
r#"text {"at":[0,0],"height":2,"text":"last and {last}"}"#,
|
||||
"--save",
|
||||
out_dxf.to_str().expect("temp path"),
|
||||
]);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(out.status.success(), "exit {:?}\nstderr: {stderr}", out.status);
|
||||
let saved = std::fs::read_to_string(&out_dxf).expect("saved DXF");
|
||||
assert!(saved.contains("last and {last}"), "the text is stored literally");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ description = "CADCraft in the browser (eframe web runner, WebGPU with WebGL2 fa
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version = "1.95"
|
||||
publish = false
|
||||
|
||||
[lints]
|
||||
@@ -26,7 +27,7 @@ serde_json = { workspace = true }
|
||||
web-sys = { version = "0.3", features = ["HtmlInputElement", "FileList", "File"] }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
eframe = { workspace = true, features = ["persistence", "default_fonts", "wgpu", "accesskit"] }
|
||||
eframe = { workspace = true, features = ["persistence", "wgpu", "accesskit"] }
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Location", "Navigator", "Blob", "BlobPropertyBag", "Url", "MessageEvent", "UrlSearchParams", "MediaQueryList"] }
|
||||
|
||||
@@ -95,7 +95,8 @@ impl Bridge {
|
||||
}
|
||||
}
|
||||
|
||||
/// After each frame: tell the parent when unsaved changes appear or go away.
|
||||
/// After each logic callback (including a hidden tab's): tell the parent when unsaved changes
|
||||
/// appear or go away. `DirtyReport` suppresses duplicates, so this is idempotent.
|
||||
pub fn report_dirty(&self, app: &CadApp, reported: &DirtyReport) {
|
||||
let dirty = app.session.docs.iter().any(|d| d.is_dirty());
|
||||
if reported.0.get() != Some(dirty) && self.send(&[("type", "cadcraft:dirty".into()), ("dirty", dirty.into())]) {
|
||||
|
||||
+133
-12
@@ -24,22 +24,39 @@ mod web {
|
||||
const LOADING_ID: &str = "cadcraft_loading";
|
||||
|
||||
/// The app, the files File > Open… and drag and drop read for it in the background, the
|
||||
/// page embedding it (`?host=parent`), and the browser's `prefers-reduced-motion` (it stays
|
||||
/// live, so View ▸ Accessibility ▸ Animation Effects ▸ Use System Setting follows a change).
|
||||
struct Shell(CadApp, crate::upload::Inbox, Option<(crate::host::Bridge, crate::host::DirtyReport)>, Option<web_sys::MediaQueryList>);
|
||||
/// page embedding it (`?host=parent`), the browser's `prefers-reduced-motion` (it stays
|
||||
/// live, so View ▸ Accessibility ▸ Animation Effects ▸ Use System Setting follows a change),
|
||||
/// and the document title last written to the page.
|
||||
struct Shell(
|
||||
CadApp,
|
||||
crate::upload::Inbox,
|
||||
Option<(crate::host::Bridge, crate::host::DirtyReport)>,
|
||||
Option<web_sys::MediaQueryList>,
|
||||
Option<String>,
|
||||
);
|
||||
|
||||
impl eframe::App for Shell {
|
||||
/// Runs one frame: takes in files dropped on or picked for the page, drains the background
|
||||
/// uploads, follows the browser's reduced-motion setting, runs the app, writes the browser
|
||||
/// document title when it changed (so the tab and an embedding page name the open
|
||||
/// drawing), and reports unsaved changes to that page.
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
self.1.take_dropped(ctx);
|
||||
// Background reads are consume-based and run on hidden ticks too. Dropped files are
|
||||
// input and are read from `ui` only.
|
||||
self.1.drain(&mut self.0);
|
||||
self.0.system_animations = self.3.as_ref().map(|reduced| !reduced.matches());
|
||||
self.0.logic(ctx);
|
||||
let drawing = self.0.session.state().ok();
|
||||
crate::push_title(&mut self.4, drawing.map(|d| d.title.as_str()), drawing.is_some_and(|d| d.is_dirty()), set_page_title);
|
||||
// `DirtyReport` suppresses duplicates, so reporting after every logic callback
|
||||
// (including a hidden tab's) is idempotent and keeps the parent's guard current.
|
||||
if let Some((bridge, reported)) = &self.2 {
|
||||
bridge.report_dirty(&self.0, reported);
|
||||
}
|
||||
}
|
||||
/// Forward raw input to the web `CadApp` and capture Tab at the command line.
|
||||
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
self.0.raw_input_hook(raw);
|
||||
self.0.raw_input_hook(ctx, raw);
|
||||
cadcraft_ui_egui::cmdline::capture_tab(ctx, raw);
|
||||
}
|
||||
/// Preferences (interface theme and language) survive reloads; egui state is not kept.
|
||||
@@ -50,11 +67,25 @@ mod web {
|
||||
fn persist_egui_memory(&self) -> bool {
|
||||
false
|
||||
}
|
||||
/// On the first pass consume files dropped on the page, then delegate drawing to `CadApp::ui`.
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
self.0.ui(ui);
|
||||
if let Some((bridge, reported)) = &self.2 {
|
||||
bridge.report_dirty(&self.0, reported);
|
||||
let first_pass = ui.ctx().current_pass_index() == 0;
|
||||
if first_pass {
|
||||
// `take_dropped` consumes the raw list; a second pass or hidden tick cannot
|
||||
// re-read the same file.
|
||||
self.1.take_dropped(ui.ctx());
|
||||
}
|
||||
self.0.ui(ui);
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes the browser's document title, which names the tab and is what an embedding page
|
||||
/// (`?host=parent`) reads. eframe's web runner ignores [`egui::ViewportCommand::Title`], so
|
||||
/// the title has to reach the DOM directly.
|
||||
fn set_page_title(title: &str) {
|
||||
match web_sys::window().and_then(|window| window.document()) {
|
||||
Some(document) => document.set_title(title),
|
||||
None => log::warn!("cannot set the page title to {title:?}: there is no document"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,9 +98,11 @@ mod web {
|
||||
}
|
||||
|
||||
/// No file picker on the web: Save / Save As from the menu (and the close prompt) download the
|
||||
/// drawing under its current name; `saveas {"path": "name.dxf"}` picks another name.
|
||||
/// drawing under its current name; `saveas {"path": "name.dxf"}` picks another name. There is
|
||||
/// no file system either, so a dropped file is opened from its contents and APPLOAD says to
|
||||
/// drop a `.lsp` file rather than asking for a name it cannot read.
|
||||
fn services() -> Services {
|
||||
Services { pick_save: Some(Box::new(|name: &str| Some(name.to_string()))), ..Services::default() }
|
||||
Services { pick_save: Some(Box::new(|name: &str| Some(name.to_string()))), no_file_system: true, ..Services::default() }
|
||||
}
|
||||
|
||||
pub fn start() {
|
||||
@@ -152,7 +185,7 @@ mod web {
|
||||
(bridge, crate::host::DirtyReport::default())
|
||||
});
|
||||
let reduced_motion = web_sys::window().and_then(|w| w.match_media("(prefers-reduced-motion: reduce)").ok().flatten());
|
||||
Ok(Box::new(Shell(app, inbox, host, reduced_motion)))
|
||||
Ok(Box::new(Shell(app, inbox, host, reduced_motion, None)))
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
@@ -177,6 +210,44 @@ fn wants_cpu(query: &str) -> bool {
|
||||
query.trim_start_matches('?').split('&').any(|p| p == "cpu" || p.starts_with("cpu="))
|
||||
}
|
||||
|
||||
/// The browser tab title: the open drawing's file name, a bullet once it has unsaved changes,
|
||||
/// and the app name (`drawing.dwg • — CADCraft`), or just the app name with no drawing. An
|
||||
/// embedding page (`?host=parent`) reads it to tell which drawing opened and whether it is dirty.
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
fn document_title(name: Option<&str>, dirty: bool) -> String {
|
||||
match name.filter(|n| !n.trim().is_empty()) {
|
||||
Some(name) => format!("{name}{} — CADCraft", if dirty { " •" } else { "" }),
|
||||
None => "CADCraft".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The title to write this frame, or `None` while the page already shows it: the browser needs
|
||||
/// the document title only when it changes, not on every frame.
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
fn title_to_send(sent: &mut Option<String>, name: Option<&str>, dirty: bool) -> Option<String> {
|
||||
let title = document_title(name, dirty);
|
||||
if sent.as_deref() == Some(title.as_str()) {
|
||||
return None;
|
||||
}
|
||||
*sent = Some(title.clone());
|
||||
Some(title)
|
||||
}
|
||||
|
||||
/// Writes a changed title through `write` (on the web, the browser's document title) and reports
|
||||
/// whether it wrote one. Split from [`title_to_send`] so the change decision and the write are
|
||||
/// both testable: eframe's web backend ignores `egui::ViewportCommand::Title`, so a title that is
|
||||
/// computed but not written leaves the tab on `CADCraft`.
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
fn push_title(sent: &mut Option<String>, name: Option<&str>, dirty: bool, write: impl FnOnce(&str)) -> bool {
|
||||
match title_to_send(sent, name, dirty) {
|
||||
Some(title) => {
|
||||
write(&title);
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod upload;
|
||||
|
||||
@@ -192,8 +263,10 @@ fn main() {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::wants_cpu;
|
||||
use super::{document_title, push_title, title_to_send, wants_cpu};
|
||||
|
||||
/// Guards the `?cpu` URL flag: only a whole `cpu` query parameter (bare or with a value)
|
||||
/// selects the CPU canvas, so `?sample` and `?gpucpu` stay on the GPU path.
|
||||
#[test]
|
||||
fn cpu_flag_is_detected() {
|
||||
assert!(wants_cpu("?cpu"));
|
||||
@@ -204,4 +277,52 @@ mod tests {
|
||||
assert!(!wants_cpu("?webgl&sample"));
|
||||
assert!(!wants_cpu("?gpucpu"));
|
||||
}
|
||||
|
||||
/// Guards the tab title text: a drawing contributes its name and, when dirty, a trailing
|
||||
/// bullet; a blank or missing name leaves the app name alone and never shows a bullet.
|
||||
#[test]
|
||||
fn viewport_title_names_the_document_and_marks_unsaved_changes() {
|
||||
assert_eq!(document_title(Some("drawing.dwg"), false), "drawing.dwg — CADCraft");
|
||||
assert_eq!(document_title(Some("drawing.dwg"), true), "drawing.dwg • — CADCraft");
|
||||
assert_eq!(document_title(Some("Drawing1"), false), "Drawing1 — CADCraft");
|
||||
assert_eq!(document_title(None, false), "CADCraft");
|
||||
assert_eq!(document_title(None, true), "CADCraft");
|
||||
assert_eq!(document_title(Some(" "), true), "CADCraft");
|
||||
}
|
||||
|
||||
/// Guards the change signal: a title is sent on open, rename, dirty change and close, but
|
||||
/// never twice in a row, so an idle tab is not repainted by a repeated title command.
|
||||
#[test]
|
||||
fn viewport_title_follows_open_rename_and_dirty_changes_only() {
|
||||
let mut sent = None;
|
||||
assert_eq!(title_to_send(&mut sent, Some("drawing.dwg"), false).as_deref(), Some("drawing.dwg — CADCraft"));
|
||||
// The page already has it: send nothing.
|
||||
assert_eq!(title_to_send(&mut sent, Some("drawing.dwg"), false), None);
|
||||
// Unsaved changes add the bullet.
|
||||
assert_eq!(title_to_send(&mut sent, Some("drawing.dwg"), true).as_deref(), Some("drawing.dwg • — CADCraft"));
|
||||
// A rename shows the new name.
|
||||
assert_eq!(title_to_send(&mut sent, Some("plan.dwg"), true).as_deref(), Some("plan.dwg • — CADCraft"));
|
||||
// Saving clears it again.
|
||||
assert_eq!(title_to_send(&mut sent, Some("plan.dwg"), false).as_deref(), Some("plan.dwg — CADCraft"));
|
||||
// Closing the last drawing leaves the app name only.
|
||||
assert_eq!(title_to_send(&mut sent, None, false).as_deref(), Some("CADCraft"));
|
||||
assert_eq!(title_to_send(&mut sent, None, false), None);
|
||||
}
|
||||
|
||||
/// Guards the title write: every changed title reaches the writer (on the web, the browser
|
||||
/// document title) exactly once, and an unchanged one is not written again. Without the write
|
||||
/// the tab and an embedding page stay on `CADCraft` however the drawing changes.
|
||||
#[test]
|
||||
fn changed_title_reaches_the_document_title_writer_once() {
|
||||
let mut sent = None;
|
||||
let mut written: Vec<String> = Vec::new();
|
||||
assert!(push_title(&mut sent, Some("drawing.dwg"), false, |title| written.push(title.to_owned())));
|
||||
// Unchanged: nothing is written again.
|
||||
assert!(!push_title(&mut sent, Some("drawing.dwg"), false, |title| written.push(title.to_owned())));
|
||||
// Unsaved changes are written.
|
||||
assert!(push_title(&mut sent, Some("drawing.dwg"), true, |title| written.push(title.to_owned())));
|
||||
// Closing the drawing writes the app name.
|
||||
assert!(push_title(&mut sent, None, false, |title| written.push(title.to_owned())));
|
||||
assert_eq!(written, ["drawing.dwg — CADCraft", "drawing.dwg • — CADCraft", "CADCraft"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,8 +68,9 @@ impl Inbox {
|
||||
}
|
||||
|
||||
/// Read the files dropped on the page this frame (eframe hands them over unread on the web).
|
||||
/// The list is taken, not cloned: a later pass or hidden tick must not read the same drop.
|
||||
pub fn take_dropped(&self, ctx: &egui::Context) {
|
||||
for f in ctx.input(|i| i.raw.dropped_files.clone()) {
|
||||
for f in ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files)) {
|
||||
if let Some(file) = f.web_file() {
|
||||
self.read(file.clone());
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ description = "CADCraft desktop app"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version = "1.95"
|
||||
publish = false
|
||||
default-run = "cadcraft"
|
||||
|
||||
@@ -16,7 +17,7 @@ cadcraft-ui-egui = { workspace = true }
|
||||
cadcraft-io = { workspace = true }
|
||||
# For the shared per-session control-token helper (control_server.rs authentication).
|
||||
cadcraft-mcp = { workspace = true }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "links", "persistence"] }
|
||||
eframe = { workspace = true, features = ["clipboard", "wgpu", "accesskit", "wayland", "x11", "links", "persistence"] }
|
||||
egui = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
@@ -33,10 +34,16 @@ winit = { version = "0.30", default-features = false, features = ["x11"] }
|
||||
zbus = { version = "=5.19.0", default-features = false, features = ["async-io", "blocking-api"] }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
# Floating panels open in their own OS windows on the desktop; the web build keeps them in the page.
|
||||
craft-ui = { workspace = true, features = ["native-windows"] }
|
||||
craft-update = { workspace = true }
|
||||
rfd = { workspace = true }
|
||||
sys-locale = "0.3"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
# `title_bar::sync_movable`: AppKit's title-band drag leaves the window still under the row's
|
||||
# controls and under floating panels.
|
||||
craft-window = { workspace = true }
|
||||
muda = { version = "0.21", default-features = false }
|
||||
# View ▸ Accessibility ▸ Animation Effects ▸ Use System Setting: the system's Reduce motion
|
||||
# (system_animations.rs). Already in Cargo.lock through muda.
|
||||
|
||||
+65
-17
@@ -39,11 +39,22 @@ struct App {
|
||||
workspace_error: Option<String>,
|
||||
#[cfg(target_os = "macos")]
|
||||
native_menu: Option<native_menu::NativeMenu>,
|
||||
/// The `movable` flag last set on the main window.
|
||||
#[cfg(target_os = "macos")]
|
||||
movable: Option<bool>,
|
||||
}
|
||||
|
||||
impl App {
|
||||
fn persist_workspace(&mut self, final_attempt: bool) {
|
||||
let result = if final_attempt { self.workspace.save_on_exit(&self.cad.ui) } else { self.workspace.save_if_changed(&self.cad.ui) };
|
||||
/// `ctx` is `None` only for the final attempt at exit.
|
||||
fn persist_workspace(&mut self, ctx: Option<&egui::Context>) {
|
||||
let result = match ctx {
|
||||
None => self.workspace.save_on_exit(&self.cad.ui),
|
||||
Some(ctx) => self.workspace.save_if_changed(&self.cad.ui, std::time::Instant::now()).map(|wait| {
|
||||
if let Some(wait) = wait {
|
||||
ctx.request_repaint_after(wait);
|
||||
}
|
||||
}),
|
||||
};
|
||||
if let Err(error) = result {
|
||||
if self.workspace_error.as_ref() != Some(&error) {
|
||||
eprintln!("CADCraft workspace: {error}");
|
||||
@@ -65,37 +76,49 @@ fn follow_desktop(raw: &mut egui::RawInput, desktop: Option<egui::Theme>) {
|
||||
}
|
||||
|
||||
impl eframe::App for App {
|
||||
/// eframe logic callback: syncs the OS animation setting and runs `CadApp::logic`; when the pass index is 0 (the first pass or a hidden tick) it also installs/polls the macOS native menu, polls the spacemouse and persists the workspace; a quit request closes the viewport.
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
if self.native_menu.is_none() && std::env::var_os("CADCRAFT_NO_NATIVE_MENU").is_none() {
|
||||
self.native_menu = Some(native_menu::NativeMenu::install(&mut self.cad));
|
||||
}
|
||||
if let Some(m) = &mut self.native_menu {
|
||||
m.poll(&mut self.cad, ctx);
|
||||
}
|
||||
}
|
||||
self.cad.system_animations = self.system_animations.current();
|
||||
// Consume control/open/screenshot queues and time-based autosave, including hidden ticks.
|
||||
self.cad.logic(ctx);
|
||||
self.spacemouse.poll(&mut self.cad, ctx);
|
||||
self.persist_workspace(false);
|
||||
// Host frame work runs here, once per frame, and so also on a hidden logic-only tick (which
|
||||
// never reaches `App::ui`): native menus stay current, the spacemouse still navigates and a
|
||||
// workspace changed while hidden is persisted. `current_pass_index()` is zero on the first
|
||||
// pass and on every hidden tick, and non-zero on later passes of a multi-pass frame.
|
||||
if ctx.current_pass_index() == 0 {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
if self.native_menu.is_none() && std::env::var_os("CADCRAFT_NO_NATIVE_MENU").is_none() {
|
||||
self.native_menu = Some(native_menu::NativeMenu::install(&mut self.cad, ctx));
|
||||
}
|
||||
if let Some(m) = &mut self.native_menu {
|
||||
m.poll(&mut self.cad, ctx);
|
||||
}
|
||||
}
|
||||
self.spacemouse.poll(&mut self.cad, ctx);
|
||||
// After `cad.logic`, so a control command's workspace change is saved in the same frame.
|
||||
self.persist_workspace(Some(ctx));
|
||||
}
|
||||
if std::mem::take(&mut self.cad.quit_requested) {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
}
|
||||
}
|
||||
/// Apply the desktop-theme fallback, forward raw input to `CadApp`, and capture Tab at the command line.
|
||||
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
follow_desktop(raw, self.desktop_theme.current());
|
||||
self.cad.raw_input_hook(raw);
|
||||
self.cad.raw_input_hook(ctx, raw);
|
||||
cadcraft_ui_egui::cmdline::capture_tab(ctx, raw);
|
||||
}
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
self.cad.ui(ui);
|
||||
self.persist_workspace(false);
|
||||
#[cfg(target_os = "macos")]
|
||||
craft_window::title_bar::sync_movable(ui.ctx(), &mut self.movable);
|
||||
}
|
||||
/// A normal exit: the workspace is saved, and this run's autosave files are not needed (a
|
||||
/// crash leaves them for recovery).
|
||||
fn on_exit(&mut self) {
|
||||
self.persist_workspace(true);
|
||||
self.persist_workspace(None);
|
||||
self.cad.cancel_screenshots();
|
||||
self.cad.shutdown_autosave();
|
||||
}
|
||||
/// Preferences (the interface theme) survive restarts; window and egui state are not kept.
|
||||
@@ -143,6 +166,13 @@ fn services() -> Services {
|
||||
.save_file()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
})),
|
||||
// APPLOAD: AutoLISP routines only, starting where the last one was loaded from.
|
||||
pick_lisp: Some(Box::new(|dir: &str| {
|
||||
let dialog = rfd::FileDialog::new().add_filter(i18n::t("AutoLISP (LSP)"), &["lsp"]).add_filter(i18n::t("All files"), &["*"]);
|
||||
let dialog = if dir.is_empty() { dialog } else { dialog.set_directory(dir) };
|
||||
dialog.pick_file().map(|p| p.to_string_lossy().to_string())
|
||||
})),
|
||||
no_file_system: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,9 +255,19 @@ fn control_flag(current: Option<u16>, value: Option<String>) -> Option<u16> {
|
||||
current
|
||||
}
|
||||
|
||||
/// Parse CLI options (`--control`, `--sample`, files, `--version`), install logging and I/O hooks, build the services and app, and run eframe.
|
||||
fn main() -> eframe::Result {
|
||||
// First, so every start-up record is captured; see `logging`.
|
||||
let logger = logging::install();
|
||||
// The update helper relaunches this binary after a self-update; it runs and exits here.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Some(done) = craft_update::self_update::finish(&craft_update::suite::CADCRAFT, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
|
||||
if let Err(e) = done {
|
||||
eprintln!("CADCraft update helper failed: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
// lossy, so a value that is not Unicode is reported like any other bad port instead of ignored
|
||||
let mut control_port =
|
||||
control_port_from("CADCRAFT_CONTROL_PORT", std::env::var_os("CADCRAFT_CONTROL_PORT").map(|p| p.to_string_lossy().into_owned()));
|
||||
@@ -273,6 +313,9 @@ fn main() -> eframe::Result {
|
||||
.with_title_shown(false)
|
||||
.with_app_id(APP_ID),
|
||||
persist_window: false,
|
||||
// CADCraft installs its own capped font chain (`theme::install_fonts`); never run
|
||||
// system-font discovery as a hidden fallback.
|
||||
system_font_fallback: false,
|
||||
// A stencil buffer clips the contents of clipped layout viewports on the GPU.
|
||||
stencil_buffer: STENCIL_BITS,
|
||||
..Default::default()
|
||||
@@ -331,6 +374,7 @@ fn main() -> eframe::Result {
|
||||
// Autosave files (SAVEFILEPATH) default to `<settings dir>/autosave`.
|
||||
if let Some(dir) = config_dir() {
|
||||
app.enable_autosave(&dir.join("autosave"));
|
||||
app.enable_update_check();
|
||||
// Startup LISP (acad.lsp, acaddoc.lsp) is read from `<settings>/support`; the
|
||||
// folder is made so it is there to put them in.
|
||||
let support = dir.join("support");
|
||||
@@ -357,7 +401,9 @@ fn main() -> eframe::Result {
|
||||
app.open_path(f);
|
||||
}
|
||||
if app.session.docs.is_empty() {
|
||||
app.session.new_drawing(false);
|
||||
// In the units the profile asks for (MEASUREINIT, restored above).
|
||||
let metric = app.session.settings.measureinit;
|
||||
app.session.new_drawing(metric);
|
||||
}
|
||||
let ctx = cc.egui_ctx.clone();
|
||||
let desktop_theme = desktop_theme::DesktopTheme::start(move || ctx.request_repaint());
|
||||
@@ -372,6 +418,8 @@ fn main() -> eframe::Result {
|
||||
workspace_error,
|
||||
#[cfg(target_os = "macos")]
|
||||
native_menu: None,
|
||||
#[cfg(target_os = "macos")]
|
||||
movable: None,
|
||||
}))
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::str::FromStr;
|
||||
use std::sync::mpsc;
|
||||
|
||||
use cadcraft_ui_egui::menus::{self, Entry};
|
||||
use cadcraft_ui_egui::{CadApp, i18n};
|
||||
@@ -15,10 +16,14 @@ pub struct NativeMenu {
|
||||
submenus: Vec<(String, Submenu)>,
|
||||
predefined: Vec<(&'static str, PredefinedMenuItem)>,
|
||||
language: &'static str,
|
||||
/// Monotonic time of the last enablement refresh. `NEG_INFINITY` makes the first poll refresh
|
||||
/// immediately: `now_ms` starts near zero, so the old `0.0` sentinel suppressed the first
|
||||
/// ~300 ms of menu enablement.
|
||||
last_refresh: f64,
|
||||
/// The recent files File ▸ Open Recent lists now (it is rebuilt when they change).
|
||||
recent: Vec<String>,
|
||||
counter: usize,
|
||||
events: mpsc::Receiver<MenuEvent>,
|
||||
}
|
||||
|
||||
/// Accelerator for "Cmd+Shift+S" (modifier-less shortcuts stay in the app so typing works).
|
||||
@@ -30,7 +35,16 @@ fn accel(sc: &str) -> Option<Accelerator> {
|
||||
}
|
||||
|
||||
impl NativeMenu {
|
||||
pub fn install(app: &mut CadApp) -> Self {
|
||||
/// Build and register the macOS menu (app menu, Settings and the translated command tree); returns the state holding item ids and submenus.
|
||||
pub fn install(app: &mut CadApp, ctx: &egui::Context) -> Self {
|
||||
// A menu choice (or its shortcut, such as Cmd+W) is no window event, so winit would not
|
||||
// wake the app for it: the handler does, and the choice runs in the next frame.
|
||||
let (sender, events) = mpsc::channel();
|
||||
let wake = ctx.clone();
|
||||
MenuEvent::set_event_handler(Some(move |event| {
|
||||
let _ = sender.send(event);
|
||||
wake.request_repaint();
|
||||
}));
|
||||
let menu = Menu::new();
|
||||
let mut items = HashMap::new();
|
||||
let language = app.language();
|
||||
@@ -66,7 +80,17 @@ impl NativeMenu {
|
||||
submenus.push((title, sub));
|
||||
}
|
||||
menu.init_for_nsapp();
|
||||
Self { _menu: menu, items, submenus, predefined, language, last_refresh: 0.0, recent: app.ui.recent_files.clone(), counter }
|
||||
Self {
|
||||
_menu: menu,
|
||||
items,
|
||||
submenus,
|
||||
predefined,
|
||||
language,
|
||||
last_refresh: f64::NEG_INFINITY,
|
||||
recent: app.ui.recent_files.clone(),
|
||||
counter,
|
||||
events,
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuild File ▸ Open Recent after a drawing was opened or the list was cleared: the native
|
||||
@@ -87,13 +111,14 @@ impl NativeMenu {
|
||||
build(&sub, &menus::recent_entries(app), &mut self.items, &mut self.submenus, &mut self.counter, self.language);
|
||||
}
|
||||
|
||||
/// Drain queued menu events into commands (Discord opens externally), rebuild Open Recent, retranslate on a language change and refresh enablement when 300 ms have elapsed.
|
||||
pub fn poll(&mut self, app: &mut CadApp, ctx: &egui::Context) {
|
||||
while let Ok(ev) = MenuEvent::receiver().try_recv() {
|
||||
while let Ok(ev) = self.events.try_recv() {
|
||||
if let Some((cmd, _, _)) = self.items.get(ev.id.as_ref()) {
|
||||
if cmd == "ui.discord" {
|
||||
ctx.open_url(egui::OpenUrl::new_tab("https://discord.gg/artcraft"));
|
||||
} else {
|
||||
menus::activate(app, &cmd.clone());
|
||||
menus::activate_native(app, ctx, &cmd.clone());
|
||||
}
|
||||
ctx.request_repaint();
|
||||
}
|
||||
@@ -113,17 +138,25 @@ impl NativeMenu {
|
||||
}
|
||||
}
|
||||
let now = cadcraft_ui_egui::now_ms();
|
||||
if now - self.last_refresh < 300.0 {
|
||||
if !refresh_due(now, self.last_refresh) {
|
||||
return;
|
||||
}
|
||||
self.last_refresh = now;
|
||||
let typing = menus::text_field_window(ctx).is_some();
|
||||
for (cmd, _, item) in self.items.values() {
|
||||
let en = cadcraft_engine::find_command(cmd).is_none_or(|c| (c.enabled)(&app.session).is_ok());
|
||||
let en = (typing && menus::edits_text(cmd)) || cadcraft_engine::find_command(cmd).is_none_or(|c| (c.enabled)(&app.session).is_ok());
|
||||
item.set_enabled(en);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The periodic enablement refresh is due every 300 ms. Split out so the first-poll sentinel is
|
||||
/// testable without a macOS menu: `now_ms` starts near zero, so a `0.0` initial value would
|
||||
/// suppress the first refresh for 300 ms.
|
||||
fn refresh_due(now: f64, last_refresh: f64) -> bool {
|
||||
now - last_refresh >= 300.0
|
||||
}
|
||||
|
||||
fn build(
|
||||
parent: &Submenu,
|
||||
entries: &[Entry],
|
||||
@@ -150,3 +183,17 @@ fn build(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::refresh_due;
|
||||
|
||||
/// `refresh_due` treats `NEG_INFINITY` as due at any finite time and `0.0` as due once 300 ms have elapsed; `install` initializes a fresh menu with `last_refresh = NEG_INFINITY`.
|
||||
#[test]
|
||||
fn the_first_poll_refreshes_immediately() {
|
||||
assert!(refresh_due(0.5, f64::NEG_INFINITY), "a fresh menu refreshes on its first poll");
|
||||
assert!(!refresh_due(0.5, 0.0), "the old 0.0 sentinel delayed the first refresh");
|
||||
assert!(!refresh_due(299.9, 0.0));
|
||||
assert!(refresh_due(300.0, 0.0));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,6 +107,23 @@ impl Filter {
|
||||
*self = Self::default();
|
||||
}
|
||||
|
||||
fn update_drift(&mut self, complete: bool, now: Instant, bias: &mut [f64; 6]) {
|
||||
if !complete {
|
||||
return;
|
||||
}
|
||||
if self.raw.iter().zip(*bias).all(|(raw, b)| (*raw - b).abs() <= FULL_SCALE * DEADZONE) {
|
||||
let since = *self.idle_since.get_or_insert(now);
|
||||
if now.saturating_duration_since(since) >= Duration::from_secs(2) {
|
||||
for (b, raw) in bias.iter_mut().zip(self.raw) {
|
||||
*b += (raw - *b) * 0.01;
|
||||
}
|
||||
self.bias = Some(*bias);
|
||||
}
|
||||
} else {
|
||||
self.idle_since = None;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update(&mut self, values: [i16; 6], mask: u8, now: Instant) -> Motion {
|
||||
for (index, seen) in self.seen.iter_mut().enumerate() {
|
||||
if mask & (1 << index) != 0 {
|
||||
@@ -128,19 +145,7 @@ impl Filter {
|
||||
}
|
||||
let Some(mut bias) = self.bias else { return Motion::default() };
|
||||
// Follow small resting drift only after two seconds entirely inside the deadzone.
|
||||
if complete {
|
||||
if self.raw.iter().zip(bias).all(|(raw, bias)| (*raw - bias).abs() <= FULL_SCALE * DEADZONE) {
|
||||
let since = *self.idle_since.get_or_insert(now);
|
||||
if now.saturating_duration_since(since) >= Duration::from_secs(2) {
|
||||
for (bias, raw) in bias.iter_mut().zip(self.raw) {
|
||||
*bias += (raw - *bias) * 0.01;
|
||||
}
|
||||
self.bias = Some(bias);
|
||||
}
|
||||
} else {
|
||||
self.idle_since = None;
|
||||
}
|
||||
}
|
||||
self.update_drift(complete, now, &mut bias);
|
||||
let mut axes = [0.0; 6];
|
||||
for ((axis, raw), bias) in axes.iter_mut().zip(self.raw).zip(bias) {
|
||||
let value = ((raw - bias) / FULL_SCALE).clamp(-1.0, 1.0);
|
||||
@@ -156,3 +161,77 @@ impl Filter {
|
||||
Motion { pan: [tx, ty], zoom: (tz + rz).clamp(-1.0, 1.0) }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn decode_reports() {
|
||||
assert_eq!(decode(&[]), None);
|
||||
assert_eq!(decode(&[3]), None);
|
||||
assert_eq!(decode(&[3, 5]), Some(Report::Buttons(5)));
|
||||
let values = [1_i16, -2, 3, 4, -5, 6];
|
||||
let mut bytes = vec![1_u8];
|
||||
for v in values {
|
||||
bytes.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
assert_eq!(decode(&bytes), Some(Report::Axes { values, mask: 3 }));
|
||||
bytes[0] = 2;
|
||||
assert_eq!(decode(&bytes), Some(Report::Axes { values: [0, 0, 0, 1, -2, 3], mask: 2 }));
|
||||
assert_eq!(decode(&[2, 1, 2, 3]), None, "too short for a rotation report");
|
||||
}
|
||||
|
||||
/// Sixteen complete reports at rest, spread over more than CALIBRATION_TIME, all stamped
|
||||
/// on a made-up point in time big enough that they sit in the past of later updates.
|
||||
fn calibrated(t0: Instant) -> Filter {
|
||||
let mut f = Filter::default();
|
||||
for i in 0..17 {
|
||||
let at = t0 + Duration::from_millis(50 * i);
|
||||
assert_eq!(f.update([0; 6], 1, at), Motion::default());
|
||||
assert_eq!(f.update([0; 6], 2, at), Motion::default());
|
||||
}
|
||||
assert!(f.calibrated());
|
||||
f
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn axes_deflect_and_expire() {
|
||||
let t0 = Instant::now();
|
||||
let mut f = calibrated(t0);
|
||||
let at = t0 + Duration::from_secs(2);
|
||||
let ms = Duration::from_millis;
|
||||
let m = f.update([500, 500, 0, 0, 0, 0], 1, at);
|
||||
assert!((m.pan[0] - 1.0).abs() < 1e-9);
|
||||
assert!((m.pan[1] - 1.0).abs() < 1e-9);
|
||||
assert_eq!(m.zoom, 0.0, "no rotation input");
|
||||
// A fresh rotation report drives the zoom.
|
||||
let m = f.update([0, 0, 0, 0, 0, 500], 2, at + ms(100));
|
||||
assert!((m.zoom - 1.0).abs() < 1e-9);
|
||||
// The rotation axes went quiet for over 250 ms while translations still arrive.
|
||||
let m = f.update([500, 500, 0, 0, 0, 0], 1, at + ms(400));
|
||||
assert_eq!(m.zoom, 0.0);
|
||||
// A fresh rotation report puts the zoom back.
|
||||
let m = f.update([500, 500, 0, 0, 0, 500], 2, at + ms(500));
|
||||
assert!((m.zoom - 1.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drift_tracks_rest_after_two_idle_seconds() {
|
||||
// Five axes units of drift sit inside the deadzone (FULL_SCALE * DEADZONE = 20).
|
||||
let mut f = Filter { raw: [5.0; 6], bias: Some([0.0; 6]), ..Filter::default() };
|
||||
let t0 = Instant::now();
|
||||
let mut b = [0.0; 6];
|
||||
f.update_drift(true, t0, &mut b); // starts the idle window: no shift yet
|
||||
assert_eq!(b, [0.0; 6]);
|
||||
f.update_drift(true, t0 + Duration::from_secs(3), &mut b);
|
||||
assert!((b[0] - 0.05).abs() < 1e-12, "bias follows resting drift");
|
||||
assert_eq!(f.bias, Some(b));
|
||||
// Motion outside the deadzone resets the idle window.
|
||||
f.raw = [100.0; 6];
|
||||
f.update_drift(true, t0 + Duration::from_secs(4), &mut b);
|
||||
assert_eq!(f.idle_since, None);
|
||||
f.update_drift(true, t0 + Duration::from_secs(5), &mut b);
|
||||
assert!((b[0] - 0.05).abs() < 1e-12, "no shift before two seconds at rest");
|
||||
}
|
||||
}
|
||||
|
||||
+108
-27
@@ -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<Value>,
|
||||
read_failed: bool,
|
||||
pending_write_error: Option<String>,
|
||||
last_write: Option<Instant>,
|
||||
}
|
||||
|
||||
fn config_dir_from(platform: &str, get: impl Fn(&str) -> Option<OsString>) -> Option<PathBuf> {
|
||||
@@ -32,7 +37,7 @@ fn config_dir_from(platform: &str, get: impl Fn(&str) -> Option<OsString>) -> Op
|
||||
}
|
||||
|
||||
fn snapshot(ui: &UiState) -> Result<Value, String> {
|
||||
ui.docking.validate()?;
|
||||
cadcraft_ui_egui::docking::validate(&ui.docking)?;
|
||||
if !matches!(ui.toolset_tab.as_str(), "Drafting" | "Modeling") {
|
||||
return Err("toolsetTab must be Drafting or Modeling".into());
|
||||
}
|
||||
@@ -44,6 +49,7 @@ fn snapshot(ui: &UiState) -> Result<Value, String> {
|
||||
}
|
||||
Ok(json!({
|
||||
"docking": ui.docking,
|
||||
"dockingVersion": cadcraft_ui_egui::docking::LAYOUT_VERSION,
|
||||
"showToolsets": ui.show_toolsets,
|
||||
"showPalettes": ui.show_palettes,
|
||||
"showToolbar": ui.show_toolbar,
|
||||
@@ -80,12 +86,18 @@ fn read(path: &Path) -> Result<Option<UiState>, 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::<cadcraft_ui_egui::docking::Workspace>(docking)
|
||||
&& docking.validate().is_ok()
|
||||
&& cadcraft_ui_egui::docking::validate(&docking).is_ok()
|
||||
{
|
||||
ui.docking = docking;
|
||||
}
|
||||
@@ -136,38 +148,47 @@ impl Workspace {
|
||||
match result {
|
||||
Ok(ui) => {
|
||||
let initial = ui.as_ref().cloned().unwrap_or_default();
|
||||
(Self { path, last_attempt: snapshot(&initial).ok(), read_failed: false, pending_write_error: None }, ui, None)
|
||||
(Self { path, last_attempt: snapshot(&initial).ok(), read_failed: false, pending_write_error: None, last_write: None }, ui, None)
|
||||
}
|
||||
Err(error) => (
|
||||
Self { path, last_attempt: None, read_failed: true, pending_write_error: None },
|
||||
Self { path, last_attempt: None, read_failed: true, pending_write_error: None, last_write: None },
|
||||
None,
|
||||
Some(format!("Workspace could not be restored; its file was left unchanged. {error}")),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save_if_changed(&mut self, ui: &UiState) -> Result<(), String> {
|
||||
let Some(path) = &self.path else { return Ok(()) };
|
||||
/// Writes `ui` if it differs from the last state written. `Ok(Some(wait))` means a change
|
||||
/// is held back by [`MIN_WRITE_INTERVAL`]; call again after `wait` to write it.
|
||||
pub fn save_if_changed(&mut self, ui: &UiState, now: Instant) -> Result<Option<Duration>, String> {
|
||||
let Some(path) = &self.path else { return Ok(None) };
|
||||
if self.read_failed {
|
||||
// Preserve a corrupt, oversized or newer-version file until the user repairs it.
|
||||
return Ok(());
|
||||
return Ok(None);
|
||||
}
|
||||
let value = snapshot(ui)?;
|
||||
if self.last_attempt.as_ref() == Some(&value) {
|
||||
return self.pending_write_error.clone().map_or(Ok(()), Err);
|
||||
return self.pending_write_error.clone().map_or(Ok(None), Err);
|
||||
}
|
||||
if let Some(wait) = self.last_write.and_then(|last| MIN_WRITE_INTERVAL.checked_sub(now.saturating_duration_since(last)))
|
||||
&& !wait.is_zero()
|
||||
{
|
||||
return Ok(Some(wait));
|
||||
}
|
||||
// Keep a failed state's warning without retrying disk I/O every rendered frame.
|
||||
self.last_attempt = Some(value.clone());
|
||||
self.last_write = Some(now);
|
||||
let result = write(path, &value);
|
||||
self.pending_write_error = result.as_ref().err().cloned();
|
||||
result
|
||||
result.map(|()| None)
|
||||
}
|
||||
|
||||
pub fn save_on_exit(&mut self, ui: &UiState) -> Result<(), String> {
|
||||
if self.pending_write_error.is_some() {
|
||||
self.last_attempt = None;
|
||||
}
|
||||
self.save_if_changed(ui)
|
||||
self.last_write = None;
|
||||
self.save_if_changed(ui, Instant::now()).map(|_| ())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,7 +237,7 @@ mod tests {
|
||||
start_tab: true,
|
||||
..Default::default()
|
||||
};
|
||||
first.save_if_changed(&ui).unwrap();
|
||||
first.save_if_changed(&ui, Instant::now()).unwrap();
|
||||
let bytes = fs::read(&path).unwrap();
|
||||
let value: Value = serde_json::from_slice(&bytes).unwrap();
|
||||
assert!(value["workspace"].get("dialog").is_none());
|
||||
@@ -229,34 +250,68 @@ mod tests {
|
||||
let mut transient_only = ui.clone();
|
||||
transient_only.dialog = None;
|
||||
transient_only.start_tab = false;
|
||||
first.save_if_changed(&transient_only).unwrap();
|
||||
first.save_if_changed(&transient_only, Instant::now()).unwrap();
|
||||
assert_eq!(fs::read(&path).unwrap(), bytes);
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn docked_floating_and_hidden_panels_survive_native_workspace_file_reload() {
|
||||
use cadcraft_ui_egui::docking::Panel;
|
||||
fn dock_columns_floating_and_hidden_panels_survive_native_workspace_file_reload() {
|
||||
use cadcraft_ui_egui::docking::{LAYOUT_VERSION, Panel, permissions, reveal_panel};
|
||||
let directory = temp_dir();
|
||||
let path = directory.join("workspace.json");
|
||||
let (mut store, _, _) = Workspace::at(Some(path.clone()));
|
||||
let mut ui = UiState::default();
|
||||
let mut app = cadcraft_ui_egui::CadApp::new(cadcraft_engine::Session::default(), Default::default());
|
||||
app.run("ui.dock", json!({"operation":"float", "panel":"layers", "rect":[120,90,340,410]})).unwrap();
|
||||
app.run("ui.dock", json!({"operation":"move", "panel":"layers", "target":"properties", "zone":"left"})).unwrap();
|
||||
app.run("ui.dock", json!({"operation":"float", "panel":"properties", "rect":[120,90,340,410]})).unwrap();
|
||||
ui.docking = app.ui.docking;
|
||||
ui.docking.set_visible(Panel::ToolSets, false).unwrap();
|
||||
ui.docking.set_visible(Panel::ToolSets, false, None, permissions).unwrap();
|
||||
ui.show_toolsets = false;
|
||||
store.save_if_changed(&ui).unwrap();
|
||||
store.save_if_changed(&ui, Instant::now()).unwrap();
|
||||
let saved: Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
|
||||
assert_eq!(saved["workspace"]["dockingVersion"], json!(LAYOUT_VERSION));
|
||||
let (_, reloaded, error) = Workspace::at(Some(path));
|
||||
assert!(error.is_none());
|
||||
let mut reloaded = reloaded.unwrap();
|
||||
assert_eq!(serde_json::to_value(&reloaded.docking).unwrap(), serde_json::to_value(&ui.docking).unwrap());
|
||||
reloaded.docking.set_visible(Panel::ToolSets, true).unwrap();
|
||||
assert!(reloaded.docking.layout.contains(&Panel::ToolSets));
|
||||
assert!(!reloaded.show_toolsets);
|
||||
reveal_panel(&mut reloaded.docking, Panel::ToolSets).unwrap();
|
||||
let tools = reloaded.docking.layout.location(&Panel::ToolSets).unwrap();
|
||||
assert_eq!(serde_json::to_value(&tools.group).unwrap()["column"]["side"], json!("Start"), "{tools:?}");
|
||||
assert_eq!(reloaded.docking.layout.floating[0].rect, [120.0, 90.0, 340.0, 410.0]);
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_frame_layout_file_loads_as_the_dock_default_with_its_other_settings() {
|
||||
use cadcraft_ui_egui::docking::default_workspace;
|
||||
// Written as CADCraft 0.7 writes it: craft-ui's v1 layout with no docking version, a
|
||||
// floating window as one `{panels, active, rect}` group, Tool Sets closed.
|
||||
let file = r#"{"version":1,"workspace":{
|
||||
"docking":{
|
||||
"layout":{
|
||||
"root":{"Split":{"axis":"Horizontal","size":{"FixedSecond":300.0},
|
||||
"first":{"Tabs":{"panels":["canvas"],"active":0}},
|
||||
"second":{"Stack":{"entries":[{"panel":"properties","open":false,"height":null}]}}}},
|
||||
"floating":[{"panels":["layers","externalReferences"],"active":1,"rect":[120.0,90.0,340.0,410.0]}]},
|
||||
"hidden":[["toolSets",{"floating":null,"anchor":"canvas","placement":{"Split":"Left"},
|
||||
"size":{"FixedFirst":220.0},"stack":null,"sibling":["canvas","properties"]}]]},
|
||||
"showToolsets":false,"showPalettes":true,"showToolbar":false,"toolsetTab":"Modeling","historyLines":4}}"#;
|
||||
let directory = temp_dir();
|
||||
let path = directory.join("workspace.json");
|
||||
fs::write(&path, file).unwrap();
|
||||
let (_, restored, error) = Workspace::at(Some(path));
|
||||
assert!(error.is_none(), "{error:?}");
|
||||
let ui = restored.unwrap();
|
||||
assert_eq!(ui.toolset_tab, "Modeling");
|
||||
assert_eq!(ui.history_lines, 4);
|
||||
assert!(!ui.show_toolsets && ui.show_palettes && !ui.show_toolbar);
|
||||
assert_eq!(ui.docking.layout, default_workspace().layout);
|
||||
assert!(ui.docking.hidden.is_empty(), "{:?}", ui.docking.hidden);
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_oversized_and_newer_state_are_reported_and_preserved() {
|
||||
let directory = temp_dir();
|
||||
@@ -272,7 +327,7 @@ mod tests {
|
||||
let (mut store, ui, error) = Workspace::at(Some(path.clone()));
|
||||
assert!(ui.is_none());
|
||||
assert!(error.as_deref().is_some_and(|error| error.contains("left unchanged")));
|
||||
store.save_if_changed(&UiState { show_palettes: false, ..Default::default() }).unwrap();
|
||||
store.save_if_changed(&UiState { show_palettes: false, ..Default::default() }, Instant::now()).unwrap();
|
||||
assert_eq!(fs::read(&path).unwrap(), bytes);
|
||||
}
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
@@ -287,7 +342,8 @@ mod tests {
|
||||
json!({"layout":false,"hidden":[]}),
|
||||
json!({"layout":{"root":null,"floating":[]},"hidden":[["futurePanel",{}]]}),
|
||||
] {
|
||||
let workspace = json!({"showToolbar":false,"showPalettes":false,"toolsetTab":"Modeling", "collapsedGroups":["Draw"], "historyLines":7,"docking":docking});
|
||||
let workspace = json!({"showToolbar":false,"showPalettes":false,"toolsetTab":"Modeling", "collapsedGroups":["Draw"], "historyLines":7,"docking":docking,
|
||||
"dockingVersion":cadcraft_ui_egui::docking::LAYOUT_VERSION});
|
||||
assert!(serde_json::from_value::<UiState>(workspace.clone()).is_err(), "runtime state must stay strict");
|
||||
fs::write(&path, serde_json::to_vec(&json!({"version":1,"workspace":workspace})).unwrap()).unwrap();
|
||||
let (_, restored, error) = Workspace::at(Some(path.clone()));
|
||||
@@ -303,16 +359,41 @@ mod tests {
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rapid_changes_are_written_once_per_interval_with_the_newest_state() {
|
||||
let directory = temp_dir();
|
||||
let path = directory.join("workspace.json");
|
||||
let (mut store, _, _) = Workspace::at(Some(path.clone()));
|
||||
let start = Instant::now();
|
||||
let mut ui = UiState { history_lines: 1, ..Default::default() };
|
||||
assert_eq!(store.save_if_changed(&ui, start).unwrap(), None);
|
||||
// A drag: a new state every frame, none of them written until the interval ends.
|
||||
for (frame, lines) in (2..=5).enumerate() {
|
||||
ui.history_lines = lines;
|
||||
let now = start + Duration::from_millis(16 * (frame as u64 + 1));
|
||||
let wait = store.save_if_changed(&ui, now).unwrap();
|
||||
assert_eq!(wait, Some(MIN_WRITE_INTERVAL - (now - start)));
|
||||
}
|
||||
assert_eq!(read(&path).unwrap().unwrap().history_lines, 1);
|
||||
assert_eq!(store.save_if_changed(&ui, start + MIN_WRITE_INTERVAL).unwrap(), None);
|
||||
assert_eq!(read(&path).unwrap().unwrap().history_lines, 5);
|
||||
ui.history_lines = 6;
|
||||
assert!(store.save_if_changed(&ui, start + MIN_WRITE_INTERVAL).unwrap().is_some());
|
||||
store.save_on_exit(&ui).unwrap();
|
||||
assert_eq!(read(&path).unwrap().unwrap().history_lines, 6);
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_update_cannot_replace_last_good_workspace() {
|
||||
let directory = temp_dir();
|
||||
let path = directory.join("workspace.json");
|
||||
let (mut store, _, _) = Workspace::at(Some(path.clone()));
|
||||
let mut ui = UiState { show_palettes: false, ..Default::default() };
|
||||
store.save_if_changed(&ui).unwrap();
|
||||
store.save_if_changed(&ui, Instant::now()).unwrap();
|
||||
let good = fs::read(&path).unwrap();
|
||||
ui.collapsed_groups = vec!["x".repeat(257)];
|
||||
assert!(store.save_if_changed(&ui).is_err());
|
||||
assert!(store.save_if_changed(&ui, Instant::now()).is_err());
|
||||
assert_eq!(fs::read(&path).unwrap(), good);
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
@@ -325,9 +406,9 @@ mod tests {
|
||||
// Simulate a destination becoming unavailable after startup.
|
||||
fs::create_dir(&path).unwrap();
|
||||
let ui = UiState { show_palettes: false, ..Default::default() };
|
||||
let first_error = store.save_if_changed(&ui).unwrap_err();
|
||||
let first_error = store.save_if_changed(&ui, Instant::now()).unwrap_err();
|
||||
fs::remove_dir(&path).unwrap();
|
||||
assert_eq!(store.save_if_changed(&ui).unwrap_err(), first_error);
|
||||
assert_eq!(store.save_if_changed(&ui, Instant::now()).unwrap_err(), first_error);
|
||||
assert!(!path.exists(), "ordinary frames must not retry disk writes");
|
||||
store.save_on_exit(&ui).unwrap();
|
||||
assert!(!read(&path).unwrap().unwrap().show_palettes);
|
||||
|
||||
@@ -2,3 +2,4 @@ allow-unwrap-in-tests = true
|
||||
allow-expect-in-tests = true
|
||||
allow-panic-in-tests = true
|
||||
allow-indexing-slicing-in-tests = true
|
||||
cognitive-complexity-threshold = 30
|
||||
|
||||
@@ -73,3 +73,21 @@ sync_github_name = false
|
||||
|
||||
[people.gurparvez]
|
||||
real_name = "Gurparvez"
|
||||
|
||||
[people.nicholasgower]
|
||||
sync_github_name = true
|
||||
|
||||
[people.ffishman]
|
||||
real_name = "Rodrigo Gressler"
|
||||
display_name = "Rodrigo"
|
||||
sync_github_name = false
|
||||
|
||||
[people.aussieboer61]
|
||||
real_name = "Richard Sauer"
|
||||
display_name = "Richard Sauer"
|
||||
sync_github_name = false
|
||||
|
||||
[people.dotCooCoo]
|
||||
real_name = "Robert Lee"
|
||||
sync_github_name = true
|
||||
|
||||
|
||||
+43
-8
@@ -21,7 +21,8 @@ impl Rgb {
|
||||
format!("#{:02x}{:02x}{:02x}", self.0, self.1, self.2)
|
||||
}
|
||||
pub fn parse_hex(s: &str) -> Option<Rgb> {
|
||||
let s = s.trim().trim_start_matches('#');
|
||||
let s = s.trim();
|
||||
let s = s.strip_prefix('#').unwrap_or(s);
|
||||
if s.len() != 6 || !s.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return None;
|
||||
}
|
||||
@@ -110,9 +111,9 @@ impl Color {
|
||||
_ => {}
|
||||
}
|
||||
if let Some(rest) = t.strip_prefix("color ") {
|
||||
return rest.trim().parse::<u8>().ok().filter(|i| *i >= 1).map(Color::Index);
|
||||
return parse_digits::<u8>(rest.trim()).filter(|i| *i >= 1).map(Color::Index);
|
||||
}
|
||||
if let Ok(i) = t.parse::<u16>() {
|
||||
if let Some(i) = parse_digits::<u16>(&t) {
|
||||
return match i {
|
||||
0 => Some(Color::ByBlock),
|
||||
256 => Some(Color::ByLayer),
|
||||
@@ -123,14 +124,22 @@ impl Color {
|
||||
if t.starts_with('#') {
|
||||
return Rgb::parse_hex(&t).map(Color::True);
|
||||
}
|
||||
let parts: Vec<_> = t.split(',').map(|p| p.trim().parse::<u8>()).collect();
|
||||
if let [Ok(r), Ok(g), Ok(b)] = parts.as_slice() {
|
||||
let parts: Vec<_> = t.split(',').map(|p| parse_digits::<u8>(p.trim())).collect();
|
||||
if let [Some(r), Some(g), Some(b)] = parts.as_slice() {
|
||||
return Some(Color::True(Rgb(*r, *g, *b)));
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse an unsigned integer made of ASCII digits only (no sign, unlike `str::parse`).
|
||||
fn parse_digits<T: std::str::FromStr>(s: &str) -> Option<T> {
|
||||
if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
s.parse().ok()
|
||||
}
|
||||
|
||||
/// The RGB of ACI `i` (0 and out-of-range map to white/7).
|
||||
pub fn aci_rgb(i: u8) -> Rgb {
|
||||
PALETTE.get(usize::from(i)).copied().unwrap_or(Rgb(255, 255, 255))
|
||||
@@ -156,7 +165,7 @@ fn build_palette() -> [Rgb; 256] {
|
||||
for (slot, c) in p.iter_mut().zip(base) {
|
||||
*slot = c;
|
||||
}
|
||||
let values = [255.0, 165.0, 127.0, 76.0, 38.0];
|
||||
let values = [255.0, 204.0, 153.0, 127.0, 76.0];
|
||||
for i in 10..250usize {
|
||||
let hue = (i - 10) / 10; // 0..24, 15° each
|
||||
let sub = i % 10;
|
||||
@@ -233,13 +242,39 @@ mod tests {
|
||||
assert_eq!(Rgb::parse_hex("#012345"), Some(Rgb(1, 35, 69)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_plus_sign() {
|
||||
assert_eq!(Color::parse("+5"), None);
|
||||
assert_eq!(Color::parse(" +256"), None);
|
||||
assert_eq!(Color::parse("color +5"), None);
|
||||
assert_eq!(Color::parse("+1,+2,+3"), None);
|
||||
assert_eq!(Color::parse("1,+2,3"), None);
|
||||
assert_eq!(Color::parse("5"), Some(Color::Index(5)));
|
||||
assert_eq!(Color::parse("256"), Some(Color::ByLayer));
|
||||
assert_eq!(Color::parse("color 5"), Some(Color::Index(5)));
|
||||
assert_eq!(Color::parse("1, 2, 3"), Some(Color::True(Rgb(1, 2, 3))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_hex_rejects_repeated_hash() {
|
||||
assert_eq!(Rgb::parse_hex("##ff0000"), None);
|
||||
assert_eq!(Rgb::parse_hex("###ff0000"), None);
|
||||
assert_eq!(Color::parse("##ff0000"), None);
|
||||
assert_eq!(Rgb::parse_hex("#ff0000"), Some(Rgb(255, 0, 0)));
|
||||
assert_eq!(Rgb::parse_hex("ff0000"), Some(Rgb(255, 0, 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn palette_known_entries() {
|
||||
assert_eq!(aci_rgb(1), Rgb(255, 0, 0));
|
||||
assert_eq!(aci_rgb(10), Rgb(255, 0, 0));
|
||||
assert_eq!(aci_rgb(11), Rgb(255, 127, 127));
|
||||
assert_eq!(aci_rgb(12), Rgb(165, 0, 0));
|
||||
assert_eq!(aci_rgb(13), Rgb(165, 82, 82));
|
||||
assert_eq!(aci_rgb(12), Rgb(204, 0, 0));
|
||||
assert_eq!(aci_rgb(13), Rgb(204, 102, 102));
|
||||
assert_eq!(aci_rgb(14), Rgb(153, 0, 0));
|
||||
assert_eq!(aci_rgb(15), Rgb(153, 76, 76));
|
||||
assert_eq!(aci_rgb(16), Rgb(127, 0, 0));
|
||||
assert_eq!(aci_rgb(18), Rgb(76, 0, 0));
|
||||
assert_eq!(aci_rgb(20), Rgb(255, 63, 0));
|
||||
assert_eq!(aci_rgb(30), Rgb(255, 127, 0));
|
||||
assert_eq!(aci_rgb(50), Rgb(255, 255, 0));
|
||||
|
||||
@@ -235,7 +235,7 @@ mod budget_tests {
|
||||
aux_init: Vec::new(),
|
||||
aux: 0,
|
||||
};
|
||||
let comp = Comp { model, built: vec![endpoint], weights: vec![1.0; 2], tol: 1e-9 };
|
||||
let comp = Comp { model, built: vec![endpoint], weights: vec![1.0; 2], held: Vec::new(), tol: 1e-9 };
|
||||
assert!(analyse(&comp).dof.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ use std::collections::BTreeMap;
|
||||
|
||||
/// Longest expression accepted (characters).
|
||||
pub const MAX_LEN: usize = 1024;
|
||||
/// Deepest nesting (parentheses, unary minus) and parameter reference chain.
|
||||
/// Deepest syntax tree and combined expression/parameter evaluation path.
|
||||
pub const MAX_DEPTH: usize = 64;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, thiserror::Error)]
|
||||
@@ -49,7 +49,7 @@ struct Parser<'a> {
|
||||
|
||||
impl Parser<'_> {
|
||||
fn ws(&mut self) {
|
||||
while self.s.get(self.i).is_some_and(|c| c.is_ascii_whitespace()) {
|
||||
while self.s.get(self.i).is_some_and(u8::is_ascii_whitespace) {
|
||||
self.i += 1;
|
||||
}
|
||||
}
|
||||
@@ -165,6 +165,20 @@ pub fn parse(text: &str) -> Result<Expr, ExprError> {
|
||||
if p.peek().is_some() {
|
||||
return Err(ExprError::Syntax(format!("unexpected `{}`", text.get(p.i..).unwrap_or("").trim())));
|
||||
}
|
||||
let mut pending = vec![(&e, 1)];
|
||||
while let Some((node, depth)) = pending.pop() {
|
||||
if depth > MAX_DEPTH {
|
||||
return Err(ExprError::TooDeep);
|
||||
}
|
||||
match node {
|
||||
Expr::Neg(a) => pending.push((a, depth + 1)),
|
||||
Expr::Bin(_, a, b) => {
|
||||
pending.push((a, depth + 1));
|
||||
pending.push((b, depth + 1));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(e)
|
||||
}
|
||||
|
||||
@@ -224,12 +238,19 @@ pub fn rename(text: &str, old: &str, new: &str) -> String {
|
||||
|
||||
/// Evaluate with a variable lookup.
|
||||
pub fn eval_with(e: &Expr, lookup: &mut dyn FnMut(&str) -> Result<f64, ExprError>) -> Result<f64, ExprError> {
|
||||
eval_at_depth(e, &mut |n, _| lookup(n), 0)
|
||||
}
|
||||
|
||||
fn eval_at_depth(e: &Expr, lookup: &mut dyn FnMut(&str, usize) -> Result<f64, ExprError>, depth: usize) -> Result<f64, ExprError> {
|
||||
if depth >= MAX_DEPTH {
|
||||
return Err(ExprError::TooDeep);
|
||||
}
|
||||
let v = match e {
|
||||
Expr::Num(v) => *v,
|
||||
Expr::Var(n) => lookup(n)?,
|
||||
Expr::Neg(a) => -eval_with(a, lookup)?,
|
||||
Expr::Var(n) => lookup(n, depth + 1)?,
|
||||
Expr::Neg(a) => -eval_at_depth(a, lookup, depth + 1)?,
|
||||
Expr::Bin(op, a, b) => {
|
||||
let (x, y) = (eval_with(a, lookup)?, eval_with(b, lookup)?);
|
||||
let (x, y) = (eval_at_depth(a, lookup, depth + 1)?, eval_at_depth(b, lookup, depth + 1)?);
|
||||
match op {
|
||||
'+' => x + y,
|
||||
'-' => x - y,
|
||||
@@ -273,9 +294,12 @@ impl Env {
|
||||
/// Value of a named parameter.
|
||||
pub fn value(&mut self, name: &str) -> Result<f64, ExprError> {
|
||||
let mut stack = Vec::new();
|
||||
self.value_inner(name, &mut stack)
|
||||
self.value_inner(name, &mut stack, 0)
|
||||
}
|
||||
fn value_inner(&mut self, name: &str, stack: &mut Vec<String>) -> Result<f64, ExprError> {
|
||||
fn value_inner(&mut self, name: &str, stack: &mut Vec<String>, depth: usize) -> Result<f64, ExprError> {
|
||||
if depth >= MAX_DEPTH {
|
||||
return Err(ExprError::TooDeep);
|
||||
}
|
||||
if let Some(v) = self.cache.get(name) {
|
||||
return Ok(*v);
|
||||
}
|
||||
@@ -288,7 +312,7 @@ impl Env {
|
||||
let text = self.exprs.get(name).cloned().ok_or_else(|| ExprError::Unknown(name.to_string()))?;
|
||||
let e = parse(&text)?;
|
||||
stack.push(name.to_string());
|
||||
let r = eval_with(&e, &mut |n| self.value_inner(n, stack));
|
||||
let r = eval_at_depth(&e, &mut |n, depth| self.value_inner(n, stack, depth), depth);
|
||||
stack.pop();
|
||||
let v = r?;
|
||||
self.cache.insert(name.to_string(), v);
|
||||
@@ -298,7 +322,7 @@ impl Env {
|
||||
pub fn eval(&mut self, text: &str) -> Result<f64, ExprError> {
|
||||
let e = parse(text)?;
|
||||
let mut stack = Vec::new();
|
||||
eval_with(&e, &mut |n| self.value_inner(n, &mut stack))
|
||||
eval_at_depth(&e, &mut |n, depth| self.value_inner(n, &mut stack, depth), 0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -351,6 +375,31 @@ mod tests {
|
||||
assert!(chain.value("p0").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expression_and_parameter_depth_share_a_limit_on_a_small_stack() {
|
||||
std::thread::Builder::new()
|
||||
.stack_size(1024 * 1024)
|
||||
.spawn(|| {
|
||||
for op in ["+", "-", "*", "/"] {
|
||||
let within = vec!["1"; MAX_DEPTH].join(op);
|
||||
assert!(eval_const(&within).is_ok());
|
||||
assert_eq!(parse(&format!("{within}{op}1")), Err(ExprError::TooDeep));
|
||||
}
|
||||
let mut env = Env::new();
|
||||
for i in 0..MAX_DEPTH {
|
||||
env.insert(&format!("p{i}"), &format!("p{}+{}", i + 1, vec!["0"; 30].join("+")));
|
||||
}
|
||||
env.insert(&format!("p{MAX_DEPTH}"), "1");
|
||||
assert_eq!(env.value("p0"), Err(ExprError::TooDeep));
|
||||
assert_eq!(env.eval("p0+1"), Err(ExprError::TooDeep));
|
||||
env.insert("safe", "2*3");
|
||||
assert_eq!(env.eval("safe+1"), Ok(7.0));
|
||||
})
|
||||
.unwrap()
|
||||
.join()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_replaces_whole_names_only() {
|
||||
assert_eq!(rename("d1+d10*d1 / (d1_x+2e1)", "d1", "w"), "w+d10*w / (d1_x+2e1)");
|
||||
|
||||
+195
-44
@@ -81,6 +81,12 @@ pub struct SolveOptions {
|
||||
pub keep: Vec<Handle>,
|
||||
/// Entities that should take up the change.
|
||||
pub prefer_move: Vec<Handle>,
|
||||
/// Points held where they are for this solve, as a Fix would hold them (the grip an edit
|
||||
/// just put down): see [`edited_points`].
|
||||
pub hold: Vec<GeomRef>,
|
||||
/// Where a Fix on a derived point (by constraint id) holds it, instead of where the point is
|
||||
/// now: see [`moved_anchors`].
|
||||
pub anchors: Vec<(u32, Vec2)>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
|
||||
@@ -205,12 +211,19 @@ pub(crate) struct Comp {
|
||||
pub(crate) model: Model,
|
||||
pub(crate) built: Vec<Built>,
|
||||
weights: Vec<f64>,
|
||||
/// Parameters of the points held by [`SolveOptions::hold`].
|
||||
held: Vec<usize>,
|
||||
tol: f64,
|
||||
}
|
||||
|
||||
impl Comp {
|
||||
fn locks(&self, skip: Option<usize>) -> Vec<bool> {
|
||||
let mut locked = vec![false; self.model.x0.len()];
|
||||
for &j in &self.held {
|
||||
if let Some(l) = locked.get_mut(j) {
|
||||
*l = true;
|
||||
}
|
||||
}
|
||||
for (i, b) in self.built.iter().enumerate() {
|
||||
if Some(i) == skip || b.form != Form::Lock {
|
||||
continue;
|
||||
@@ -245,7 +258,30 @@ impl Comp {
|
||||
return Err(SolveError::TooLarge);
|
||||
}
|
||||
let f = |x: &[f64], out: &mut Vec<f64>| self.eval(x, out, skip);
|
||||
Ok(lm::solve(&f, &self.model.x0, &free, &self.weights, self.tol, MAX_ITER))
|
||||
let mut o = lm::solve(&f, &self.model.x0, &free, &self.weights, self.tol, MAX_ITER);
|
||||
// A solution the ellipses would be stored differently from (axes swapped, minor axis
|
||||
// mirrored) holds only if the constraints still hold on the stored form: a constraint on
|
||||
// an axis end by index does not.
|
||||
if o.converged && !self.model.storable(&o.x) {
|
||||
let mut out = Vec::new();
|
||||
match self.model.stored_form(&o.x) {
|
||||
Some(mut xc) => {
|
||||
// Contact parameters are positions along the curves: they follow the stored form.
|
||||
for b in self.built.iter().filter(|b| b.form == Form::Contact) {
|
||||
for (k, r) in b.refs.iter().enumerate() {
|
||||
if let (Some(&t), Some(slot)) = (o.x.get(b.aux + k), xc.get_mut(b.aux + k)) {
|
||||
*slot = self.model.stored_t(&o.x, r.handle, t);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.eval(&xc, &mut out, skip);
|
||||
o.converged = lm::max_abs(&out) <= self.tol;
|
||||
o.x = xc;
|
||||
}
|
||||
None => o.converged = false,
|
||||
}
|
||||
}
|
||||
Ok(o)
|
||||
}
|
||||
/// Constraint ids in conflict (each one whose removal makes the rest solvable).
|
||||
fn conflicts(&self, x_failed: &[f64]) -> Vec<u32> {
|
||||
@@ -322,6 +358,56 @@ fn components<'a>(cons: &[&'a Constraint]) -> Vec<Vec<&'a Constraint>> {
|
||||
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<f64> {
|
||||
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<Comp>, Vec<(u32, String)>), SolveError> {
|
||||
let mut members_left = model::MAX_RIGID_WORK;
|
||||
@@ -346,55 +432,24 @@ pub(crate) fn build_budgeted(
|
||||
for c in group {
|
||||
let target = if c.kind.is_dimensional() { constraint_value(&mut e, c)? } else { 0.0 };
|
||||
match interpret(c, &mut model, target) {
|
||||
Ok(b) => built.push(b),
|
||||
Ok(mut b) => {
|
||||
if let Form::Anchor(_) = b.form
|
||||
&& let Some(&(_, a)) = opts.anchors.iter().find(|(id, _)| *id == c.id)
|
||||
{
|
||||
b.form = Form::Anchor(a);
|
||||
}
|
||||
built.push(b);
|
||||
}
|
||||
Err(msg) => skipped.push((c.id, msg)),
|
||||
}
|
||||
}
|
||||
if built.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut weights = vec![1.0; model.x0.len()];
|
||||
for s in &model.slots {
|
||||
let w = if opts.keep.contains(&s.handle) {
|
||||
1e4
|
||||
} else if opts.prefer_move.contains(&s.handle) {
|
||||
1e-2
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
for j in s.off..s.off + s.len {
|
||||
if let Some(slot) = weights.get_mut(j) {
|
||||
*slot = w;
|
||||
}
|
||||
}
|
||||
// Angles and ratios: weight by size² so the movement of points is what is minimised.
|
||||
let sq = |j: usize| model.x0.get(j).map_or(1.0, |r| (r * r).max(1e-6));
|
||||
let scaled: Vec<(usize, f64)> = match s.shape {
|
||||
model::Shape::Arc => vec![(s.off + 3, sq(s.off + 2)), (s.off + 4, sq(s.off + 2))],
|
||||
model::Shape::Ellipse { arc } => {
|
||||
let a2 = (sq(s.off + 2) + sq(s.off + 3)).max(1e-6);
|
||||
let mut v = vec![(s.off + 4, a2)];
|
||||
if arc {
|
||||
v.extend([(s.off + 5, a2), (s.off + 6, a2)]);
|
||||
}
|
||||
v
|
||||
}
|
||||
model::Shape::Insert { .. } => vec![(s.off + 2, model.rigid_extent2(s.handle).max(1.0))],
|
||||
_ => Vec::new(),
|
||||
};
|
||||
for (j, f) in scaled {
|
||||
if let Some(slot) = weights.get_mut(j) {
|
||||
*slot *= f;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Contact parameters move freely.
|
||||
let geo = model.slots.iter().map(|s| s.off + s.len).max().unwrap_or(0);
|
||||
for w in weights.iter_mut().skip(geo) {
|
||||
*w = 1e-3;
|
||||
}
|
||||
let weights = solver_weights(&model, opts);
|
||||
let held = opts.hold.iter().filter_map(|r| model.lock_indices(r)).flatten().collect();
|
||||
let tol = scale_tol(&model.x0);
|
||||
comps.push(Comp { model, built, weights, tol });
|
||||
comps.push(Comp { model, built, weights, held, tol });
|
||||
}
|
||||
Ok((comps, skipped))
|
||||
}
|
||||
@@ -445,6 +500,9 @@ pub fn residuals(d: &Drawing) -> Vec<(u32, Option<f64>)> {
|
||||
/// A Fix keeps no position of its own: the solver locks the parameters at their current values.
|
||||
/// Without this, an edit that moves a fixed point (a grip drag) would carry the lock along with
|
||||
/// it. Only Fixes that already existed in `before`, on objects of the same shape, are restored.
|
||||
/// An object held by a Fix on a derived point (a line's midpoint, an arc's end) has nothing to
|
||||
/// lock: when the edit only moved it (MOVE), it is put back whole, so it is not reshaped to bring
|
||||
/// the point back.
|
||||
pub fn restore_fixed(d: &mut Drawing, before: &Drawing) -> bool {
|
||||
let refs: Vec<GeomRef> = d
|
||||
.constraints
|
||||
@@ -461,10 +519,21 @@ pub fn restore_fixed(d: &mut Drawing, before: &Drawing) -> bool {
|
||||
let mut changed = false;
|
||||
for r in &refs {
|
||||
let same_shape = matches!((now.slot(r.handle), old.slot(r.handle)), (Some(a), Some(b)) if a.shape == b.shape);
|
||||
let (Some(i), Some(j)) = (now.lock_indices(r), old.lock_indices(r)) else { continue };
|
||||
if !same_shape {
|
||||
continue;
|
||||
}
|
||||
let (Some(i), Some(j)) = (now.lock_indices(r), old.lock_indices(r)) else {
|
||||
if let (Some(a), Some(b)) = (now.slot(r.handle), old.slot(r.handle))
|
||||
&& translated(&now, &old, r.handle)
|
||||
&& let (Some(dst), Some(src)) = (x.get_mut(a.off..a.off + a.len), old.x0.get(b.off..b.off + b.len))
|
||||
{
|
||||
for (v, &w) in dst.iter_mut().zip(src) {
|
||||
*v = w;
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
continue;
|
||||
};
|
||||
for (i, j) in i.into_iter().zip(j) {
|
||||
if let (Some(slot), Some(&v)) = (x.get_mut(i), old.x0.get(j))
|
||||
&& *slot != v
|
||||
@@ -480,6 +549,88 @@ pub fn restore_fixed(d: &mut Drawing, before: &Drawing) -> bool {
|
||||
changed
|
||||
}
|
||||
|
||||
/// The parameters of `h`'s points (see [`Model::point_refs`]) in `m`, as (x, y) pairs.
|
||||
fn point_params(m: &Model, h: Handle) -> Vec<(usize, usize)> {
|
||||
m.point_refs(h)
|
||||
.iter()
|
||||
.filter_map(|r| m.lock_indices(r))
|
||||
.flat_map(|ix| ix.as_chunks::<2>().0.iter().map(|&[x, y]| (x, y)).collect::<Vec<_>>())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Is `h` in `now` its shape in `old` moved by one vector, and nothing else?
|
||||
fn translated(now: &Model, old: &Model, h: Handle) -> bool {
|
||||
let (Some(a), Some(b)) = (now.slot(h), old.slot(h)) else { return false };
|
||||
let (Some(xa), Some(xb)) = (now.x0.get(a.off..a.off + a.len), old.x0.get(b.off..b.off + b.len)) else { return false };
|
||||
let tol = scale_tol(xb) * 10.0;
|
||||
let pts: Vec<(usize, usize)> =
|
||||
point_params(old, h).into_iter().filter_map(|(i, j)| Some((i.checked_sub(b.off)?, j.checked_sub(b.off)?))).collect();
|
||||
let delta = |(i, j): (usize, usize)| Some(Vec2::new(xa.get(i)? - xb.get(i)?, xa.get(j)? - xb.get(j)?));
|
||||
let Some(v) = pts.first().and_then(|&p| delta(p)) else { return false };
|
||||
if v.len() <= tol || !pts.iter().all(|&p| delta(p).is_some_and(|d| d.near(v, tol))) {
|
||||
return false;
|
||||
}
|
||||
(0..xb.len())
|
||||
.filter(|k| !pts.iter().any(|&(i, j)| *k == i || *k == j))
|
||||
.all(|k| matches!((xa.get(k), xb.get(k)), (Some(p), Some(q)) if (p - q).abs() <= tol))
|
||||
}
|
||||
|
||||
/// The points of `h` (its ends, centre or vertices) the edit from `before` to `d` moved, when it
|
||||
/// moved some of them but not all: the grip that was dragged, to pass as [`SolveOptions::hold`] so
|
||||
/// it stays where it was put and the rest of the object takes up the constraints. Empty when the
|
||||
/// object kept its points or moved them all.
|
||||
pub fn edited_points(d: &Drawing, before: &Drawing, h: Handle) -> Vec<GeomRef> {
|
||||
let (now, old) = (Model::build(d, &[h]), Model::build(before, &[h]));
|
||||
if !matches!((now.slot(h), old.slot(h)), (Some(a), Some(b)) if a.shape == b.shape) {
|
||||
return Vec::new();
|
||||
}
|
||||
let tol = scale_tol(&old.x0) * 10.0;
|
||||
let refs = now.point_refs(h);
|
||||
let moved: Vec<GeomRef> = refs
|
||||
.iter()
|
||||
.filter(|r| matches!((now.lock_indices(r), old.lock_indices(r)), (Some(i), Some(j)) if i.iter().zip(&j).any(|(&i, &j)| matches!((now.x0.get(i), old.x0.get(j)), (Some(p), Some(q)) if (p - q).abs() > tol))))
|
||||
.copied()
|
||||
.collect();
|
||||
if moved.len() == refs.len() { Vec::new() } else { moved }
|
||||
}
|
||||
|
||||
/// Where `before` had each derived point held by a Fix (a line's midpoint, an arc's end), for the
|
||||
/// Fixes whose point has moved since, to pass as [`SolveOptions::anchors`].
|
||||
///
|
||||
/// A Fix on a derived point anchors it where it is when the system is built, and
|
||||
/// [`restore_fixed`] has no parameters to put back: without this, an edit that moves the point
|
||||
/// would leave the Fix satisfied at the new place.
|
||||
pub fn moved_anchors(d: &Drawing, before: &Drawing) -> Vec<(u32, Vec2)> {
|
||||
let fixes: Vec<(u32, GeomRef)> = d
|
||||
.constraints
|
||||
.iter()
|
||||
.filter(|c| c.kind == ConstraintKind::Fix && before.constraints.contains(c))
|
||||
.filter_map(|c| match c.refs.as_slice() {
|
||||
[r] => Some((c.id, *r)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
if fixes.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let handles: Vec<Handle> = fixes.iter().map(|(_, r)| r.handle).collect();
|
||||
let (now, old) = (Model::build(d, &handles), Model::build(before, &handles));
|
||||
let tol = scale_tol(&old.x0);
|
||||
let mut out = Vec::new();
|
||||
for (id, r) in fixes {
|
||||
let same_shape = matches!((now.slot(r.handle), old.slot(r.handle)), (Some(a), Some(b)) if a.shape == b.shape);
|
||||
if !same_shape || now.lock_indices(&r).is_some() {
|
||||
continue;
|
||||
}
|
||||
if let (Some(p), Some(a)) = (now.point(&now.x0, &r), old.point(&old.x0, &r))
|
||||
&& !p.near(a, tol)
|
||||
{
|
||||
out.push((id, a));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// True when every interpretable constraint holds at the current geometry.
|
||||
pub fn all_satisfied(d: &Drawing) -> bool {
|
||||
let handles: Vec<Handle> = d.constraints.iter().flat_map(|c| c.refs.iter().map(|r| r.handle)).collect();
|
||||
|
||||
+140
-67
@@ -32,6 +32,8 @@ pub const MAX_RIGID_POINTS: usize = 256;
|
||||
const MAX_RIGID_MEMBERS: usize = 100_000;
|
||||
/// Shared across every model built for one analysis request.
|
||||
pub(crate) const MAX_RIGID_WORK: usize = 1_000_000;
|
||||
/// How far past 1 a solved ellipse ratio may land by rounding and still be stored as 1.
|
||||
const RATIO_TOL: f64 = 1e-9;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Shape {
|
||||
@@ -189,6 +191,77 @@ impl Model {
|
||||
Self::build_budgeted(d, handles, &mut members_left)
|
||||
}
|
||||
|
||||
/// Add the parameters of one supported entity to `m`, and return its shape; `None` when it is
|
||||
/// left out (too large, degenerate or unsupported).
|
||||
fn add_params(m: &mut Model, d: &Drawing, h: Handle, kind: &EntityKind, off: usize, members_left: &mut usize) -> Option<Shape> {
|
||||
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<Vec2> = rigid_points_budgeted(d, ins, members_left).into_iter().map(|p| (p - base).rotate(-ins.rotation)).collect();
|
||||
m.x0.extend([ins.insert.x, ins.insert.y, ins.rotation]);
|
||||
let n = pts.len();
|
||||
m.rigid.insert(h, pts);
|
||||
Shape::Insert { n }
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
Some(shape)
|
||||
}
|
||||
|
||||
/// Build solver parameters for supported entities while sharing rigid-member sampling work with other models.
|
||||
pub(crate) fn build_budgeted(d: &Drawing, handles: &[Handle], members_left: &mut usize) -> Model {
|
||||
let mut m = Model::default();
|
||||
@@ -198,71 +271,7 @@ impl Model {
|
||||
}
|
||||
let Some(e) = d.entity(h) else { continue };
|
||||
let off = m.x0.len();
|
||||
let shape = match &e.kind {
|
||||
EntityKind::Line(l) => {
|
||||
m.x0.extend([l.a.x, l.a.y, l.b.x, l.b.y]);
|
||||
Shape::Line
|
||||
}
|
||||
EntityKind::Circle(c) => {
|
||||
m.x0.extend([c.center.x, c.center.y, c.radius]);
|
||||
Shape::Circle
|
||||
}
|
||||
EntityKind::Arc(a) => {
|
||||
m.x0.extend([a.center.x, a.center.y, a.radius, a.start, a.end]);
|
||||
Shape::Arc
|
||||
}
|
||||
EntityKind::LwPolyline(p) => {
|
||||
if p.vertices.len() < 2 || off + p.vertices.len() * 2 > MAX_PARAMS {
|
||||
continue;
|
||||
}
|
||||
for v in &p.vertices {
|
||||
m.x0.extend([v.p.x, v.p.y]);
|
||||
}
|
||||
Shape::Poly { n: p.vertices.len(), closed: p.closed }
|
||||
}
|
||||
EntityKind::Point(p) => {
|
||||
m.x0.extend([p.p.x, p.p.y]);
|
||||
Shape::Point
|
||||
}
|
||||
EntityKind::Ellipse(el) => {
|
||||
let ell = cadcraft_geom::Ellipse { center: el.center.xy(), major: el.major.xy(), ratio: el.ratio, start: el.start, end: el.end };
|
||||
let arc = !ell.is_full();
|
||||
m.x0.extend([el.center.x, el.center.y, el.major.x, el.major.y, el.ratio]);
|
||||
if arc {
|
||||
m.x0.extend([el.start, el.end]);
|
||||
}
|
||||
Shape::Ellipse { arc }
|
||||
}
|
||||
EntityKind::Spline(sp) => {
|
||||
let fit = fit_mode(sp);
|
||||
let pts = if fit { &sp.fit } else { &sp.control };
|
||||
let n = pts.len();
|
||||
if !(2..=MAX_SPLINE_POINTS).contains(&n) || off + n * 2 > MAX_PARAMS || (!fit && !sp.is_valid()) {
|
||||
continue;
|
||||
}
|
||||
for p in pts {
|
||||
m.x0.extend([p.x, p.y]);
|
||||
}
|
||||
// Fit points interpolate exactly, so the curve's ends are the first and last
|
||||
// fit point; a control polygon's ends are its first and last points when the
|
||||
// knots are clamped.
|
||||
let ends = !sp.closed && (fit || (clamped(sp, false) && clamped(sp, true)));
|
||||
// Missing weights default to 1 in eval; only excess weights are unused.
|
||||
let mut kept = sp.clone();
|
||||
kept.weights.truncate(kept.control.len());
|
||||
m.splines.insert(h, kept);
|
||||
Shape::Spline { n, fit, ends }
|
||||
}
|
||||
EntityKind::Insert(ins) => {
|
||||
let base = Vec2::new(ins.insert.x, ins.insert.y);
|
||||
let pts: Vec<Vec2> = rigid_points_budgeted(d, ins, members_left).into_iter().map(|p| (p - base).rotate(-ins.rotation)).collect();
|
||||
m.x0.extend([ins.insert.x, ins.insert.y, ins.rotation]);
|
||||
let n = pts.len();
|
||||
m.rigid.insert(h, pts);
|
||||
Shape::Insert { n }
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
let Some(shape) = Self::add_params(&mut m, d, h, &e.kind, off, members_left) else { continue };
|
||||
if m.x0.len() > MAX_PARAMS || m.x0.get(off..).unwrap_or(&[]).iter().any(|v| !v.is_finite()) {
|
||||
m.x0.truncate(off);
|
||||
continue;
|
||||
@@ -477,6 +486,18 @@ impl Model {
|
||||
}
|
||||
}
|
||||
|
||||
/// The points of an object a Fix locks directly (ends, centre, vertices, insertion point).
|
||||
pub fn point_refs(&self, h: Handle) -> Vec<GeomRef> {
|
||||
let Some(s) = self.slot(h) else { return Vec::new() };
|
||||
let at = |sub: Sub| GeomRef { handle: h, sub };
|
||||
match s.shape {
|
||||
Shape::Line => vec![at(Sub::Start), at(Sub::End)],
|
||||
Shape::Point => vec![at(Sub::Whole)],
|
||||
Shape::Circle | Shape::Arc | Shape::Ellipse { .. } | Shape::Insert { .. } => vec![at(Sub::Center)],
|
||||
Shape::Poly { n, .. } | Shape::Spline { n, .. } => (0..n).filter_map(|i| u32::try_from(i).ok()).map(|i| at(Sub::Vertex(i))).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parameters a Fix on this reference locks directly. `None` when the reference is a derived
|
||||
/// point (fixed through a residual instead).
|
||||
pub fn lock_indices(&self, r: &GeomRef) -> Option<Vec<usize>> {
|
||||
@@ -504,6 +525,57 @@ impl Model {
|
||||
Some(v)
|
||||
}
|
||||
|
||||
/// Can `write_back` store `x` as it is? An ellipse keeps 0 < ratio <= 1, its major axis the
|
||||
/// longer one: a solution past that is stored with its axes swapped or its minor axis
|
||||
/// mirrored (see [`Model::stored_form`]), which renumbers the axis ends.
|
||||
pub fn storable(&self, x: &[f64]) -> bool {
|
||||
self.slots.iter().all(|s| match s.shape {
|
||||
Shape::Ellipse { .. } => x.get(s.off + 4).is_some_and(|r| *r > 0.0 && *r <= 1.0 + RATIO_TOL),
|
||||
_ => true,
|
||||
})
|
||||
}
|
||||
|
||||
/// `x` with every ellipse re-expressed as [`Model::write_back`] stores it (the same curve with
|
||||
/// the major axis the longer one, a full ellipse's negative ratio made positive), so the
|
||||
/// constraints can be checked against what would be stored. `None` when an elliptical arc has
|
||||
/// a negative ratio or an ellipse a zero one.
|
||||
pub fn stored_form(&self, x: &[f64]) -> Option<Vec<f64>> {
|
||||
let mut xc = x.to_vec();
|
||||
for s in &self.slots {
|
||||
let Shape::Ellipse { arc } = s.shape else { continue };
|
||||
let o = s.off;
|
||||
let mut ratio = *x.get(o + 4)?;
|
||||
if ratio < 0.0 && !arc {
|
||||
ratio = -ratio;
|
||||
}
|
||||
if ratio <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
if ratio > 1.0 + RATIO_TOL {
|
||||
let major = p2(x, o + 2)?.perp() * ratio;
|
||||
*xc.get_mut(o + 2)? = major.x;
|
||||
*xc.get_mut(o + 3)? = major.y;
|
||||
ratio = 1.0 / ratio;
|
||||
if arc {
|
||||
*xc.get_mut(o + 5)? -= std::f64::consts::FRAC_PI_2;
|
||||
*xc.get_mut(o + 6)? -= std::f64::consts::FRAC_PI_2;
|
||||
}
|
||||
}
|
||||
*xc.get_mut(o + 4)? = ratio;
|
||||
}
|
||||
Some(xc)
|
||||
}
|
||||
|
||||
/// The parameter `stored_form` gives the point at parameter `t` on ellipse `h` (unchanged for
|
||||
/// any other object): `-t` where a negative ratio was made positive, a quarter turn back where
|
||||
/// the axes were swapped.
|
||||
pub fn stored_t(&self, x: &[f64], h: Handle, t: f64) -> f64 {
|
||||
let Some(s) = self.slot(h) else { return t };
|
||||
let (Shape::Ellipse { .. }, Some(&ratio)) = (s.shape, x.get(s.off + 4)) else { return t };
|
||||
let (t, ratio) = if ratio < 0.0 { (-t, -ratio) } else { (t, ratio) };
|
||||
if ratio > 1.0 + RATIO_TOL { t - std::f64::consts::FRAC_PI_2 } else { t }
|
||||
}
|
||||
|
||||
/// Write solved parameters back. Returns the handles that moved.
|
||||
pub fn write_back(&self, d: &mut Drawing, x: &[f64]) -> Vec<Handle> {
|
||||
let mut moved = Vec::new();
|
||||
@@ -572,8 +644,9 @@ impl Model {
|
||||
[a, b] if arc => (*a, *b),
|
||||
_ => (el.start, el.end),
|
||||
};
|
||||
// Keep the stored form: the major axis is the longer one.
|
||||
if ratio > 1.0 {
|
||||
// Keep the stored form: the major axis is the longer one. `storable` lets a
|
||||
// solved ratio exceed 1 only by rounding: that is a circle, not a swap.
|
||||
if ratio > 1.0 + RATIO_TOL {
|
||||
major = major.perp() * ratio;
|
||||
ratio = 1.0 / ratio;
|
||||
if arc {
|
||||
|
||||
@@ -305,7 +305,7 @@ fn keep_option_moves_the_other_entity() {
|
||||
let mut d = Drawing::default();
|
||||
let a = line(&mut d, (0.0, 0.0), (10.0, 0.0));
|
||||
let b = line(&mut d, (0.0, 2.0), (10.0, 4.0));
|
||||
add(&mut d, con(ConstraintKind::Parallel, &[w(a), w(b)]), &SolveOptions { keep: vec![a], prefer_move: vec![b] }).unwrap();
|
||||
add(&mut d, con(ConstraintKind::Parallel, &[w(a), w(b)]), &SolveOptions { keep: vec![a], prefer_move: vec![b], ..Default::default() }).unwrap();
|
||||
let (a0, a1) = ln(&d, a);
|
||||
assert!(a0.near(Vec2::new(0.0, 0.0), 1e-3) && a1.near(Vec2::new(10.0, 0.0), 1e-3), "{a0:?} {a1:?}");
|
||||
}
|
||||
@@ -412,7 +412,7 @@ fn glyphs_and_residuals() {
|
||||
})
|
||||
.unwrap();
|
||||
assert!(!all_satisfied(&d));
|
||||
let r = solve(&mut d, &SolveOptions { keep: vec![], prefer_move: vec![] }).unwrap();
|
||||
let r = solve(&mut d, &SolveOptions::default()).unwrap();
|
||||
assert_eq!(r.moved, vec![a]);
|
||||
assert!(all_satisfied(&d));
|
||||
}
|
||||
|
||||
@@ -382,3 +382,83 @@ fn vertex_constraints_on_large_fit_spline_do_not_charge_reconstruction() {
|
||||
assert_eq!(report.status, DofStatus::Under);
|
||||
assert_eq!(report.dof, Some(1000));
|
||||
}
|
||||
|
||||
/// Adds a constraint that the stored ellipse can only hold with its axes renumbered: refused,
|
||||
/// with the drawing unchanged.
|
||||
fn refused_on_minor_axis_end(p: (f64, f64)) {
|
||||
let mut d = Drawing::default();
|
||||
let e = ellipse(&mut d, (0.0, 0.0), (4.0, 0.0), 0.5, 0.0, std::f64::consts::TAU);
|
||||
ok(&mut d, con(K::Fix, &[s(e, Sub::Center)]));
|
||||
let p = point(&mut d, p);
|
||||
ok(&mut d, con(K::Fix, &[w(p)]));
|
||||
let before = d.clone();
|
||||
let r = add(&mut d, con(K::Coincident, &[s(e, Sub::Vertex(1)), w(p)]), &SolveOptions::default());
|
||||
assert!(matches!(r, Err(SolveError::Conflict { .. })), "{r:?}, ellipse {:?}", ell(&d, e));
|
||||
assert_eq!(d, before);
|
||||
}
|
||||
|
||||
/// Pulling a minor axis end past the major axis length would need a ratio above 1, which the
|
||||
/// stored ellipse holds only by swapping its axes (and so renumbering their ends).
|
||||
#[test]
|
||||
fn minor_axis_end_pulled_past_the_major_axis_is_refused() {
|
||||
refused_on_minor_axis_end((0.0, 6.0));
|
||||
}
|
||||
|
||||
/// Pulling a minor axis end through the centre would need a negative ratio, which the stored
|
||||
/// ellipse cannot hold: storing its absolute value would mirror the minor axis end.
|
||||
#[test]
|
||||
fn minor_axis_end_pulled_through_the_centre_is_refused() {
|
||||
refused_on_minor_axis_end((0.0, -1.0));
|
||||
}
|
||||
|
||||
/// A circle-like ellipse whose solution makes the minor axis the longer one: stored with its axes
|
||||
/// swapped, which constraints that name no axis end by index do not notice.
|
||||
fn round_ellipse(d: &mut Drawing, start: f64, end: f64) -> Handle {
|
||||
let e = ellipse(d, (0.0, 0.0), (4.0, 0.0), 1.0, start, end);
|
||||
ok(d, con(K::Fix, &[s(e, Sub::Center)]));
|
||||
e
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tangent_that_swaps_a_round_ellipse_axes_is_kept() {
|
||||
let mut d = Drawing::default();
|
||||
let e = round_ellipse(&mut d, 0.0, std::f64::consts::TAU);
|
||||
let l = line(&mut d, (-10.0, 4.5), (10.0, 4.5));
|
||||
ok(&mut d, con(K::Fix, &[w(l)]));
|
||||
ok(&mut d, con(K::Tangent, &[w(l), w(e)]));
|
||||
assert!(ell(&d, e).ratio <= 1.0);
|
||||
assert!(all_satisfied(&d));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn point_on_curve_that_swaps_a_round_ellipse_axes_is_kept() {
|
||||
let mut d = Drawing::default();
|
||||
let e = round_ellipse(&mut d, 0.0, std::f64::consts::TAU);
|
||||
let p = point(&mut d, (0.0, 4.5));
|
||||
ok(&mut d, con(K::Fix, &[w(p)]));
|
||||
ok(&mut d, con(K::Coincident, &[w(p), w(e)]));
|
||||
assert!(ell(&d, e).ratio <= 1.0);
|
||||
assert!(all_satisfied(&d));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn elliptical_arc_start_that_swaps_its_axes_is_kept() {
|
||||
let mut d = Drawing::default();
|
||||
let e = round_ellipse(&mut d, std::f64::consts::FRAC_PI_2, std::f64::consts::PI);
|
||||
let p = point(&mut d, (0.0, 4.5));
|
||||
ok(&mut d, con(K::Fix, &[w(p)]));
|
||||
ok(&mut d, con(K::Coincident, &[s(e, Sub::Start), w(p)]));
|
||||
assert!(ell(&d, e).ratio <= 1.0);
|
||||
assert!(all_satisfied(&d));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tangent_circle_that_swaps_a_round_ellipse_axes_is_kept() {
|
||||
let mut d = Drawing::default();
|
||||
let e = round_ellipse(&mut d, 0.0, std::f64::consts::TAU);
|
||||
let c = circle(&mut d, (0.0, 6.0), 1.5);
|
||||
ok(&mut d, con(K::Fix, &[w(c)]));
|
||||
ok(&mut d, con(K::Tangent, &[w(c), w(e)]));
|
||||
assert!(ell(&d, e).ratio <= 1.0);
|
||||
assert!(all_satisfied(&d));
|
||||
}
|
||||
|
||||
@@ -208,7 +208,7 @@ impl Default for ParametricSettings {
|
||||
angle_tolerance: 1.0,
|
||||
auto_types: ["Coincident", "Collinear", "Parallel", "Perpendicular", "Horizontal", "Vertical", "Tangent", "Concentric", "Equal"]
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.map(ToString::to_string)
|
||||
.collect(),
|
||||
bars_visible: true,
|
||||
bar_exceptions: Vec::new(),
|
||||
|
||||
@@ -29,16 +29,21 @@ fn tag(code: i32, value: impl Into<String>) -> RawTag {
|
||||
RawTag { code, value: value.into() }
|
||||
}
|
||||
|
||||
/// The element tag (`AcDbBlockRepETag` 1071) of object `h`, if it has one.
|
||||
fn etag(d: &Drawing, h: Handle) -> Option<i64> {
|
||||
let x = &d.foreign.entities.get(&h)?.xdata;
|
||||
/// The element tag (`AcDbBlockRepETag` 1071) in `x`, if it has one.
|
||||
fn etag_in(x: &[RawTag]) -> Option<i64> {
|
||||
let at = x.iter().position(|t| t.code == 1001 && t.value.eq_ignore_ascii_case(ETAG))?;
|
||||
x.iter().skip(at + 1).take_while(|t| t.code != 1001).find(|t| t.code == 1071).and_then(|t| t.value.trim().parse().ok())
|
||||
}
|
||||
|
||||
/// The element tag of object `e`: its own xdata (where the importer keeps it), else any kept in the
|
||||
/// foreign data.
|
||||
fn etag(d: &Drawing, e: &Entity) -> Option<i64> {
|
||||
etag_in(&e.common.xdata).or_else(|| etag_in(&d.foreign.entities.get(&e.handle)?.xdata))
|
||||
}
|
||||
|
||||
/// Each object of `b` keyed by its element tag, or by its position when it has none.
|
||||
fn by_tag(d: &Drawing, b: &Block) -> Vec<(i64, Handle)> {
|
||||
b.entities.iter().enumerate().map(|(i, e)| (etag(d, e.handle).unwrap_or(i as i64 + 1_000_000), e.handle)).collect()
|
||||
b.entities.iter().enumerate().map(|(i, e)| (etag(d, e).unwrap_or(i as i64 + 1_000_000), e.handle)).collect()
|
||||
}
|
||||
|
||||
/// The file handle of block `name`'s BLOCK_RECORD, as the kept data refers to it.
|
||||
@@ -542,6 +547,12 @@ fn apply(
|
||||
let Some(nh) = copies.get(&e.handle).copied() else { continue };
|
||||
let mut c: Entity = (**e).clone();
|
||||
c.handle = nh;
|
||||
// The copy's own xdata names the copy, not its original (as the kept data below does).
|
||||
for t in &mut c.common.xdata {
|
||||
if t.code == 1005 && Handle::parse_hex(&t.value) == Some(e.handle) {
|
||||
t.value = nh.hex();
|
||||
}
|
||||
}
|
||||
if let Some(v) = shown_copy.get(&e.handle) {
|
||||
c.common.visible = *v;
|
||||
}
|
||||
@@ -964,3 +975,30 @@ fn drop_if_unused(d: &mut Drawing, name: &str) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn tagged(h: u64, tag_value: Option<&str>) -> Entity {
|
||||
let mut e = Entity::new(Handle(h), EntityKind::Line(crate::Line { a: Vec3::ZERO, b: Vec3::ZERO }));
|
||||
if let Some(v) = tag_value {
|
||||
e.common.xdata = vec![tag(1001, ETAG), tag(1071, v)];
|
||||
}
|
||||
e
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn element_tags_are_read_from_the_entitys_own_xdata() {
|
||||
let d = Drawing::new_imperial();
|
||||
let mut def = Block::new("DEF");
|
||||
def.entities.push(tagged(1, Some("10")));
|
||||
def.entities.push(tagged(2, Some("20")));
|
||||
let mut anon = Block::new("*U1");
|
||||
// Reordered, and the first object left out.
|
||||
anon.entities.push(tagged(5, Some("20")));
|
||||
anon.entities.push(tagged(6, None));
|
||||
assert_eq!(by_tag(&d, &def), vec![(10, Handle(1)), (20, Handle(2))]);
|
||||
assert_eq!(by_tag(&d, &anon), vec![(20, Handle(5)), (1_000_001, Handle(6))]);
|
||||
}
|
||||
}
|
||||
|
||||
+24
-930
@@ -9,126 +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<Handle> {
|
||||
u64::from_str_radix(s.trim(), 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<crate::AnnoScale>,
|
||||
/// 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<RawTag>,
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -142,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<PolyVertex>,
|
||||
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<Vec3>,
|
||||
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<Vec3>,
|
||||
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
|
||||
}
|
||||
@@ -253,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<MTextColumns>,
|
||||
}
|
||||
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<f64>,
|
||||
/// 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<MultilineAttrib>,
|
||||
/// 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<MText> {
|
||||
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<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub color: Option<Color>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub linetype: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub lineweight: Option<Lineweight>,
|
||||
}
|
||||
|
||||
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<Attrib>,
|
||||
#[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<String>,
|
||||
/// 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<String, serde_json::Value>,
|
||||
/// Associativity: which definition points follow which objects.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub assoc: Vec<DimAssoc>,
|
||||
/// 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<f64>,
|
||||
/// 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<Vec3>,
|
||||
/// A jogged radius dimension (DIMJOGGED).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub jogged: Option<JoggedRadius>,
|
||||
/// Gaps in the dimension and extension lines (DIMBREAK).
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub breaks: Vec<DimBreak>,
|
||||
/// Inspection frame, label and rate (DIMINSPECT).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub inspect: Option<Inspection>,
|
||||
}
|
||||
|
||||
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<Vec3>,
|
||||
#[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<Vec<Vec3>>,
|
||||
pub landing: Vec3,
|
||||
pub dogleg: f64,
|
||||
pub text: Option<MText>,
|
||||
#[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<Insert>,
|
||||
}
|
||||
|
||||
impl MLeader {
|
||||
/// Each leader line as drawn: through its points to the landing, smoothed for spline leaders.
|
||||
pub fn leader_paths(&self) -> Vec<Vec<Vec2>> {
|
||||
self.leaders
|
||||
.iter()
|
||||
.map(|l| {
|
||||
let pts: Vec<Vec2> = 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<PolyVertex>,
|
||||
#[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<Handle>,
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Hatch {
|
||||
pub pattern: String,
|
||||
pub solid: bool,
|
||||
pub loops: Vec<HatchLoop>,
|
||||
#[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<Gradient>,
|
||||
#[serde(default)]
|
||||
pub origin: Vec2,
|
||||
#[serde(default)]
|
||||
pub background: Option<Color>,
|
||||
/// 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<crate::library::PatternLine>,
|
||||
}
|
||||
#[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<String>,
|
||||
/// 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<Handle>,
|
||||
/// 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<Vec2>,
|
||||
/// 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<Vec2>,
|
||||
}
|
||||
#[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<String>,
|
||||
/// 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<f64>,
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Table {
|
||||
pub insert: Vec3,
|
||||
pub col_widths: Vec<f64>,
|
||||
pub row_heights: Vec<f64>,
|
||||
pub cells: Vec<Vec<TableCell>>,
|
||||
#[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<String>,
|
||||
/// Table breaking (TABLEBREAK): the table drawn in several parts.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub breaks: Option<crate::TableBreak>,
|
||||
/// The CSV file the cells come from (DATALINK).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub data_link: Option<crate::DataLink>,
|
||||
}
|
||||
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,
|
||||
@@ -1477,6 +562,15 @@ mod grip_tests {
|
||||
use super::*;
|
||||
use cadcraft_geom::PolyVertex;
|
||||
|
||||
#[test]
|
||||
fn handle_parse_hex_accepts_only_hex_digits() {
|
||||
assert_eq!(Handle::parse_hex("1a"), Some(Handle(0x1A)));
|
||||
assert_eq!(Handle::parse_hex(" 1A "), Some(Handle(0x1A)));
|
||||
for bad in ["+1A", "-1A", "", "+", "1 A"] {
|
||||
assert_eq!(Handle::parse_hex(bad), None, "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn polyline_grips_are_vertices_and_midpoints() {
|
||||
// Grips are each vertex plus the midpoint of each segment; the segment vector is computed
|
||||
|
||||
@@ -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<MultilineAttrib>,
|
||||
/// 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<MText> {
|
||||
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<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub color: Option<Color>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub linetype: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub lineweight: Option<Lineweight>,
|
||||
}
|
||||
|
||||
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<Attrib>,
|
||||
#[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))
|
||||
}
|
||||
}
|
||||
@@ -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<Handle> {
|
||||
let s = s.trim();
|
||||
if !s.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
return None;
|
||||
}
|
||||
u64::from_str_radix(s, 16).ok().map(Handle)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<crate::AnnoScale>,
|
||||
/// 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<RawTag>,
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
@@ -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<PolyVertex>,
|
||||
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<Vec3>,
|
||||
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,
|
||||
}
|
||||
@@ -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<String>,
|
||||
/// 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<String, serde_json::Value>,
|
||||
/// Associativity: which definition points follow which objects.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub assoc: Vec<DimAssoc>,
|
||||
/// 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<f64>,
|
||||
/// 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<Vec3>,
|
||||
/// A jogged radius dimension (DIMJOGGED).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub jogged: Option<JoggedRadius>,
|
||||
/// Gaps in the dimension and extension lines (DIMBREAK).
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub breaks: Vec<DimBreak>,
|
||||
/// Inspection frame, label and rate (DIMINSPECT).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub inspect: Option<Inspection>,
|
||||
}
|
||||
|
||||
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<Vec3>,
|
||||
#[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<Vec<Vec3>>,
|
||||
pub landing: Vec3,
|
||||
pub dogleg: f64,
|
||||
pub text: Option<MText>,
|
||||
#[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<Insert>,
|
||||
}
|
||||
|
||||
impl MLeader {
|
||||
/// Each leader line as drawn: through its points to the landing, smoothed for spline leaders.
|
||||
pub fn leader_paths(&self) -> Vec<Vec<Vec2>> {
|
||||
self.leaders
|
||||
.iter()
|
||||
.map(|l| {
|
||||
let pts: Vec<Vec2> = 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()
|
||||
}
|
||||
}
|
||||
@@ -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<PolyVertex>,
|
||||
#[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<Handle>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Hatch {
|
||||
pub pattern: String,
|
||||
pub solid: bool,
|
||||
pub loops: Vec<HatchLoop>,
|
||||
#[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<Gradient>,
|
||||
#[serde(default)]
|
||||
pub origin: Vec2,
|
||||
#[serde(default)]
|
||||
pub background: Option<Color>,
|
||||
/// 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<crate::library::PatternLine>,
|
||||
}
|
||||
|
||||
#[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<Vec2>,
|
||||
}
|
||||
@@ -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<String>,
|
||||
/// 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<Handle>,
|
||||
/// 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<Vec2>,
|
||||
/// 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<String>,
|
||||
/// 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<f64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Table {
|
||||
pub insert: Vec3,
|
||||
pub col_widths: Vec<f64>,
|
||||
pub row_heights: Vec<f64>,
|
||||
pub cells: Vec<Vec<TableCell>>,
|
||||
#[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<String>,
|
||||
/// Table breaking (TABLEBREAK): the table drawn in several parts.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub breaks: Option<crate::TableBreak>,
|
||||
/// The CSV file the cells come from (DATALINK).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub data_link: Option<crate::DataLink>,
|
||||
}
|
||||
|
||||
fn default_text_h() -> f64 {
|
||||
0.18
|
||||
}
|
||||
@@ -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<Vec3>,
|
||||
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<MTextColumns>,
|
||||
}
|
||||
|
||||
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<f64>,
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,10 @@ pub struct Foreign {
|
||||
pub records: BTreeMap<ForeignKey, ForeignExtra>,
|
||||
/// The roles of the file handles the kept data refers to, for objects CADCraft rewrites.
|
||||
pub targets: BTreeMap<Handle, ForeignKey>,
|
||||
/// The upper-case names of the file's classes in its CLASSES order, kept when the file has
|
||||
/// proxy objects or entities: a proxy's class number (group 91) is 500 plus its class's place
|
||||
/// here, and saving renumbers it to the written CLASSES (#1812).
|
||||
pub class_order: Vec<String>,
|
||||
/// The file's data store section (ACDSDATA, AutoCAD 2013 and later), as read: its groups
|
||||
/// after the section name. It holds the ACIS geometry of 3D solids, regions, bodies and
|
||||
/// surfaces, which from 2013 on those entities don't carry themselves: each `ACDSRECORD`
|
||||
@@ -81,6 +85,7 @@ impl Foreign {
|
||||
&& self.records.is_empty()
|
||||
&& self.classes.is_empty()
|
||||
&& self.targets.is_empty()
|
||||
&& self.class_order.is_empty()
|
||||
&& self.data_store.is_empty()
|
||||
}
|
||||
|
||||
|
||||
@@ -98,6 +98,9 @@ pub struct Drawing {
|
||||
pub blocks: BTreeMap<String, std::sync::Arc<Block>>,
|
||||
pub model: EntityStore,
|
||||
pub layouts: Vec<Layout>,
|
||||
/// The layout a file marks active (its block is `*Paper_Space`, the others `*Paper_SpaceN`),
|
||||
/// written active again. `None`, or a layout that no longer exists: the first by tab order.
|
||||
pub active_layout: Option<String>,
|
||||
pub views: Vec<NamedView>,
|
||||
/// Model space viewport configurations (VPORTS): the tiles shown and named ones.
|
||||
pub vports: Vec<VportConfig>,
|
||||
@@ -171,6 +174,7 @@ impl Drawing {
|
||||
blocks: BTreeMap::new(),
|
||||
model: EntityStore::new(),
|
||||
layouts: vec![Layout::new("Layout1", 1), Layout::new("Layout2", 2)],
|
||||
active_layout: None,
|
||||
views: Vec::new(),
|
||||
vports: Vec::new(),
|
||||
ucss: Vec::new(),
|
||||
@@ -298,6 +302,18 @@ impl Drawing {
|
||||
true
|
||||
}
|
||||
|
||||
/// The layouts in the order their paper space blocks are named in a file: the active one
|
||||
/// (`*Paper_Space`) first, then the others by tab order (`*Paper_Space0`, `*Paper_Space1`, …).
|
||||
pub fn paper_space_layouts(&self) -> Vec<&Layout> {
|
||||
let mut ls: Vec<&Layout> = self.layouts.iter().collect();
|
||||
ls.sort_by_key(|l| l.tab_order);
|
||||
if let Some(i) = self.active_layout.as_ref().and_then(|a| ls.iter().position(|l| l.name.eq_ignore_ascii_case(a))) {
|
||||
let active = ls.remove(i);
|
||||
ls.insert(0, active);
|
||||
}
|
||||
ls
|
||||
}
|
||||
|
||||
pub fn space(&self, space: &Space) -> Option<&EntityStore> {
|
||||
match space {
|
||||
Space::Model => Some(&self.model),
|
||||
|
||||
+66
-36
@@ -177,6 +177,11 @@ pub struct DimStyle {
|
||||
pub dim_line_color: Color,
|
||||
pub ext_line_color: Color,
|
||||
pub text_color: Color,
|
||||
/// DIMTFILL, the background of the text: 0 none, 1 the drawing's background color,
|
||||
/// 2 `text_fill_color`.
|
||||
pub text_fill: u8,
|
||||
/// DIMTFILLCLR: the text background color when `text_fill` is 2.
|
||||
pub text_fill_color: Color,
|
||||
pub text_style: String,
|
||||
/// DIMPOST prefix/suffix "<>" template.
|
||||
pub post: String,
|
||||
@@ -296,6 +301,8 @@ pub const DIMVARS: &[(&str, &str)] = &[
|
||||
("DIMCLRD", "dimLineColor"),
|
||||
("DIMCLRE", "extLineColor"),
|
||||
("DIMCLRT", "textColor"),
|
||||
("DIMTFILL", "textFill"),
|
||||
("DIMTFILLCLR", "textFillColor"),
|
||||
("DIMTXSTY", "textStyle"),
|
||||
("DIMPOST", "post"),
|
||||
("DIMCEN", "centerMark"),
|
||||
@@ -367,6 +374,8 @@ impl Default for DimStyle {
|
||||
dim_line_color: Color::ByBlock,
|
||||
ext_line_color: Color::ByBlock,
|
||||
text_color: Color::ByBlock,
|
||||
text_fill: 0,
|
||||
text_fill_color: Color::ByBlock,
|
||||
text_style: "Standard".into(),
|
||||
post: String::new(),
|
||||
center_mark: 0.09,
|
||||
@@ -432,6 +441,49 @@ impl DimStyle {
|
||||
.or_else(|| ["name", "annotative"].into_iter().find(|f| f.eq_ignore_ascii_case(name)))
|
||||
}
|
||||
|
||||
/// The value `v` takes for a field whose current value is `old`: coerced to the field's type where
|
||||
/// the text or number can be read as it.
|
||||
fn coerce_value(old: &serde_json::Value, v: &serde_json::Value, field: &str) -> serde_json::Value {
|
||||
use serde_json::Value;
|
||||
match (old, v) {
|
||||
(Value::Bool(_), Value::Number(n)) => Value::Bool(n.as_f64().is_some_and(|x| x != 0.0)),
|
||||
(Value::Bool(_), Value::String(t)) => Value::Bool(matches!(t.to_ascii_lowercase().as_str(), "1" | "on" | "true" | "yes")),
|
||||
(Value::Number(_), Value::String(t)) => match t.trim().parse::<f64>() {
|
||||
Ok(x) => serde_json::json!(x),
|
||||
// Lineweight names.
|
||||
Err(_) if matches!(field, "dimLineweight" | "extLineweight") => match t.trim().to_ascii_lowercase().as_str() {
|
||||
"bylayer" => serde_json::json!(-1),
|
||||
"byblock" => serde_json::json!(-2),
|
||||
"default" => serde_json::json!(-3),
|
||||
_ => v.clone(),
|
||||
},
|
||||
Err(_) => v.clone(),
|
||||
},
|
||||
(Value::Number(_), Value::Bool(b)) => serde_json::json!(u8::from(*b)),
|
||||
(Value::String(_), Value::Number(n)) => Value::String(n.to_string()),
|
||||
(Value::Object(_), Value::Number(n)) => {
|
||||
let c = n.as_i64().and_then(|i| i16::try_from(i).ok()).map(Color::from_aci);
|
||||
c.and_then(|c| serde_json::to_value(c).ok()).unwrap_or_else(|| v.clone())
|
||||
}
|
||||
(Value::Object(_), Value::String(t)) => Color::parse(t).and_then(|c| serde_json::to_value(c).ok()).unwrap_or_else(|| v.clone()),
|
||||
_ => v.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `coerced` is outside the range its `field` can take.
|
||||
fn out_of_range(field: &str, coerced: &serde_json::Value) -> bool {
|
||||
let x = coerced.as_f64();
|
||||
match field {
|
||||
"textHeight" | "tolScale" => x.is_some_and(|x| x <= 0.0),
|
||||
"scale" | "arrowSize" | "tickSize" | "fixedExtLength" => x.is_some_and(|x| x < 0.0),
|
||||
"fit" | "angularZeroSuppression" => x.is_some_and(|x| x > 3.0),
|
||||
"textMove" | "tolJust" | "arcSymbol" | "textFill" => x.is_some_and(|x| x > 2.0),
|
||||
"dimLineweight" | "extLineweight" => x.is_some_and(|x| !(-3.0..=211.0).contains(&x)),
|
||||
"jogAngle" => x.is_some_and(|x| !(5.0..=90.0).contains(&x)),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set fields from a JSON object of field or DIM* variable names (numbers, booleans as
|
||||
/// 0/1, colours as ACI numbers or names). Returns the names that were not recognised or
|
||||
/// had unusable values.
|
||||
@@ -449,51 +501,29 @@ impl DimStyle {
|
||||
rejected.push(k.clone());
|
||||
continue;
|
||||
};
|
||||
let coerced = match (&old, v) {
|
||||
(Value::Bool(_), Value::Number(n)) => Value::Bool(n.as_f64().is_some_and(|x| x != 0.0)),
|
||||
(Value::Bool(_), Value::String(t)) => Value::Bool(matches!(t.to_ascii_lowercase().as_str(), "1" | "on" | "true" | "yes")),
|
||||
(Value::Number(_), Value::String(t)) => match t.trim().parse::<f64>() {
|
||||
Ok(x) => serde_json::json!(x),
|
||||
// Lineweight names.
|
||||
Err(_) if matches!(field, "dimLineweight" | "extLineweight") => match t.trim().to_ascii_lowercase().as_str() {
|
||||
"bylayer" => serde_json::json!(-1),
|
||||
"byblock" => serde_json::json!(-2),
|
||||
"default" => serde_json::json!(-3),
|
||||
_ => v.clone(),
|
||||
},
|
||||
Err(_) => v.clone(),
|
||||
},
|
||||
(Value::Number(_), Value::Bool(b)) => serde_json::json!(u8::from(*b)),
|
||||
(Value::String(_), Value::Number(n)) => Value::String(n.to_string()),
|
||||
(Value::Object(_), Value::Number(n)) => {
|
||||
let c = n.as_i64().and_then(|i| i16::try_from(i).ok()).map(Color::from_aci);
|
||||
c.and_then(|c| serde_json::to_value(c).ok()).unwrap_or_else(|| v.clone())
|
||||
}
|
||||
(Value::Object(_), Value::String(t)) => Color::parse(t).and_then(|c| serde_json::to_value(c).ok()).unwrap_or_else(|| v.clone()),
|
||||
_ => v.clone(),
|
||||
};
|
||||
let coerced = Self::coerce_value(&old, v, field);
|
||||
// Integers stored in u8 fields: round floats.
|
||||
let coerced = match (&old, &coerced) {
|
||||
// Lineweights (i16): whole hundredths of a mm or -1/-2/-3.
|
||||
(Value::Number(_), Value::Number(n)) if matches!(field, "dimLineweight" | "extLineweight") && !n.is_i64() => {
|
||||
serde_json::json!(n.as_f64().filter(|x| x.is_finite()).unwrap_or(0.0).round().clamp(-3.0, 211.0) as i64)
|
||||
serde_json::json!(n.as_f64().filter(|x| x.is_finite()).unwrap_or(0.0).round() as i64)
|
||||
}
|
||||
(Value::Number(_), Value::Number(_)) if matches!(field, "dimLineweight" | "extLineweight") => coerced,
|
||||
// A value below 0 or one that rounds above 255 is refused; DIMADEC -1 reads as 0.
|
||||
(Value::Number(o), Value::Number(n)) if o.is_u64() && !n.is_u64() => {
|
||||
serde_json::json!(n.as_f64().filter(|x| x.is_finite()).unwrap_or(0.0).round().clamp(0.0, 255.0) as u64)
|
||||
let min = if field == "angularDecimals" { -1.0 } else { 0.0 };
|
||||
match n.as_f64().filter(|x| x.is_finite() && *x >= min).map(f64::round).filter(|x| *x <= 255.0) {
|
||||
Some(x) => serde_json::json!(x.max(0.0) as u64),
|
||||
None => {
|
||||
rejected.push(k.clone());
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => coerced,
|
||||
};
|
||||
// Sizes that can't be negative (text height and tolerance scale: not zero either).
|
||||
let x = coerced.as_f64();
|
||||
let out_of_range = match field {
|
||||
"textHeight" | "tolScale" => x.is_some_and(|x| x <= 0.0),
|
||||
"scale" | "arrowSize" | "tickSize" | "fixedExtLength" => x.is_some_and(|x| x < 0.0),
|
||||
"fit" | "angularZeroSuppression" => x.is_some_and(|x| x > 3.0),
|
||||
"textMove" | "tolJust" | "arcSymbol" => x.is_some_and(|x| x > 2.0),
|
||||
"dimLineweight" | "extLineweight" => x.is_some_and(|x| !(-3.0..=211.0).contains(&x)),
|
||||
"jogAngle" => x.is_some_and(|x| !(5.0..=90.0).contains(&x)),
|
||||
_ => false,
|
||||
};
|
||||
let out_of_range = Self::out_of_range(field, &coerced);
|
||||
if out_of_range {
|
||||
rejected.push(k.clone());
|
||||
continue;
|
||||
@@ -857,7 +887,7 @@ pub struct VportConfig {
|
||||
|
||||
impl VportConfig {
|
||||
/// The name of the configuration shown.
|
||||
pub const ACTIVE: &'static str = "*Active";
|
||||
pub const ACTIVE: &str = "*Active";
|
||||
pub fn is_active(&self) -> bool {
|
||||
self.name.eq_ignore_ascii_case(Self::ACTIVE)
|
||||
}
|
||||
|
||||
@@ -109,9 +109,9 @@ impl Default for CellStyle {
|
||||
}
|
||||
|
||||
impl CellStyle {
|
||||
pub const TITLE: &'static str = "Title";
|
||||
pub const HEADER: &'static str = "Header";
|
||||
pub const DATA: &'static str = "Data";
|
||||
pub const TITLE: &str = "Title";
|
||||
pub const HEADER: &str = "Header";
|
||||
pub const DATA: &str = "Data";
|
||||
|
||||
/// Whether `name` is one of the three styles every table style has.
|
||||
pub fn is_builtin(name: &str) -> bool {
|
||||
@@ -190,7 +190,7 @@ impl TableStyle {
|
||||
|
||||
/// The names of its cell styles: Title, Header, Data, then the custom ones.
|
||||
pub fn cell_style_names(&self) -> Vec<String> {
|
||||
let mut out: Vec<String> = [CellStyle::TITLE, CellStyle::HEADER, CellStyle::DATA].iter().map(|s| s.to_string()).collect();
|
||||
let mut out: Vec<String> = [CellStyle::TITLE, CellStyle::HEADER, CellStyle::DATA].iter().map(ToString::to_string).collect();
|
||||
out.extend(self.cell_styles.iter().filter(|c| !CellStyle::is_builtin(&c.name)).map(|c| c.name.clone()));
|
||||
out
|
||||
}
|
||||
|
||||
@@ -541,12 +541,80 @@ mod native {
|
||||
dimension_flags(&mut doc);
|
||||
drop_unwritable(&mut doc);
|
||||
current_dimstyle(&mut doc);
|
||||
add_variable_dictionary(&mut doc);
|
||||
current_text_style(&mut doc);
|
||||
std::panic::catch_unwind(AssertUnwindSafe(|| acadrust::DwgWriter::write_to_vec(&doc)))
|
||||
.map_err(|_| "the DWG writer failed on this drawing".to_string())?
|
||||
.map_err(|e| format!("DWG write: {e}"))
|
||||
}
|
||||
|
||||
/// AutoCAD's entry name for the drawing variables dictionary (the DXF Reference calls it the
|
||||
/// ACDBVARIABLEDICTIONARY; dictionary keys are case-insensitive).
|
||||
const VARIABLE_DICTIONARY: &str = "AcDbVariableDictionary";
|
||||
|
||||
/// A style or scale name, or `default` when the drawing has none.
|
||||
fn name_or_default(name: &str, default: &str) -> String {
|
||||
let name = name.trim();
|
||||
if name.is_empty() { default.to_string() } else { name.to_string() }
|
||||
}
|
||||
|
||||
/// A drawing variable's value as text, or `default` when the header holds a value the
|
||||
/// variable doesn't accept (a hostile file can put any integer in it).
|
||||
fn variable_value(value: i16, valid: &[i16], default: i16) -> String {
|
||||
if valid.contains(&value) { value.to_string() } else { default.to_string() }
|
||||
}
|
||||
|
||||
/// Add the drawing variables dictionary AutoCAD always writes into the named object
|
||||
/// dictionary, with the variables it keeps there, when the drawing has none (#1813).
|
||||
/// Vectric Aspire 10.5 reads a 2004 (AC1018) DWG without it as empty ("No data was imported
|
||||
/// from the DXF / DWG file"), though it reads the same drawing as DXF — which has no such
|
||||
/// dictionary either, as CADCraft's and ezdxf's writers don't make one. AutoCAD's AC1032 DWGs
|
||||
/// read there without it, so newer output is left as it was, and a dictionary the drawing
|
||||
/// does have keeps its own entries and values.
|
||||
fn add_variable_dictionary(doc: &mut acadrust::CadDocument) {
|
||||
use acadrust::objects::{Dictionary, DictionaryVariable, ObjectType};
|
||||
if doc.version >= acadrust::DxfVersion::AC1032 {
|
||||
return;
|
||||
}
|
||||
let root_handle = doc.header.named_objects_dict_handle;
|
||||
match doc.objects.get(&root_handle) {
|
||||
Some(ObjectType::Dictionary(root)) => {
|
||||
if root.get(VARIABLE_DICTIONARY).is_some() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
_ => return,
|
||||
}
|
||||
// The variables AutoCAD writes for a drawing with default styles and a 1:1 annotation
|
||||
// scale. DIMASSOC and HIDETEXT keep the drawing's own header values; LAYEREVAL and
|
||||
// LAYERNOTIFY have no DXF header variable.
|
||||
let variables = [
|
||||
("CMLEADERSTYLE", name_or_default(&doc.header.current_mleader_style_name, "Standard")),
|
||||
("CTABLESTYLE", name_or_default(&doc.header.current_table_style_name, "Standard")),
|
||||
("CANNOSCALE", name_or_default(&doc.header.current_annotation_scale, "1:1")),
|
||||
("DIMASSOC", variable_value(doc.header.dimension_associativity, &[0, 1, 2], 2)),
|
||||
("HIDETEXT", variable_value(doc.header.hide_text, &[0, 1], 1)),
|
||||
("LAYEREVAL", "0".to_string()),
|
||||
("LAYERNOTIFY", "0".to_string()),
|
||||
];
|
||||
let dictionary_handle = doc.allocate_handle();
|
||||
let mut dictionary = Dictionary::new();
|
||||
dictionary.handle = dictionary_handle;
|
||||
dictionary.owner = root_handle;
|
||||
for (name, value) in variables {
|
||||
let handle = doc.allocate_handle();
|
||||
let mut variable = DictionaryVariable::new(name, &value);
|
||||
variable.handle = handle;
|
||||
variable.owner_handle = dictionary_handle;
|
||||
dictionary.add_entry(name, handle);
|
||||
doc.objects.insert(handle, ObjectType::DictionaryVariable(variable));
|
||||
}
|
||||
doc.objects.insert(dictionary_handle, ObjectType::Dictionary(dictionary));
|
||||
if let Some(ObjectType::Dictionary(root)) = doc.objects.get_mut(&root_handle) {
|
||||
root.add_entry(VARIABLE_DICTIONARY, dictionary_handle);
|
||||
}
|
||||
}
|
||||
|
||||
/// Entities of a layout other than the one in `*Paper_Space` are written owned by their block
|
||||
/// (entity mode 0, with its handle). acadrust writes every paper-space entity with mode 1,
|
||||
/// "paper space", which AutoCAD reads as the `*Paper_Space` block's: everything drawn on a
|
||||
|
||||
+23
-11
@@ -413,8 +413,7 @@ pub fn write_ascii_as(tags: &[Tag], utf8: bool) -> String {
|
||||
for t in tags {
|
||||
let _ = write!(out, "{:>3}\r\n", t.code);
|
||||
match &t.value {
|
||||
Value::Str(s) if utf8 => out.extend(s.chars().map(|c| if matches!(c, '\r' | '\n') { ' ' } else { c })),
|
||||
Value::Str(s) => escape_unicode(s, &mut out),
|
||||
Value::Str(s) => escape_unicode(s, utf8, &mut out),
|
||||
Value::Real(r) => out.push_str(&format_real(*r)),
|
||||
Value::Int(i) => out.push_str(&i.to_string()),
|
||||
Value::Bool(b) => out.push_str(&i64::from(*b).to_string()),
|
||||
@@ -446,11 +445,7 @@ pub fn write_binary(tags: &[Tag], utf8: bool) -> Vec<u8> {
|
||||
v => v.as_str(),
|
||||
};
|
||||
let mut text = String::with_capacity(s.len());
|
||||
if utf8 {
|
||||
text.extend(s.chars().map(|c| if matches!(c, '\r' | '\n') { ' ' } else { c }));
|
||||
} else {
|
||||
escape_unicode(&s, &mut text);
|
||||
}
|
||||
escape_unicode(&s, utf8, &mut text);
|
||||
out.extend(text.bytes().filter(|&b| b != 0));
|
||||
out.push(0);
|
||||
}
|
||||
@@ -507,12 +502,16 @@ pub fn is_binary(bytes: &[u8]) -> bool {
|
||||
|
||||
/// Escape a string for a pre-R2007 DXF, whose text is in the `$DWGCODEPAGE` code page: ASCII
|
||||
/// stays as it is and every other character becomes `\U+XXXX` (a surrogate pair of escapes
|
||||
/// above U+FFFF), so the bytes are valid in any code page. Line breaks become spaces.
|
||||
fn escape_unicode(s: &str, out: &mut String) {
|
||||
for c in s.chars() {
|
||||
/// above U+FFFF), so the bytes are valid in any code page; with `utf8` they stay as they are.
|
||||
/// A backslash that would start a `\U+` or `\M+` sequence is written as `\U+005C` so the reader
|
||||
/// gives back the literal text. Line breaks become spaces.
|
||||
fn escape_unicode(s: &str, utf8: bool, out: &mut String) {
|
||||
let chars: Vec<char> = s.chars().collect();
|
||||
for (i, &c) in chars.iter().enumerate() {
|
||||
match c {
|
||||
'\r' | '\n' => out.push(' '),
|
||||
c if c.is_ascii() => out.push(c),
|
||||
'\\' if matches!(chars.get(i + 1), Some('U' | 'M')) && chars.get(i + 2) == Some(&'+') => out.push_str("\\U+005C"),
|
||||
c if utf8 || c.is_ascii() => out.push(c),
|
||||
c => {
|
||||
let mut units = [0u16; 2];
|
||||
for u in c.encode_utf16(&mut units) {
|
||||
@@ -569,6 +568,19 @@ pub fn records(tags: &[Tag]) -> Vec<(String, Vec<Tag>)> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn literal_escape_text_roundtrips() {
|
||||
for utf8 in [false, true] {
|
||||
for text in ["\\U+00E9", "\\M+183A4F", "a\\nb \\P", "\\\\U+00E9", "\\U+005C"] {
|
||||
let tags = vec![Tag::s(8, text)];
|
||||
let back = parse_ascii(&write_ascii_as(&tags, utf8)).unwrap();
|
||||
assert_eq!(back, tags, "utf8={utf8} {text}");
|
||||
let back = parse_binary(&write_binary(&tags, utf8)).unwrap();
|
||||
assert_eq!(back[1..], tags[..], "binary utf8={utf8} {text}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ascii_roundtrip() {
|
||||
let tags = vec![
|
||||
|
||||
@@ -208,6 +208,12 @@ impl Autosaver {
|
||||
if minutes == 0 || dir.trim().is_empty() || (!urgent && now - since < f64::from(minutes) * 60.0) {
|
||||
continue;
|
||||
}
|
||||
// In the Block Editor: the drawing with the editor's unsaved changes in the block. When
|
||||
// they can't be put there, nothing is written and nothing is marked as covered, so the
|
||||
// next pass tries again instead of keeping the host without them.
|
||||
let Ok(doc) = st.recovery_drawing() else {
|
||||
continue;
|
||||
};
|
||||
let path = t
|
||||
.file
|
||||
.clone()
|
||||
@@ -216,8 +222,7 @@ impl Autosaver {
|
||||
t.written = Some(Arc::downgrade(&st.doc));
|
||||
t.host = Some(Arc::downgrade(st.drawing()));
|
||||
t.since = None;
|
||||
// In the Block Editor: the drawing with the editor's unsaved changes in the block.
|
||||
jobs.push(Job::Write { uid: st.uid, path, doc: st.recovery_drawing() });
|
||||
jobs.push(Job::Write { uid: st.uid, path, doc });
|
||||
}
|
||||
jobs
|
||||
}
|
||||
@@ -302,7 +307,7 @@ mod native {
|
||||
/// files of stopped apps that have no files left. Only an autosave file is deleted: a path
|
||||
/// whose name doesn't parse as one ([`parse_file_name`]) is left alone.
|
||||
pub fn discard(path: &str) {
|
||||
let ours = Path::new(path).file_name().and_then(|n| n.to_str()).is_some_and(|n| parse_file_name(n).is_some());
|
||||
let ours = Path::new(path).file_name().and_then(std::ffi::OsStr::to_str).is_some_and(|n| parse_file_name(n).is_some());
|
||||
if !ours {
|
||||
log::warn!("autosave: not deleting {path}: not an autosave file");
|
||||
return;
|
||||
@@ -334,10 +339,22 @@ mod native {
|
||||
|
||||
type WriteHook = fn(&Drawing, &str) -> std::result::Result<Vec<u8>, String>;
|
||||
|
||||
/// Create the autosave folder `dir` and any missing parents. On Unix the folders made here are
|
||||
/// owner-only (`0o700`): autosave files are unsaved drawings, and with the default mode other
|
||||
/// accounts on the machine could read them. A folder that already exists keeps its mode, since
|
||||
/// SAVEFILEPATH can be any folder the user picked.
|
||||
fn create_folder(dir: &Path) -> std::io::Result<()> {
|
||||
let mut builder = std::fs::DirBuilder::new();
|
||||
builder.recursive(true);
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700);
|
||||
builder.create(dir)
|
||||
}
|
||||
|
||||
fn write_file(write: WriteHook, doc: &Drawing, path: &str) -> std::result::Result<(), String> {
|
||||
let bytes = write(doc, "autosave.dxf")?;
|
||||
if let Some(dir) = Path::new(path).parent() {
|
||||
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
|
||||
create_folder(dir).map_err(|e| e.to_string())?;
|
||||
}
|
||||
// Create a fresh temporary file exclusively and rename it into place, so a planted
|
||||
// `<path>.tmp` symlink in a shared autosave directory cannot redirect the write (CWE-59).
|
||||
@@ -434,7 +451,7 @@ mod native {
|
||||
}
|
||||
self.last_beat = Some(now);
|
||||
let dir = Path::new(dir);
|
||||
let r = std::fs::create_dir_all(dir).and_then(|()| std::fs::write(lock_path(dir, &self.instance), std::process::id().to_string()));
|
||||
let r = create_folder(dir).and_then(|()| std::fs::write(lock_path(dir, &self.instance), std::process::id().to_string()));
|
||||
if let Err(e) = r {
|
||||
log::warn!("autosave: lock file in {}: {e}", dir.display());
|
||||
}
|
||||
|
||||
@@ -195,12 +195,13 @@ impl DocState {
|
||||
self.block_edit.as_ref().is_some_and(|b| !Arc::ptr_eq(&b.saved, &self.doc))
|
||||
}
|
||||
/// The drawing to keep for recovery (autosave): with the Block Editor's unsaved changes in the
|
||||
/// block, so a crash doesn't lose them.
|
||||
pub fn recovery_drawing(&self) -> Arc<Drawing> {
|
||||
/// block, so a crash doesn't lose them. An error when the editor's changes can't be put in the
|
||||
/// block (it would contain itself): the host drawing alone would lose them.
|
||||
pub fn recovery_drawing(&self) -> Result<Arc<Drawing>> {
|
||||
match &self.block_edit {
|
||||
Some(be) if self.block_unsaved() => be.compose(&self.doc, &be.name).map(Arc::new).unwrap_or_else(|_| be.host.doc.clone()),
|
||||
Some(be) => be.host.doc.clone(),
|
||||
None => self.doc.clone(),
|
||||
Some(be) if self.block_unsaved() => be.compose(&self.doc, &be.name).map(Arc::new),
|
||||
Some(be) => Ok(be.host.doc.clone()),
|
||||
None => Ok(self.doc.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ pub mod plotters;
|
||||
mod props;
|
||||
pub use draw2::breakline::BreaklineOptions;
|
||||
pub use props::MatchProps;
|
||||
pub(crate) use props::{current_color_value, set_current_color_value};
|
||||
mod qselect;
|
||||
mod selecttools;
|
||||
mod settings;
|
||||
@@ -7,7 +7,8 @@ use cadcraft_doc::{AnnoScale, Drawing, EntityKind, Handle, MAX_SCALES, is_annota
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::machines::{SelOutcome, SelectPhase, SelectRun};
|
||||
use super::*;
|
||||
use super::{CommandSpec, bad, f64_req, targets};
|
||||
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
+17
-1705
File diff suppressed because it is too large
Load Diff
@@ -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<EntityKind> {
|
||||
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<Value> {
|
||||
if let Some(ls) = p.get("lines").and_then(Value::as_array) {
|
||||
let hs: Vec<Handle> = 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<Value> {
|
||||
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<Handle>,
|
||||
first: Option<(Vec2, Vec2)>,
|
||||
second: Option<(Vec2, Vec2)>,
|
||||
vertex_mode: Vec<Vec2>,
|
||||
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<Vec2>,
|
||||
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<Step> {
|
||||
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<EntityKind> {
|
||||
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<cadcraft_geom::Arc>,
|
||||
text: DimText,
|
||||
/// Partial: the points picked so far on the arc (`None` when not choosing a part).
|
||||
partial: Option<Vec<Vec2>>,
|
||||
}
|
||||
|
||||
/// 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<cadcraft_geom::Arc> {
|
||||
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<Step> {
|
||||
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<EntityKind> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -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<f64>, 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<Handle> {
|
||||
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<DimAssoc>) -> 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<Handle> {
|
||||
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<Handle> {
|
||||
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<Segment> {
|
||||
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<DimAsk>,
|
||||
}
|
||||
|
||||
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<Prompt> {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
use super::common::{hex_param, style};
|
||||
use super::*;
|
||||
|
||||
pub(super) fn run_update(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
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<String, Value> = 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::<Vec<_>>()));
|
||||
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<Handle> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
let base = hex_param(p, "base").ok_or_else(|| bad("dimspace", "`base` dimension is required"))?;
|
||||
let hs: Vec<Handle> = 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<Handle>,
|
||||
sel: SelectPhase,
|
||||
spacing_phase: bool,
|
||||
}
|
||||
|
||||
impl DimSpaceM {
|
||||
fn apply(&self, s: &mut Session, base: Handle, spacing: Option<f64>) -> Result<Step> {
|
||||
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::<Vec<_>>()));
|
||||
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<Step> {
|
||||
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<Step> {
|
||||
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<String, Value>,
|
||||
var: Option<String>,
|
||||
clear: bool,
|
||||
selecting: Option<crate::cmd::machines::SelectPhase>,
|
||||
}
|
||||
|
||||
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<Handle>) -> Result<Step> {
|
||||
let mut p = self.vars.clone();
|
||||
p.insert("handles".into(), json!(hs.iter().map(|h| h.hex()).collect::<Vec<_>>()));
|
||||
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<Step> {
|
||||
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::<f64>().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<EntityKind> {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
@@ -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<Value> {
|
||||
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<Value> {
|
||||
let a = point_req("dimaligned", p, "p1")?;
|
||||
let b = point_req("dimaligned", p, "p2")?;
|
||||
let at = point_req("dimaligned", p, "at")?;
|
||||
let k = dim(s, DimKind::Aligned, at, a, b, Vec2::ZERO, 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<Value> {
|
||||
let f = point_req("dimordinate", p, "feature")?;
|
||||
let l = point_req("dimordinate", p, "leader")?;
|
||||
let xtype = p.get("xtype").and_then(Value::as_bool).unwrap_or((l - f).y.abs() > (l - f).x.abs());
|
||||
let k = dim(s, DimKind::Ordinate { x_type: xtype }, Vec2::ZERO, f, l, Vec2::ZERO, "");
|
||||
Ok(json!({ "handle": add_dim(s, k)?.hex() }))
|
||||
}
|
||||
|
||||
/// The last linear/aligned dimension (for CONTINUE/BASELINE).
|
||||
fn last_linear(s: &Session) -> Option<Dimension> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
chain_run(s, p, false)
|
||||
}
|
||||
|
||||
pub(super) fn run_baseline(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Vec2>,
|
||||
rotation: Option<f64>,
|
||||
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<Step> {
|
||||
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<EntityKind> {
|
||||
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<Vec2>,
|
||||
/// Xdatum / Ydatum: the forced ordinate type (`true` = X datum).
|
||||
xtype: Option<bool>,
|
||||
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<Step> {
|
||||
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<EntityKind> {
|
||||
self.feature.map(|f| vec![self.kind(s, f, c)]).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct ChainM {
|
||||
baseline: bool,
|
||||
created: Vec<Handle>,
|
||||
}
|
||||
|
||||
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<Step> {
|
||||
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<Step> {
|
||||
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<EntityKind> {
|
||||
last_linear(s).map(|d| vec![chained(s, &d, c, self.baseline)]).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
@@ -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<EntityKind> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Vec2>,
|
||||
/// All leader points are placed.
|
||||
leader_done: bool,
|
||||
/// The leader text once entered (Content first enters it before the leader).
|
||||
text: Option<String>,
|
||||
opt: Option<LeaderOpt>,
|
||||
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<Vec2>) -> Vec<Vec2> {
|
||||
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<Step> {
|
||||
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<LeaderOpt> {
|
||||
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::<usize>() {
|
||||
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<Step> {
|
||||
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<EntityKind> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -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<Value> {
|
||||
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<Value> {
|
||||
radial(s, p, false)
|
||||
}
|
||||
|
||||
pub(super) fn run_diameter(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Step> {
|
||||
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<EntityKind> {
|
||||
self.target.map(|(ctr, r)| vec![self.kind(s, ctr, r, c)]).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
use cadcraft_doc::{AssocSnap, DimKind, Dimension, EntityKind, Handle};
|
||||
use cadcraft_doc::{DimKind, Dimension, EntityKind, Handle};
|
||||
use cadcraft_geom::Vec2;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -136,7 +136,9 @@ fn angular_between_lines_follows_rotation() {
|
||||
let b = line(&mut s, [0.0, 0.0], [0.0, 10.0]);
|
||||
let d = h(&s.execute("dimangular", &json!({ "lines": [a.hex(), b.hex()], "at": [3, 3] })).unwrap());
|
||||
let dm = dim(&s, d);
|
||||
assert!(dm.assoc.iter().any(|x| matches!(x.snap, AssocSnap::Intersection { .. })));
|
||||
// Two picked lines make a two-line angular dimension (type 2), tied to both lines at all four points.
|
||||
assert_eq!(dm.kind, DimKind::Angular);
|
||||
assert_eq!(dm.assoc.len(), 4);
|
||||
assert_eq!(cadcraft_render::dimension_in(s.doc().unwrap(), &dm).value, "90°");
|
||||
// Swing the second line to 45°.
|
||||
s.execute("properties.set", &json!({ "handles": [b.hex()], "end": [10, 10] })).unwrap();
|
||||
@@ -5,8 +5,11 @@ use cadcraft_doc::{DimBreak, DimKind, Dimension, EntityKind, Handle, Inspection,
|
||||
use cadcraft_geom::{Segment, Vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::machines::{SelOutcome, SelectPhase};
|
||||
use super::*;
|
||||
use super::super::helpers::v3;
|
||||
use super::super::machines::{SelOutcome, SelectPhase};
|
||||
use super::super::{CommandSpec, bad, bool_or, point_param, point_req, size_param, str_param, targets};
|
||||
use super::{DimText, add_dim, circle_of, dim, dims_of, pick_at};
|
||||
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
/// Most objects DIMBREAK Auto looks at, and most breaks it adds to one dimension.
|
||||
@@ -280,7 +283,7 @@ fn jogged_kind(s: &Session, c: Vec2, r: f64, center: Vec2, at: Vec2, jog: Option
|
||||
}
|
||||
let tip = c + dir * r;
|
||||
let jog = jog.unwrap_or_else(|| center.mid(tip));
|
||||
let mut k = dim(s, DimKind::Radius, c, Vec2::ZERO, Vec2::ZERO, tip, Vec2::ZERO, text);
|
||||
let mut k = dim(s, DimKind::Radius, c, Vec2::ZERO, Vec2::ZERO, tip, text);
|
||||
if let EntityKind::Dimension(dm) = &mut k {
|
||||
dm.marks.jogged = Some(JoggedRadius { center: v3(center), jog: v3(jog) });
|
||||
if at.dist(c) > r + 1e-9 {
|
||||
|
||||
@@ -6,8 +6,10 @@ use cadcraft_doc::{DimKind, EntityKind, Handle, Prim};
|
||||
use cadcraft_geom::{Segment, Vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::machines::{SelOutcome, SelectPhase};
|
||||
use super::*;
|
||||
use super::super::machines::{SelOutcome, SelectPhase};
|
||||
use super::super::{bad, point_param, point_req, points_param, str_param, targets};
|
||||
use super::{MAX_CHAIN, assoc_enabled, dim};
|
||||
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
pub(super) const PARAMS: &str = "{handles?, at: [x,y] (dimension line position), mode?: continuous|staggered|baseline|ordinate|radius|diameter (default continuous), vertical?: bool (default: from `at`), datum?: [x,y] (baseline and ordinate; default the first point, or the origin for ordinate), add?: [[x,y]], remove?: [[x,y]] (dimension points; removes those within `tolerance`)}";
|
||||
@@ -94,9 +96,9 @@ fn plan(
|
||||
let dir = if dir == Vec2::ZERO || !dir.is_finite() { Vec2::from_angle(std::f64::consts::FRAC_PI_4) } else { dir };
|
||||
let p = *c + dir * *r;
|
||||
out.push(if mode == Mode::Diameter {
|
||||
dim(s, DimKind::Diameter, *c - dir * *r, Vec2::ZERO, Vec2::ZERO, p, Vec2::ZERO, "")
|
||||
dim(s, DimKind::Diameter, *c - dir * *r, Vec2::ZERO, Vec2::ZERO, p, "")
|
||||
} else {
|
||||
dim(s, DimKind::Radius, *c, Vec2::ZERO, Vec2::ZERO, p, Vec2::ZERO, "")
|
||||
dim(s, DimKind::Radius, *c, Vec2::ZERO, Vec2::ZERO, p, "")
|
||||
});
|
||||
}
|
||||
if out.is_empty() {
|
||||
@@ -129,7 +131,7 @@ fn plan(
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let end = if vertical { Vec2::new(at.x, p.y) } else { Vec2::new(p.x, at.y) };
|
||||
dim(s, DimKind::Ordinate { x_type: !vertical }, origin, *p, end, Vec2::ZERO, Vec2::ZERO, "")
|
||||
dim(s, DimKind::Ordinate { x_type: !vertical }, origin, *p, end, Vec2::ZERO, "")
|
||||
})
|
||||
.collect());
|
||||
}
|
||||
@@ -145,8 +147,7 @@ fn plan(
|
||||
let mid = bounds.center();
|
||||
let out_dir =
|
||||
if vertical { Vec2::new(if at.x >= mid.x { 1.0 } else { -1.0 }, 0.0) } else { Vec2::new(0.0, if at.y >= mid.y { 1.0 } else { -1.0 }) };
|
||||
let lin =
|
||||
|a: Vec2, b: Vec2, k: usize| dim(s, DimKind::Linear { rotation: rot }, at + out_dir * (step * k as f64), a, b, Vec2::ZERO, Vec2::ZERO, "");
|
||||
let lin = |a: Vec2, b: Vec2, k: usize| dim(s, DimKind::Linear { rotation: rot }, at + out_dir * (step * k as f64), a, b, Vec2::ZERO, "");
|
||||
Ok(match mode {
|
||||
Mode::Baseline => {
|
||||
// From the datum (the point nearest it, by default the first) to every other point.
|
||||
|
||||
@@ -17,7 +17,8 @@ use serde_json::{Value, json};
|
||||
use super::helpers::{line, v3};
|
||||
use super::machines::{SelOutcome, SelectPhase, number};
|
||||
use super::modify::transform_entities;
|
||||
use super::*;
|
||||
use super::{CommandSpec, bad, bool_or, f64_or, find_command, point_param, point_req, str_param, targets};
|
||||
|
||||
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
mod edit;
|
||||
|
||||
@@ -16,7 +16,8 @@ use serde_json::{Value, json};
|
||||
|
||||
use super::super::helpers::v3;
|
||||
use super::super::machines::{SelOutcome, SelectPhase, number};
|
||||
use super::super::*;
|
||||
use super::super::{CommandSpec, bad, bool_or, point_param, targets};
|
||||
|
||||
use super::{KEYWORDS, angle, check_size, count, fmt, nesting, other, yes, yes_no};
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
|
||||
@@ -5,7 +5,9 @@ use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{CommandSpec, bad, bool_or, file, str_param};
|
||||
|
||||
use super::always;
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! Block Editor.
|
||||
|
||||
pub(crate) mod bedit;
|
||||
mod purge;
|
||||
pub(in crate::cmd) mod purge;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -12,7 +12,9 @@ use serde_json::{Value, json};
|
||||
|
||||
use super::helpers::v3;
|
||||
use super::machines::{SelOutcome, SelectPhase, number};
|
||||
use super::*;
|
||||
use super::{CommandSpec, bad, bool_or, f64_or, find_command, ok, point_param, point_req, point_value, str_param, targets};
|
||||
|
||||
use super::has_doc;
|
||||
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
mod attedit;
|
||||
@@ -74,27 +76,36 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
attribs::attdisp_spec(),
|
||||
attribs::attsync_spec(),
|
||||
burst::burst_spec(),
|
||||
blockreplace::blockreplace_spec(),
|
||||
extract::dataextraction_spec(),
|
||||
extract::eattext_spec(),
|
||||
]
|
||||
.into_iter()
|
||||
.chain(attribs::attdisp_menu_specs())
|
||||
.chain(xref::specs())
|
||||
.chain(refedit::specs())
|
||||
.chain(bedit::specs())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn valid_block_name(n: &str) -> bool {
|
||||
pub(crate) fn valid_block_name(n: &str) -> bool {
|
||||
!n.trim().is_empty() && n.len() <= 255 && !n.starts_with('*') && !n.chars().any(|c| "<>/\\\":;?*|,=`".contains(c))
|
||||
}
|
||||
|
||||
/// The block an entity references, when it is a block reference.
|
||||
/// The block an entity draws, if any: a block reference's block, a dimension's block or a
|
||||
/// multileader's block content.
|
||||
fn referenced_block(e: &Entity) -> Option<String> {
|
||||
if let EntityKind::Insert(i) = &e.kind { Some(i.block.clone()) } else { None }
|
||||
match &e.kind {
|
||||
EntityKind::Insert(i) => Some(i.block.clone()),
|
||||
EntityKind::Dimension(dm) => dm.block.clone(),
|
||||
EntityKind::MLeader(m) => m.block.as_ref().map(|b| b.block.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether any of `ents` refers to block `name`, directly or through nested block references.
|
||||
fn references_block(d: &cadcraft_doc::Drawing, ents: &[Entity], name: &str) -> bool {
|
||||
/// Whether any of `ents` refers to block `name`, directly or through nested block references,
|
||||
/// dimension blocks and multileader block content.
|
||||
pub(crate) fn references_block(d: &cadcraft_doc::Drawing, ents: &[Entity], name: &str) -> bool {
|
||||
let mut todo: Vec<String> = ents.iter().filter_map(referenced_block).collect();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
while let Some(b) = todo.pop() {
|
||||
@@ -878,3 +889,4 @@ impl Interactive for AttdefM {
|
||||
|
||||
mod attribs;
|
||||
mod battman;
|
||||
mod blockreplace;
|
||||
|
||||
@@ -16,6 +16,22 @@ pub(super) fn attdisp_spec() -> CommandSpec {
|
||||
CommandSpec::new("attdisp", "Attribute Display", run_attdisp)
|
||||
.params("{mode: \"normal\" | \"on\" | \"off\"} (sets ATTMODE to 1 / 2 / 0) → {attmode}")
|
||||
.interactive(|_| Ok(Box::new(AttdispM)))
|
||||
.transparent()
|
||||
}
|
||||
|
||||
/// Returns the View > Attribute Display > Normal, On and Off commands, which set ATTMODE as the matching ATTDISP option does.
|
||||
pub(super) fn attdisp_menu_specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("attdisp.normal", "Normal", |s, _| set_attmode(s, 1))
|
||||
.menu(&["View", "Attribute Display", "Normal"])
|
||||
.params("{} (ATTDISP Normal: ATTMODE 1) → {attmode}"),
|
||||
CommandSpec::new("attdisp.on", "On", |s, _| set_attmode(s, 2))
|
||||
.menu(&["View", "Attribute Display", "On"])
|
||||
.params("{} (ATTDISP ON: ATTMODE 2) → {attmode}"),
|
||||
CommandSpec::new("attdisp.off", "Off", |s, _| set_attmode(s, 0))
|
||||
.menu(&["View", "Attribute Display", "Off"])
|
||||
.params("{} (ATTDISP OFF: ATTMODE 0) → {attmode}"),
|
||||
]
|
||||
}
|
||||
|
||||
pub(super) fn attsync_spec() -> CommandSpec {
|
||||
@@ -84,7 +100,7 @@ impl Interactive for AttdispM {
|
||||
/// The attributes `ins` gets from the definitions `defs` of its block `blk`: placed, styled and
|
||||
/// shown as defined, keeping the values of attributes whose tag (any case) is still defined.
|
||||
/// Properties set on single attributes (layer, colour, …) go back to the definitions', as in AutoCAD.
|
||||
fn synced(blk: &Block, defs: &[&Attrib], ins: &Insert) -> Vec<Attrib> {
|
||||
pub(super) fn synced(blk: &Block, defs: &[&Attrib], ins: &Insert) -> Vec<Attrib> {
|
||||
let m = ins.transform(blk.base.xy());
|
||||
defs.iter()
|
||||
.map(|ad| {
|
||||
@@ -119,12 +135,16 @@ fn references<'a>(ents: impl Iterator<Item = &'a Arc<Entity>>, name: &str) -> Ve
|
||||
/// Apply `f` to every reference of block `name` (in model space, layouts and other blocks);
|
||||
/// returns how many references there were.
|
||||
fn update_references(d: &mut cadcraft_doc::Drawing, name: &str, mut f: impl FnMut(&mut Insert)) -> usize {
|
||||
let mut n = 0;
|
||||
let mut update = |e: &mut Entity| {
|
||||
update_reference_entities(d, name, |e| {
|
||||
if let EntityKind::Insert(ins) = &mut e.kind {
|
||||
f(ins);
|
||||
}
|
||||
};
|
||||
})
|
||||
}
|
||||
|
||||
/// [`update_references`], with `f` given the whole object.
|
||||
pub(super) fn update_reference_entities(d: &mut cadcraft_doc::Drawing, name: &str, mut update: impl FnMut(&mut Entity)) -> usize {
|
||||
let mut n = 0;
|
||||
let spaces: Vec<cadcraft_doc::Space> =
|
||||
std::iter::once(cadcraft_doc::Space::Model).chain(d.layouts.iter().map(|l| cadcraft_doc::Space::Paper(l.name.clone()))).collect();
|
||||
for sp in spaces {
|
||||
@@ -169,18 +189,22 @@ pub(super) fn rename_tag(s: &mut Session, name: &str, old: &str, new: &str) -> R
|
||||
}))
|
||||
}
|
||||
|
||||
/// Synchronize every reference of block `name` (in model space, layouts and other blocks).
|
||||
/// Returns the block's name as defined and how many references were updated.
|
||||
pub(super) fn sync(s: &mut Session, name: &str) -> Result<(String, usize)> {
|
||||
let blk = s.doc()?.block(name).cloned().ok_or_else(|| EngineError::Other(format!("Block \"{name}\" not found.")))?;
|
||||
let defs: Vec<&Attrib> = blk
|
||||
.entities
|
||||
/// The attribute definitions of `blk` that references carry (constant ones are not).
|
||||
pub(super) fn attdefs(blk: &Block) -> Vec<&Attrib> {
|
||||
blk.entities
|
||||
.iter()
|
||||
.filter_map(|e| match &e.kind {
|
||||
EntityKind::AttDef(a) if !a.constant => Some(a),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Synchronize every reference of block `name` (in model space, layouts and other blocks).
|
||||
/// Returns the block's name as defined and how many references were updated.
|
||||
pub(super) fn sync(s: &mut Session, name: &str) -> Result<(String, usize)> {
|
||||
let blk = s.doc()?.block(name).cloned().ok_or_else(|| EngineError::Other(format!("Block \"{name}\" not found.")))?;
|
||||
let defs = attdefs(&blk);
|
||||
let d = s.doc_mut()?;
|
||||
let n = update_references(d, &blk.name, |ins| ins.attribs = synced(&blk, &defs, ins));
|
||||
Ok((blk.name.clone(), n))
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
//! BLOCKREPLACE and -BLOCKREPLACE (Express Tools): every reference of one block, in model space,
|
||||
//! the layouts and other blocks, becomes a reference of another block at the same insertion
|
||||
//! point, scale and rotation. The unreferenced block definition can then be purged.
|
||||
|
||||
use cadcraft_doc::{Drawing, Entity, EntityKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::attribs::{attdefs, synced, update_reference_entities};
|
||||
use super::purge::{override_arrows, used_blocks};
|
||||
use super::references_block;
|
||||
use crate::cmd::curves::KW;
|
||||
use crate::cmd::machines::{SelOutcome, SelectPhase};
|
||||
use crate::cmd::{CommandSpec, bad, str_param};
|
||||
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
pub(super) fn blockreplace_spec() -> CommandSpec {
|
||||
CommandSpec::new("blockreplace", "Replace Block...", run_blockreplace)
|
||||
.menu(&["Tools", "Express Tools", "Replace Block..."])
|
||||
.alias(&["-blockreplace"])
|
||||
.params("{block (the block whose references are replaced), with (the replacement block), purge?: bool (default false: remove the replaced block's definition when nothing references it any more)} → {block, with, replaced, purged}")
|
||||
.interactive(|_| Ok(Box::new(BlockreplaceM::default())))
|
||||
}
|
||||
|
||||
/// The defined name of block `name` (names are case-insensitive), when it is a named block of
|
||||
/// this drawing: not anonymous and not one an xref brings in (`X|A`), which is never saved.
|
||||
fn named_block(d: &Drawing, name: &str) -> Option<String> {
|
||||
d.block(name.trim()).filter(|b| !b.anonymous && !b.name.starts_with('*') && !b.name.contains('|')).map(|b| b.name.clone())
|
||||
}
|
||||
|
||||
/// Why the references of block `old` cannot be replaced: a dynamic block's references keep their
|
||||
/// property values in data that names the block.
|
||||
fn not_replaceable(d: &Drawing, old: &str) -> Option<String> {
|
||||
cadcraft_doc::dynblock::dynamic_block(d, old).map(|_| format!("Block \"{old}\" is a dynamic block; its references cannot be replaced."))
|
||||
}
|
||||
|
||||
/// Why block `new` cannot replace block `old`: references of `old` inside `new` would make `new`
|
||||
/// reference itself.
|
||||
fn cannot_replace_with(d: &Drawing, old: &str, new: &str) -> Option<String> {
|
||||
let blk = d.block(new)?;
|
||||
references_block(d, &blk.entities.iter().map(|e| (**e).clone()).collect::<Vec<_>>(), old)
|
||||
.then(|| format!("Block \"{new}\" references \"{old}\", so it cannot replace it."))
|
||||
}
|
||||
|
||||
/// Replace every reference of `old` by a reference of `new`; with `purge`, remove `old`'s
|
||||
/// definition when nothing references it afterwards. Returns (references replaced, purged).
|
||||
fn replace(s: &mut Session, old: &str, new: &str, purge: bool) -> Result<(usize, bool)> {
|
||||
let cmd = "blockreplace";
|
||||
if s.state()?.block_edit.is_some() {
|
||||
return Err(bad(cmd, "the Block Editor is open; close it with BCLOSE first"));
|
||||
}
|
||||
let d = s.doc()?;
|
||||
if d.refedit.is_some() {
|
||||
return Err(bad(cmd, "a block is being edited in place; finish with REFCLOSE first"));
|
||||
}
|
||||
if let Some(n) = [old, new].into_iter().find(|n| n.contains('|')) {
|
||||
return Err(bad(cmd, format!("Block \"{}\" comes from an xref; bind the xref first.", n.trim())));
|
||||
}
|
||||
let old = named_block(d, old).ok_or_else(|| bad(cmd, format!("Block \"{}\" not found.", old.trim())))?;
|
||||
let new = named_block(d, new).ok_or_else(|| bad(cmd, format!("Block \"{}\" not found.", new.trim())))?;
|
||||
if old.eq_ignore_ascii_case(&new) {
|
||||
return Err(bad(cmd, "the replacement block must be a different block"));
|
||||
}
|
||||
if let Some(why) = not_replaceable(d, &old).or_else(|| cannot_replace_with(d, &old, &new)) {
|
||||
return Err(bad(cmd, why));
|
||||
}
|
||||
let blk = d.block(&new).cloned().ok_or_else(|| bad(cmd, format!("Block \"{new}\" not found.")))?;
|
||||
let defs = attdefs(&blk);
|
||||
let scale = d.current_anno_scale();
|
||||
let d = s.doc_mut()?;
|
||||
let n = update_reference_entities(d, &old, |e| {
|
||||
if let EntityKind::Insert(ins) = &mut e.kind {
|
||||
ins.block.clone_from(&blk.name);
|
||||
ins.attribs = synced(&blk, &defs, ins);
|
||||
}
|
||||
// A reference of an annotative block is annotative, as INSERT makes it.
|
||||
if !blk.annotative {
|
||||
e.common.anno_scales.clear();
|
||||
} else if e.common.anno_scales.is_empty() {
|
||||
e.common.anno_scales = vec![scale.clone()];
|
||||
}
|
||||
});
|
||||
let key = d.blocks.keys().find(|k| k.eq_ignore_ascii_case(&old)).cloned();
|
||||
let purged = purge && !referenced(d, &old) && key.is_some_and(|k| d.blocks.remove(&k).is_some());
|
||||
Ok((n, purged))
|
||||
}
|
||||
|
||||
/// Whether anything still names block `name`: what PURGE counts as in use, and the dimensions,
|
||||
/// multileaders and block references of every other block definition, used or not.
|
||||
fn referenced(d: &Drawing, name: &str) -> bool {
|
||||
if used_blocks(d).contains(&name.to_ascii_uppercase()) {
|
||||
return true;
|
||||
}
|
||||
let names = |e: &Entity| -> Vec<String> {
|
||||
match &e.kind {
|
||||
EntityKind::Insert(i) => vec![i.block.clone()],
|
||||
EntityKind::Dimension(dm) => dm.block.iter().cloned().chain(override_arrows(dm)).collect(),
|
||||
EntityKind::MLeader(m) => m.block.iter().map(|b| b.block.clone()).collect(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
};
|
||||
d.blocks
|
||||
.values()
|
||||
.filter(|b| !b.name.eq_ignore_ascii_case(name))
|
||||
.flat_map(|b| b.entities.iter())
|
||||
.any(|e| names(e).iter().any(|n| n.trim().eq_ignore_ascii_case(name)))
|
||||
}
|
||||
|
||||
fn run_blockreplace(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "blockreplace";
|
||||
let old = str_param(p, "block").ok_or_else(|| bad(cmd, "`block` (the block to replace) is required"))?;
|
||||
let new = str_param(p, "with").ok_or_else(|| bad(cmd, "`with` (the replacement block) is required"))?;
|
||||
let purge = match p.get("purge") {
|
||||
None | Some(Value::Null) => false,
|
||||
Some(Value::Bool(b)) => *b,
|
||||
Some(v) => return Err(bad(cmd, format!("`purge` must be true or false, not {v}"))),
|
||||
};
|
||||
let (old, new) = (old.to_string(), new.to_string());
|
||||
let (n, purged) = replace(s, &old, &new, purge)?;
|
||||
let d = s.doc()?;
|
||||
let defined = |name: &str| named_block(d, name).unwrap_or_else(|| name.trim().to_string());
|
||||
Ok(json!({ "block": defined(&old), "with": defined(&new), "replaced": n, "purged": purged }))
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
enum Stage {
|
||||
#[default]
|
||||
Old,
|
||||
New,
|
||||
Select,
|
||||
Purge,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct BlockreplaceM {
|
||||
stage: Stage,
|
||||
/// The stage a picked reference answers.
|
||||
back: Stage,
|
||||
sel: SelectPhase,
|
||||
old: String,
|
||||
new: String,
|
||||
}
|
||||
|
||||
impl BlockreplaceM {
|
||||
/// Take `name` as the answer to the current name prompt. A block that cannot take part is
|
||||
/// refused here, so the prompt asks again.
|
||||
fn take(&mut self, s: &Session, stage: Stage, name: &str) -> Result<()> {
|
||||
let d = s.doc()?;
|
||||
let name = named_block(d, name).ok_or_else(|| EngineError::Other(format!("Block \"{}\" not found.", name.trim())))?;
|
||||
if stage == Stage::Old {
|
||||
if let Some(why) = not_replaceable(d, &name) {
|
||||
return Err(EngineError::Other(why));
|
||||
}
|
||||
self.old = name;
|
||||
self.stage = Stage::New;
|
||||
} else {
|
||||
if name.eq_ignore_ascii_case(&self.old) {
|
||||
return Err(EngineError::Other("The replacement block must be a different block.".into()));
|
||||
}
|
||||
if let Some(why) = cannot_replace_with(d, &self.old, &name) {
|
||||
return Err(EngineError::Other(why));
|
||||
}
|
||||
self.new = name;
|
||||
self.stage = Stage::Purge;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn finish(&mut self, s: &mut Session, purge: bool) -> Result<Step> {
|
||||
let (n, purged) = replace(s, &self.old, &self.new, purge)?;
|
||||
s.echo(format!(
|
||||
"{n} reference{} of block {} replaced with {}.{}",
|
||||
if n == 1 { "" } else { "s" },
|
||||
self.old,
|
||||
self.new,
|
||||
if purged { format!(" Block {} purged.", self.old) } else { String::new() }
|
||||
));
|
||||
Ok(Step::Done)
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for BlockreplaceM {
|
||||
fn name(&self) -> &'static str {
|
||||
"BLOCKREPLACE"
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
match self.stage {
|
||||
Stage::Old => Prompt::new("Enter the name of the block to replace", Accept::TEXT).kw(&["?", "Select"]),
|
||||
Stage::New => Prompt::new(format!("Enter the name of the replacement block for {}", self.old), Accept::TEXT).kw(&["?", "Select"]),
|
||||
Stage::Select => Prompt::new("Select a block reference", Accept::SELECT),
|
||||
Stage::Purge => Prompt::new(format!("Purge the unreferenced block definition {}?", self.old), KW).kw(&["Yes", "No"]).default("Yes"),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
match self.stage {
|
||||
stage @ (Stage::Old | Stage::New) => match &i {
|
||||
Input::Keyword(k) if k == "?" => {
|
||||
let d = s.doc()?;
|
||||
let mut names: Vec<String> = d.blocks.keys().filter_map(|k| named_block(d, k)).collect();
|
||||
names.sort_by_key(|n| n.to_ascii_lowercase());
|
||||
s.echo(if names.is_empty() { "No blocks defined.".to_string() } else { format!("Blocks: {}", names.join(", ")) });
|
||||
}
|
||||
Input::Keyword(k) if k == "Select" => {
|
||||
self.back = stage;
|
||||
self.sel = SelectPhase::single();
|
||||
self.stage = Stage::Select;
|
||||
}
|
||||
Input::Text(t) if !t.trim().is_empty() => self.take(s, stage, t)?,
|
||||
Input::Enter | Input::Cancel => return Ok(Step::Done),
|
||||
_ => {}
|
||||
},
|
||||
Stage::Select => match self.sel.feed(s, &i)? {
|
||||
SelOutcome::More => {}
|
||||
SelOutcome::Empty => self.stage = self.back,
|
||||
SelOutcome::Done(hs) => {
|
||||
s.set_selection(Vec::new());
|
||||
self.sel = SelectPhase::single();
|
||||
let block = hs.iter().find_map(|h| match s.doc().ok()?.entity(*h).map(|e| &e.kind) {
|
||||
Some(EntityKind::Insert(ins)) => Some(ins.block.clone()),
|
||||
_ => None,
|
||||
});
|
||||
match block {
|
||||
Some(b) => self.take(s, self.back, &b)?,
|
||||
None => return Err(EngineError::Other("That object is not a block reference.".into())),
|
||||
}
|
||||
}
|
||||
},
|
||||
Stage::Purge => match &i {
|
||||
Input::Keyword(k) if k == "No" => return self.finish(s, false),
|
||||
Input::Keyword(_) | Input::Enter => return self.finish(s, true),
|
||||
Input::Cancel => return Ok(Step::Done),
|
||||
_ => return Err(EngineError::Other("Invalid option keyword.".into())),
|
||||
},
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
}
|
||||
@@ -149,6 +149,7 @@ fn merge_tables(d: &mut Drawing, src: &Drawing) {
|
||||
merge(&mut d.dim_styles, &src.dim_styles, |t| &t.name);
|
||||
merge(&mut d.mleader_styles, &src.mleader_styles, |t| &t.name);
|
||||
merge(&mut d.table_styles, &src.table_styles, |t| &t.name);
|
||||
merge(&mut d.mline_styles, &src.mline_styles, |t| &t.name);
|
||||
}
|
||||
|
||||
/// Which source blocks to copy, and under which name: new names keep theirs, generated
|
||||
@@ -255,4 +256,17 @@ mod tests {
|
||||
assert_eq!(file_ref(&s, "C:\\parts\\bolt.dxf").map(|f| f.name), Some("bolt".into()));
|
||||
assert!(file_ref(&s, "BOLT").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inserted_file_brings_its_mline_styles() {
|
||||
let mut src = Drawing::default();
|
||||
src.mline_styles.push(cadcraft_doc::MLineStyle { name: "WALL".into(), ..Default::default() });
|
||||
let mut d = Drawing::default();
|
||||
merge_tables(&mut d, &src);
|
||||
assert!(d.mline_styles.iter().any(|m| m.name == "WALL"));
|
||||
// A style this drawing already has keeps its own definition.
|
||||
let before = d.mline_styles.len();
|
||||
merge_tables(&mut d, &src);
|
||||
assert_eq!(d.mline_styles.len(), before);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,8 +13,9 @@ use super::super::qselect::wildcard;
|
||||
use super::super::{bad, find_command, str_param};
|
||||
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
/// The named objects PURGE can remove. `USAGE` runs the same scan.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Kind {
|
||||
pub(in crate::cmd) enum Kind {
|
||||
Blocks,
|
||||
Dimstyles,
|
||||
Groups,
|
||||
@@ -117,14 +118,35 @@ fn entities(d: &Drawing) -> impl Iterator<Item = &std::sync::Arc<cadcraft_doc::E
|
||||
d.model.iter().chain(d.layouts.iter().flat_map(|l| l.entities.iter())).chain(d.blocks.values().flat_map(|b| b.entities.iter())).take(5_000_000)
|
||||
}
|
||||
|
||||
/// Upper-case names of blocks reachable from model space, the layouts and dimensions.
|
||||
fn used_blocks(d: &Drawing) -> HashSet<String> {
|
||||
/// The arrowhead block names a dimension's overrides set.
|
||||
pub(in crate::cmd) fn override_arrows(dm: &cadcraft_doc::Dimension) -> impl Iterator<Item = String> + '_ {
|
||||
dm.overrides
|
||||
.iter()
|
||||
.filter(|(k, _)| cadcraft_doc::DimStyle::field_name(k).is_some_and(|f| f.starts_with("arrowBlock") || f == "leaderArrowBlock"))
|
||||
.filter_map(|(_, v)| v.as_str().map(|n| n.trim().to_string()))
|
||||
.filter(|n| !n.is_empty())
|
||||
}
|
||||
|
||||
/// Upper-case names of blocks reachable from model space, the layouts and dimensions, and the
|
||||
/// arrowhead blocks dimension styles and dimension overrides name.
|
||||
pub(in crate::cmd) fn used_blocks(d: &Drawing) -> HashSet<String> {
|
||||
let mut used = HashSet::new();
|
||||
let mut frontier: Vec<String> = Vec::new();
|
||||
for st in &d.dim_styles {
|
||||
frontier.extend(
|
||||
[&st.arrow_block, &st.arrow_block1, &st.arrow_block2, &st.leader_arrow_block]
|
||||
.into_iter()
|
||||
.map(|n| n.trim().to_string())
|
||||
.filter(|n| !n.is_empty()),
|
||||
);
|
||||
}
|
||||
for e in d.model.iter().chain(d.layouts.iter().flat_map(|l| l.entities.iter())) {
|
||||
match &e.kind {
|
||||
EntityKind::Insert(i) => frontier.push(i.block.clone()),
|
||||
EntityKind::Dimension(dm) => frontier.extend(dm.block.clone()),
|
||||
EntityKind::Dimension(dm) => {
|
||||
frontier.extend(dm.block.clone());
|
||||
frontier.extend(override_arrows(dm));
|
||||
}
|
||||
EntityKind::MLeader(m) => frontier.extend(m.block.as_ref().map(|b| b.block.clone())),
|
||||
_ => {}
|
||||
}
|
||||
@@ -139,7 +161,10 @@ fn used_blocks(d: &Drawing) -> HashSet<String> {
|
||||
for e in b.entities.iter() {
|
||||
match &e.kind {
|
||||
EntityKind::Insert(i) => frontier.push(i.block.clone()),
|
||||
EntityKind::Dimension(dm) => frontier.extend(dm.block.clone()),
|
||||
EntityKind::Dimension(dm) => {
|
||||
frontier.extend(dm.block.clone());
|
||||
frontier.extend(override_arrows(dm));
|
||||
}
|
||||
EntityKind::MLeader(m) => frontier.extend(m.block.as_ref().map(|b| b.block.clone())),
|
||||
_ => {}
|
||||
}
|
||||
@@ -153,8 +178,9 @@ fn is(name: &str, list: &[&str]) -> bool {
|
||||
list.iter().any(|n| n.eq_ignore_ascii_case(name))
|
||||
}
|
||||
|
||||
/// Names of the unused objects of a named kind, in table order.
|
||||
fn candidates(d: &Drawing, kind: Kind) -> Vec<String> {
|
||||
/// Names of the unused objects of a named kind, in table order. This is PURGE's scan; `USAGE`
|
||||
/// reads it too, without purging anything.
|
||||
pub(in crate::cmd) fn candidates(d: &Drawing, kind: Kind) -> Vec<String> {
|
||||
let lower = |it: &mut dyn Iterator<Item = String>| it.map(|n| n.to_ascii_lowercase()).collect::<HashSet<String>>();
|
||||
match kind {
|
||||
Kind::Blocks => {
|
||||
|
||||
@@ -420,6 +420,17 @@ fn import(d: &mut Drawing, key: &str, x: &Drawing, cx: &mut Ctx) -> Result<(), S
|
||||
nb.anonymous = b.anonymous;
|
||||
nb.explodable = b.explodable;
|
||||
nb.units = b.units;
|
||||
// Array parameters keep their kind and spacing; block names follow the block renaming, and
|
||||
// a source edit in progress in the referenced file is not carried over.
|
||||
nb.array = b.array.as_ref().map(|a| {
|
||||
let mut a = a.clone();
|
||||
a.source = dep(Sym::Block, &a.source).unwrap_or(a.source);
|
||||
for r in &mut a.replaced {
|
||||
r.block = dep(Sym::Block, &r.block).unwrap_or_else(|| r.block.clone());
|
||||
}
|
||||
a.editing = None;
|
||||
a
|
||||
});
|
||||
for e in b.entities.iter() {
|
||||
let e = adopt(d, e);
|
||||
nb.entities.push(e);
|
||||
@@ -532,4 +543,35 @@ mod tests {
|
||||
assert!(!has_prefix("SITE", "site|"));
|
||||
assert!(!has_prefix("é", "ab"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_array_blocks_keep_their_parameters() {
|
||||
use cadcraft_doc::{ArrayKind, ArrayParams, Block, RectArray, ReplacedItem};
|
||||
let rect = RectArray { cols: 3, rows: 2, col_spacing: 5.0, row_spacing: 4.0, axis_angle: 0.0, row_elevation: 0.0 };
|
||||
let params = ArrayParams {
|
||||
source: "*U1".into(),
|
||||
kind: ArrayKind::Rect(rect),
|
||||
levels: 1,
|
||||
level_spacing: 0.0,
|
||||
replaced: vec![ReplacedItem { i: 1, j: 0, level: 0, block: "ALT".into() }],
|
||||
editing: Some(Default::default()),
|
||||
};
|
||||
let mut arr = Block::new("*U2");
|
||||
arr.array = Some(params);
|
||||
let mut x = Drawing::default();
|
||||
x.blocks.insert("*U2".into(), Arc::new(arr));
|
||||
|
||||
let mut d = Drawing::default();
|
||||
d.blocks.insert("SITE".into(), Arc::new(Block::new("SITE")));
|
||||
let mut cx = Ctx { chain: Vec::new(), objects: 0, files: 0, bytes: 0, notes: Vec::new() };
|
||||
assert!(import(&mut d, "SITE", &x, &mut cx).is_ok());
|
||||
|
||||
let got = d.blocks.get("SITE|*U2").and_then(|b| b.array.clone());
|
||||
let Some(got) = got else { panic!("array parameters were dropped") };
|
||||
assert_eq!(got.source, "SITE|*U1");
|
||||
assert_eq!(got.replaced[0].block, "SITE|ALT");
|
||||
assert!(got.editing.is_none());
|
||||
let ArrayKind::Rect(r) = got.kind else { panic!("not a rectangular array") };
|
||||
assert_eq!((r.cols, r.rows), (3, 2));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,8 @@ use cadcraft_geom::{Line, Vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::machines::{SelOutcome, SelectPhase};
|
||||
use super::*;
|
||||
use super::{CommandSpec, bad, bool_or, f64_or, point_value, str_param, targets};
|
||||
|
||||
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
macro_rules! gc {
|
||||
@@ -327,7 +328,7 @@ fn add_constraint(s: &mut Session, c: Constraint) -> Result<u32> {
|
||||
let keep: Vec<Handle> = c.refs.first().map(|r| r.handle).into_iter().collect();
|
||||
let prefer_move: Vec<Handle> = c.refs.iter().skip(1).map(|r| r.handle).filter(|h| !keep.contains(h)).collect();
|
||||
let mut d = s.doc()?.clone();
|
||||
let id = cs::add(&mut d, c, &cs::SolveOptions { keep, prefer_move }).map_err(err)?;
|
||||
let id = cs::add(&mut d, c, &cs::SolveOptions { keep, prefer_move, ..Default::default() }).map_err(err)?;
|
||||
*s.doc_mut()? = d;
|
||||
Ok(id)
|
||||
}
|
||||
@@ -1119,7 +1120,7 @@ fn infer_after(s: &mut Session, before: &Drawing) {
|
||||
return;
|
||||
}
|
||||
let mut nd = d.clone();
|
||||
let opts = cs::SolveOptions { keep: others, prefer_move: placed };
|
||||
let opts = cs::SolveOptions { keep: others, prefer_move: placed, ..Default::default() };
|
||||
let added = cands.into_iter().take(200).filter(|c| cs::add(&mut nd, c.clone(), &opts).is_ok()).count();
|
||||
if added > 0
|
||||
&& let Ok(st) = s.state_mut()
|
||||
@@ -1148,7 +1149,8 @@ pub fn after_command(s: &mut Session, before: Option<&Arc<Drawing>>, ok: bool) {
|
||||
let mut d = (*st.doc).clone();
|
||||
let purged = cs::purge_invalid(&mut d);
|
||||
let restored = cs::restore_fixed(&mut d, before);
|
||||
let satisfied = cs::all_satisfied(&d);
|
||||
let anchors = cs::moved_anchors(&d, before);
|
||||
let satisfied = anchors.is_empty() && cs::all_satisfied(&d);
|
||||
if purged.is_empty() && !restored && satisfied {
|
||||
return;
|
||||
}
|
||||
@@ -1167,9 +1169,25 @@ pub fn after_command(s: &mut Session, before: Option<&Arc<Drawing>>, ok: bool) {
|
||||
keep.push(h);
|
||||
}
|
||||
}
|
||||
let mut trial = d.clone();
|
||||
match cs::solve(&mut trial, &cs::SolveOptions { keep, prefer_move: Vec::new() }) {
|
||||
Ok(_) => d = trial,
|
||||
// A grip drag moved some of an object's points: they stay where they were put, and the
|
||||
// edited objects still move least. When that cannot be solved, the edited objects are
|
||||
// only kept as a whole, as for any other edit.
|
||||
let hold: Vec<GeomRef> = keep.iter().flat_map(|&h| cs::edited_points(&d, before, h)).collect();
|
||||
let mut solved = None;
|
||||
if !hold.is_empty() {
|
||||
let mut trial = d.clone();
|
||||
let opts = cs::SolveOptions { keep: keep.clone(), prefer_move: Vec::new(), hold, anchors: anchors.clone() };
|
||||
solved = cs::solve(&mut trial, &opts).ok().map(|_| trial);
|
||||
}
|
||||
let solved = match solved {
|
||||
Some(t) => Ok(t),
|
||||
None => {
|
||||
let mut trial = d.clone();
|
||||
cs::solve(&mut trial, &cs::SolveOptions { keep, anchors, ..Default::default() }).map(|_| trial)
|
||||
}
|
||||
};
|
||||
match solved {
|
||||
Ok(t) => d = t,
|
||||
Err(e) => msg.push(format!("Constraints could not be satisfied: {e}")),
|
||||
}
|
||||
}
|
||||
@@ -1182,5 +1200,4 @@ pub fn after_command(s: &mut Session, before: Option<&Arc<Drawing>>, ok: bool) {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "constraints_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
+27
-1529
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,178 @@
|
||||
use super::*;
|
||||
|
||||
pub(super) fn run_circle(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
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<Vec2>,
|
||||
diameter: bool,
|
||||
/// The `Ttr` option hands over to the tangent-circle machine.
|
||||
delegate: Option<Box<dyn Interactive>>,
|
||||
}
|
||||
|
||||
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<Step> {
|
||||
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<EntityKind> {
|
||||
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(())
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
use super::*;
|
||||
|
||||
pub(super) fn run_ellipse(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<dyn Interactive> {
|
||||
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<Vec2>,
|
||||
/// The `Rotation` option: the next value is the rotation around the major axis.
|
||||
rotation: bool,
|
||||
ellipse: Option<Ellipse>,
|
||||
/// The `Parameter` option: arc start/end values are ellipse parameters, not true angles.
|
||||
param: bool,
|
||||
start: Option<f64>,
|
||||
/// The Isocircle option (isometric snap style) in progress.
|
||||
iso: Option<crate::cmd::settings::iso::IsoCircle>,
|
||||
}
|
||||
|
||||
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<Ellipse> {
|
||||
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<Ellipse> {
|
||||
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<Step> {
|
||||
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<Step> {
|
||||
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<EntityKind> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
use super::*;
|
||||
|
||||
// ---------------- JSON forms ----------------
|
||||
|
||||
pub(super) fn run_line(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<PolyVertex> {
|
||||
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<Value> {
|
||||
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<PolyVertex> = 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<Vec2>,
|
||||
handles: Vec<cadcraft_doc::Handle>,
|
||||
/// A first point picked as a deferred tangent/perpendicular, resolved by the next point.
|
||||
first: Option<snap::Deferred>,
|
||||
}
|
||||
|
||||
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<Step> {
|
||||
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<Step> {
|
||||
if self.pts.is_empty()
|
||||
&& let Some(first) = self.first
|
||||
{
|
||||
match i {
|
||||
Input::Point(p) => {
|
||||
// A deferred TAN first point with a typed `@d<a` next point: resolve
|
||||
// exactly from the direction and distance the user gave.
|
||||
if let Some((dist, ang)) = s.last_polar.take()
|
||||
&& let Some(t) = snap::tangent_point_in_direction(&first.curve, ang, first.at)
|
||||
{
|
||||
let e = t + Vec2::new(ang.cos(), ang.sin()) * dist;
|
||||
self.handles.push(s.add_entity(line(t, e))?);
|
||||
self.pts = vec![t, e];
|
||||
s.last_point = e;
|
||||
self.first = None;
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
return self.resolve_first(s, first, snap::LineEnd::Point(p));
|
||||
}
|
||||
Input::Deferred(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<EntityKind> {
|
||||
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<PolyVertex>,
|
||||
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<cadcraft_doc::Handle>,
|
||||
/// 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<PlineAsk>,
|
||||
}
|
||||
|
||||
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<Step> {
|
||||
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<EntityKind> {
|
||||
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)]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
use super::*;
|
||||
|
||||
pub(super) fn run_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Step> {
|
||||
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<Step> {
|
||||
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<Vec2>,
|
||||
}
|
||||
|
||||
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<Step> {
|
||||
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<EntityKind> {
|
||||
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<f64>,
|
||||
outside: Option<f64>,
|
||||
}
|
||||
|
||||
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<Step> {
|
||||
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<EntityKind> {
|
||||
match (self.inside, self.outside) {
|
||||
(Some(a), Some(b)) => vec![donut(c, a, b)],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
use super::*;
|
||||
|
||||
pub(super) fn run_polygon(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// 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<usize>,
|
||||
center: Option<Vec2>,
|
||||
edge: bool,
|
||||
edge_pts: Vec<Vec2>,
|
||||
inscribed: Option<bool>,
|
||||
}
|
||||
|
||||
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<Step> {
|
||||
if self.sides.is_none() {
|
||||
let n = match &i {
|
||||
Input::Text(t) => t
|
||||
.trim()
|
||||
.parse::<usize>()
|
||||
.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<EntityKind> {
|
||||
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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
use super::*;
|
||||
|
||||
pub(super) fn run_spline(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Vec2>,
|
||||
asking_method: bool,
|
||||
/// `Method` > `CV` hands over to the control-vertex machine (`spline.cv`).
|
||||
delegate: Option<Box<dyn Interactive>>,
|
||||
/// 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<crate::cmd::spline_opts::SplineAsk>,
|
||||
}
|
||||
|
||||
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<Step> {
|
||||
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<Step> {
|
||||
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<EntityKind> {
|
||||
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))]
|
||||
}
|
||||
}
|
||||
@@ -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<Value> {
|
||||
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<Value> {
|
||||
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<Vec2>,
|
||||
height: Option<f64>,
|
||||
rotation: Option<f64>,
|
||||
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<Step> {
|
||||
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<Step> {
|
||||
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<Vec2>,
|
||||
second: Option<Vec2>,
|
||||
/// 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<String>,
|
||||
/// 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<crate::cmd::mtext_opts::MTextAsk>,
|
||||
}
|
||||
|
||||
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<Step> {
|
||||
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<Step> {
|
||||
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<MText> {
|
||||
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<EntityKind> {
|
||||
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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
+28
-2175
File diff suppressed because it is too large
Load Diff
@@ -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<Vec2> {
|
||||
if let Val::P(p) = self { Some(p) } else { None }
|
||||
}
|
||||
fn n(self) -> Option<f64> {
|
||||
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<EntityKind> {
|
||||
let p = |i: usize| v.get(i).and_then(|x| x.p());
|
||||
let n = |i: usize| v.get(i).and_then(|x| x.n());
|
||||
let a = match self {
|
||||
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<dyn Interactive> {
|
||||
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<dyn Interactive> {
|
||||
seq(ArcV::ThreeP)
|
||||
}
|
||||
|
||||
struct SeqM {
|
||||
v: ArcV,
|
||||
vals: Vec<Val>,
|
||||
/// 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<Val> {
|
||||
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<Vec2> {
|
||||
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<Step> {
|
||||
if let Some((st, dir)) = self.cont {
|
||||
// Ctrl flips the arc: negating the start tangent makes `arc_sed` pick the mirror
|
||||
// arc (same two points and center, opposite sweep). `ctrl_down` comes from the
|
||||
// UI's per-frame write on `Session`; the `Input` enum itself stays free of
|
||||
// modifiers so it remains UI-agnostic.
|
||||
let dir = if s.ctrl_down { -dir } else { dir };
|
||||
return match i {
|
||||
Input::Point(e) => {
|
||||
let a = curves::arc_sed(st, e, dir).ok_or_else(|| other("Invalid end point."))?;
|
||||
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<EntityKind> {
|
||||
if let Some((st, d)) = self.cont {
|
||||
let d = if s.ctrl_down { -d } else { d };
|
||||
return curves::arc_sed(st, c, d).map(|a| vec![arc(&a)]).unwrap_or_default();
|
||||
}
|
||||
let Some((_, ask)) = self.ask() else { return Vec::new() };
|
||||
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<EntityKind> = self.base().map(|b| vec![line(b, c)]).unwrap_or_default();
|
||||
if vals.len() == self.v.steps().len() {
|
||||
out.extend(self.v.build(&vals, s.ctrl_down));
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
fn direction_param(p: &Value) -> Option<f64> {
|
||||
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<EntityKind> {
|
||||
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<Value> {
|
||||
let k = arc_from_params(p)?;
|
||||
added(s.add_entity(k)?)
|
||||
}
|
||||
|
||||
pub(super) fn run_arc_continue(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Step> {
|
||||
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<Step> {
|
||||
let Some((st, dir)) = self.from else { return Ok(Step::Cancel) };
|
||||
// Ctrl flips the direction, exactly as in `SeqM::input`.
|
||||
let dir = if s.ctrl_down { -dir } else { dir };
|
||||
match i {
|
||||
Input::Point(e) => {
|
||||
let a = curves::arc_sed(st, e, dir).ok_or_else(|| other("Invalid end point."))?;
|
||||
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<EntityKind> {
|
||||
self.from
|
||||
.and_then(|(st, dir)| {
|
||||
let dir = if s.ctrl_down { -dir } else { dir };
|
||||
curves::arc_sed(st, c, dir)
|
||||
})
|
||||
.map(|a| vec![arc(&a)])
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
@@ -9,10 +9,11 @@ use cadcraft_doc::{Drawing, EntityKind};
|
||||
use cadcraft_geom::{PolyVertex, Vec2};
|
||||
use serde_json::Value;
|
||||
|
||||
use super::super::machines::number;
|
||||
use super::added;
|
||||
use super::curves::MAX_GEN;
|
||||
use super::machines::number;
|
||||
use super::*;
|
||||
use super::{CommandSpec, line, lwpoly, point_param, point_req, size_param, str_param};
|
||||
|
||||
use crate::cmd::modify2::as_lwpoly;
|
||||
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
|
||||
@@ -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<Handle> {
|
||||
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<Vec<String>> {
|
||||
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<Handle> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Handle>,
|
||||
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<Step> {
|
||||
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<Step> {
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
use super::*;
|
||||
|
||||
// =====================================================================================
|
||||
// 3DPOLY
|
||||
// =====================================================================================
|
||||
|
||||
fn point3_value(v: &Value) -> Option<Vec3> {
|
||||
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<Value> {
|
||||
let pts: Vec<Vec3> = 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<Vec2>,
|
||||
handle: Option<Handle>,
|
||||
}
|
||||
|
||||
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<Step> {
|
||||
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<EntityKind> {
|
||||
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<Value> {
|
||||
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<Vec2>,
|
||||
rb: Option<f64>,
|
||||
rt: Option<f64>,
|
||||
turns: Option<f64>,
|
||||
cw: bool,
|
||||
diameter: bool,
|
||||
asking: u8, // 0 main, 1 turns, 2 twist, 3 turn height
|
||||
start: Option<f64>,
|
||||
}
|
||||
|
||||
impl HelixM {
|
||||
fn turns(&self) -> f64 {
|
||||
self.turns.unwrap_or(3.0)
|
||||
}
|
||||
fn create(&self, s: &mut Session, height: f64) -> Result<Step> {
|
||||
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<Step> {
|
||||
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<Step> {
|
||||
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<Vec2>| -> Option<f64> {
|
||||
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<EntityKind> {
|
||||
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<Value> {
|
||||
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<Vec2>,
|
||||
degree: Option<usize>,
|
||||
asking_degree: bool,
|
||||
}
|
||||
|
||||
impl Interactive for SplineCvM {
|
||||
fn name(&self) -> &'static str {
|
||||
"SPLINE"
|
||||
}
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
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<Step> {
|
||||
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<EntityKind> {
|
||||
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)))]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
use super::*;
|
||||
|
||||
// =====================================================================================
|
||||
// DIVIDE / MEASURE
|
||||
// =====================================================================================
|
||||
|
||||
fn chain_of(s: &Session, h: Handle) -> Result<Chain> {
|
||||
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<Vec<String>> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<String>,
|
||||
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<Step> {
|
||||
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.")),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
use super::*;
|
||||
|
||||
// ---------------- ellipse entries ----------------
|
||||
|
||||
fn ellipse_from_axis(p1: Vec2, p2: Vec2, half: f64) -> Option<Ellipse> {
|
||||
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<Ellipse> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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))?)
|
||||
}
|
||||
@@ -9,9 +9,11 @@ use cadcraft_doc::{
|
||||
use cadcraft_geom::{Line, Vec2, line_line_infinite};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::super::machines::number;
|
||||
use super::super::{distinct_points, ok};
|
||||
use super::curves::{self, MAX_GEN};
|
||||
use super::machines::number;
|
||||
use super::*;
|
||||
use super::{CommandSpec, bad, bool_or, f64_or, point_req, points_param, str_param};
|
||||
|
||||
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
@@ -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<Value> {
|
||||
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<Vec2>,
|
||||
/// 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<RAsk>,
|
||||
}
|
||||
|
||||
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<Step> {
|
||||
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<Vec2> {
|
||||
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<Step> {
|
||||
if let Some(a) = self.asking {
|
||||
let val = |i: &Input, default: f64| -> Result<f64> {
|
||||
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<EntityKind> {
|
||||
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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Vec<PolyVertex>> {
|
||||
let mut pts: Vec<Vec2> = 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<Vec2> = 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<Vec<Vec2>> {
|
||||
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<PolyVertex>, 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<Value> {
|
||||
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<Vec2>,
|
||||
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<Vec2>) -> Vec<Vec2> {
|
||||
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<Step> {
|
||||
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<Step> {
|
||||
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<Step> {
|
||||
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<EntityKind> {
|
||||
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)]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
use super::*;
|
||||
|
||||
pub(super) fn run_circle_variant(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<TanObj> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Step> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -566,6 +566,7 @@ fn xline_options_command_line() {
|
||||
|
||||
// ---------------- RECTANG ----------------
|
||||
|
||||
/// The corners of the rectangle just added, so a test can assert its bounds.
|
||||
fn rect_pts(s: &Session) -> Vec<Vec2> {
|
||||
match last(s) {
|
||||
EntityKind::LwPolyline(p) => p.vertices.iter().map(|v| v.p).collect(),
|
||||
@@ -622,6 +623,111 @@ fn rectang_options_command_line() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Guards the JSON `center: true` form for every sizing route: a picked corner mirrored about
|
||||
/// the centre, full-size `dimensions`, full `area`, rotation about the centre, that the default
|
||||
/// corner mode is untouched, and that centre mode without a size is rejected.
|
||||
#[test]
|
||||
fn rectang_center_mode_json() {
|
||||
let mut s = Session::new();
|
||||
// The first point is the centre; the second is a corner mirrored about it.
|
||||
s.execute("rectang", &json!({"center": true, "p1": [10, 10], "p2": [14, 16]})).unwrap();
|
||||
let b = cadcraft_geom::Bounds2::from_points(rect_pts(&s));
|
||||
assert!(near(b.min, 6.0, 4.0) && near(b.max, 14.0, 16.0), "{b:?}");
|
||||
// Dimensions are the rectangle's full size, still centred on the first point.
|
||||
s.execute("rectang", &json!({"center": true, "p1": [10, 10], "dimensions": [8, 12]})).unwrap();
|
||||
let b = cadcraft_geom::Bounds2::from_points(rect_pts(&s));
|
||||
assert!(near(b.min, 6.0, 4.0) && near(b.max, 14.0, 16.0), "{b:?}");
|
||||
// A second point only picks the quadrant.
|
||||
s.execute("rectang", &json!({"center": true, "p1": [10, 10], "dimensions": [8, 12], "p2": [0, 0]})).unwrap();
|
||||
let b = cadcraft_geom::Bounds2::from_points(rect_pts(&s));
|
||||
assert!(near(b.min, 6.0, 4.0) && near(b.max, 14.0, 16.0), "{b:?}");
|
||||
// The area is the full area, centred too.
|
||||
s.execute("rectang", &json!({"center": true, "p1": [10, 10], "area": 96, "length": 8})).unwrap();
|
||||
let b = cadcraft_geom::Bounds2::from_points(rect_pts(&s));
|
||||
assert!(near(b.min, 6.0, 4.0) && near(b.max, 14.0, 16.0), "{b:?}");
|
||||
// Rotation is about the centre.
|
||||
s.execute("rectang", &json!({"center": true, "p1": [10, 10], "dimensions": [8, 12], "rotation": 90})).unwrap();
|
||||
let b = cadcraft_geom::Bounds2::from_points(rect_pts(&s));
|
||||
assert!(near(b.min, 4.0, 6.0) && near(b.max, 16.0, 14.0), "{b:?}");
|
||||
// The default corner mode is unchanged.
|
||||
s.execute("rectang", &json!({"p1": [10, 10], "p2": [14, 16]})).unwrap();
|
||||
let b = cadcraft_geom::Bounds2::from_points(rect_pts(&s));
|
||||
assert!(near(b.min, 10.0, 10.0) && near(b.max, 14.0, 16.0), "{b:?}");
|
||||
// Centre mode still needs a size.
|
||||
assert!(s.execute("rectang", &json!({"center": true, "p1": [10, 10]})).is_err());
|
||||
}
|
||||
|
||||
/// Guards that fillet, chamfer, width and thickness still apply in centre mode, and that an
|
||||
/// oversized fillet falls back to sharp corners without moving the rectangle off centre.
|
||||
#[test]
|
||||
fn rectang_center_mode_honours_options() {
|
||||
let mut s = Session::new();
|
||||
s.execute("rectang", &json!({"center": true, "p1": [10, 10], "p2": [14, 16], "fillet": 1, "width": 0.25, "thickness": 2})).unwrap();
|
||||
match &s.doc().unwrap().model.last().unwrap().kind {
|
||||
EntityKind::LwPolyline(p) => assert!(p.vertices.len() == 8 && p.const_width == 0.25),
|
||||
k => panic!("{k:?}"),
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().model.last().unwrap().common.thickness, 2.0);
|
||||
let b = cadcraft_geom::Bounds2::from_points(rect_pts(&s));
|
||||
assert!(near(b.min, 6.0, 4.0) && near(b.max, 14.0, 16.0), "{b:?}");
|
||||
// Chamfer, still centred.
|
||||
s.execute("rectang", &json!({"center": true, "p1": [10, 10], "p2": [14, 16], "chamfer": [1, 1]})).unwrap();
|
||||
assert_eq!(rect_pts(&s).len(), 8);
|
||||
let b = cadcraft_geom::Bounds2::from_points(rect_pts(&s));
|
||||
assert!(near(b.min, 6.0, 4.0) && near(b.max, 14.0, 16.0), "{b:?}");
|
||||
// A fillet too large for the rectangle falls back to sharp corners, still centred.
|
||||
s.execute("rectang", &json!({"center": true, "p1": [10, 10], "p2": [14, 16], "fillet": 5})).unwrap();
|
||||
assert_eq!(rect_pts(&s).len(), 4);
|
||||
let b = cadcraft_geom::Bounds2::from_points(rect_pts(&s));
|
||||
assert!(near(b.min, 6.0, 4.0) && near(b.max, 14.0, 16.0), "{b:?}");
|
||||
}
|
||||
|
||||
/// Guards the `Center` keyword at the command line, before or after other options, with
|
||||
/// dimensions and area still centred, the prompt wording and centred rubber band, and that the
|
||||
/// default corner mode is unchanged.
|
||||
#[test]
|
||||
fn rectang_center_mode_command_line() {
|
||||
let mut s = Session::new();
|
||||
s.cmdline("rectang ce 10,10 14,16").unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
let b = cadcraft_geom::Bounds2::from_points(rect_pts(&s));
|
||||
assert!(near(b.min, 6.0, 4.0) && near(b.max, 14.0, 16.0), "{b:?}");
|
||||
// Options before the centre point, and the centre picked after them.
|
||||
s.cmdline("rectang center f 1 w 0.1 10,10 14,16").unwrap();
|
||||
match &s.doc().unwrap().model.last().unwrap().kind {
|
||||
EntityKind::LwPolyline(p) => assert!(p.vertices.len() == 8 && p.const_width == 0.1),
|
||||
k => panic!("{k:?}"),
|
||||
}
|
||||
let b = cadcraft_geom::Bounds2::from_points(rect_pts(&s));
|
||||
assert!(near(b.min, 6.0, 4.0) && near(b.max, 14.0, 16.0), "{b:?}");
|
||||
// Dimensions after the centre: the pick only chooses the quadrant.
|
||||
s.cmdline("rectang ce 10,10 d 8 12 14,16").unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
let b = cadcraft_geom::Bounds2::from_points(rect_pts(&s));
|
||||
assert!(near(b.min, 6.0, 4.0) && near(b.max, 14.0, 16.0), "{b:?}");
|
||||
// Area too: the rectangle of that area is centred on the first point.
|
||||
s.cmdline("rectang ce 10,10 a 96 l 8").unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
let b = cadcraft_geom::Bounds2::from_points(rect_pts(&s));
|
||||
assert!(near(b.min, 6.0, 4.0) && near(b.max, 14.0, 16.0), "{b:?}");
|
||||
// The rubber band is centred while the second point is picked.
|
||||
s.start("rectang").unwrap();
|
||||
s.cmdline("ce").unwrap();
|
||||
assert!(s.prompt_text().contains("Specify center point for rectangle"), "{}", s.prompt_text());
|
||||
s.cmdline("10,10").unwrap();
|
||||
let pv = s.preview(Vec2::new(14.0, 16.0));
|
||||
let pb = match pv.first().map(|e| &e.kind) {
|
||||
Some(EntityKind::LwPolyline(p)) => cadcraft_geom::Bounds2::from_points(p.vertices.iter().map(|v| v.p).collect::<Vec<_>>()),
|
||||
k => panic!("{k:?}"),
|
||||
};
|
||||
assert!(near(pb.min, 6.0, 4.0) && near(pb.max, 14.0, 16.0), "{pb:?}");
|
||||
s.cancel();
|
||||
// The default corner mode is unchanged.
|
||||
s.cmdline("rectang 10,10 14,16").unwrap();
|
||||
let b = cadcraft_geom::Bounds2::from_points(rect_pts(&s));
|
||||
assert!(near(b.min, 10.0, 10.0) && near(b.max, 14.0, 16.0), "{b:?}");
|
||||
}
|
||||
|
||||
// ---------------- 3DPOLY / MLINE / HELIX / SPLINE CV ----------------
|
||||
|
||||
#[test]
|
||||
@@ -0,0 +1,137 @@
|
||||
use super::*;
|
||||
|
||||
// =====================================================================================
|
||||
// WIPEOUT
|
||||
// =====================================================================================
|
||||
|
||||
fn set_frames(s: &mut Session, mode: &str) -> Result<i64> {
|
||||
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<Handle> {
|
||||
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<Value> {
|
||||
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<Vec2>,
|
||||
phase: u8, // 0 points, 1 select polyline, 2 erase?, 3 frames
|
||||
poly: Option<Handle>,
|
||||
}
|
||||
|
||||
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<Step> {
|
||||
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<EntityKind> {
|
||||
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)]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
use super::*;
|
||||
|
||||
// =====================================================================================
|
||||
// XLINE with Hor/Ver/Ang/Bisect/Offset
|
||||
// =====================================================================================
|
||||
|
||||
fn add_xline(s: &mut Session, base: Vec2, dir: Vec2) -> Result<Handle> {
|
||||
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<Vec2>) -> 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<Vec2> {
|
||||
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<Value> {
|
||||
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<Vec2>,
|
||||
bis: Vec<Vec2>,
|
||||
offset: Option<f64>, // 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<Step> {
|
||||
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<EntityKind> {
|
||||
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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,8 @@ use cadcraft_doc::{EntityKind, Handle};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::machines::{SelOutcome, SelectPhase, SelectRun};
|
||||
use super::*;
|
||||
use super::{CommandSpec, EngineError, bad, ok, targets};
|
||||
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
@@ -5,7 +5,8 @@ use cadcraft_geom::{Bounds2, Mat3, Vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::machines::{SelOutcome, SelectPhase, SelectRun};
|
||||
use super::*;
|
||||
use super::{CommandSpec, EngineError, bad, bool_or, clipboard, f64_or, has_selection, point_param, point_req, points_param, str_param, targets};
|
||||
|
||||
use crate::undoctl::Control;
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
@@ -26,6 +27,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.key("Cmd+X")
|
||||
.params("{handles?}")
|
||||
.interactive(|_| Ok(Box::new(SelectRun::new("cutclip", "CUTCLIP")))),
|
||||
CommandSpec::new("cutbase", "Cut with Base Point", run_cutbase)
|
||||
.menu(&["Edit", "Cut with Base Point"])
|
||||
.params("{handles?, base: [x,y]}")
|
||||
.interactive(|_| Ok(Box::new(CopyBaseM { cut: true, ..CopyBaseM::default() }))),
|
||||
CommandSpec::new("copyclip", "Copy", run_copyclip)
|
||||
.menu(&["Edit", "Copy"])
|
||||
.key("Cmd+C")
|
||||
@@ -434,9 +439,11 @@ impl Interactive for MredoM {
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy the objects of `hs` to the clipboard, each once however often `hs` lists it.
|
||||
fn copy_to_clip(s: &mut Session, hs: &[Handle], base: Option<Vec2>) -> Result<usize> {
|
||||
let d = s.doc()?;
|
||||
let ents: Vec<Entity> = hs.iter().filter_map(|h| d.entity(*h).map(|e| (**e).clone())).collect();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let ents: Vec<Entity> = hs.iter().filter(|h| seen.insert(**h)).filter_map(|h| d.entity(*h).map(|e| (**e).clone())).collect();
|
||||
let b = ents.iter().fold(Bounds2::EMPTY, |acc, e| acc.union(&entity_bounds(d, e, 0)));
|
||||
let base = base.unwrap_or(if b.min.is_finite() { b.min } else { Vec2::ZERO });
|
||||
let n = ents.len();
|
||||
@@ -451,22 +458,59 @@ fn run_copyclip(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn run_copybase(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let base = point_req("copybase", p, "base")?;
|
||||
let hs = targets(s, p)?;
|
||||
let base = point_param(p, "base");
|
||||
let n = copy_to_clip(s, &hs, base)?;
|
||||
let n = copy_to_clip(s, &hs, Some(base))?;
|
||||
Ok(json!({ "copied": n }))
|
||||
}
|
||||
|
||||
fn run_cut(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut hs = targets(s, p)?;
|
||||
hs.retain(|h| !super::curves::is_locked(s, *h));
|
||||
let n = copy_to_clip(s, &hs, None)?;
|
||||
/// Copy the objects that are not on locked layers to the clipboard and erase them. Returns how
|
||||
/// many were cut and the message to show: how many were on a locked layer, and that nothing was
|
||||
/// cut when that is all of them (the clipboard then keeps what it held).
|
||||
fn cut_to_clip(s: &mut Session, hs: &[Handle], base: Option<Vec2>) -> Result<(usize, Option<String>)> {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let live: Vec<Handle> = {
|
||||
let d = s.doc()?;
|
||||
hs.iter().copied().filter(|h| seen.insert(*h) && d.entity(*h).is_some()).collect()
|
||||
};
|
||||
let (hs, locked): (Vec<Handle>, Vec<Handle>) = live.into_iter().partition(|h| !super::curves::is_locked(s, *h));
|
||||
let note = match locked.len() {
|
||||
0 => None,
|
||||
1 => Some("1 object was on a locked layer.".to_string()),
|
||||
n => Some(format!("{n} objects were on a locked layer.")),
|
||||
};
|
||||
if hs.is_empty() {
|
||||
let msg = note.map_or_else(|| "Nothing was cut.".to_string(), |m| format!("{m} Nothing was cut."));
|
||||
return Ok((0, Some(msg)));
|
||||
}
|
||||
let n = copy_to_clip(s, &hs, base)?;
|
||||
let d = s.doc_mut()?;
|
||||
for h in &hs {
|
||||
d.remove_entity(*h);
|
||||
}
|
||||
s.set_selection(Vec::new());
|
||||
Ok(json!({ "cut": n }))
|
||||
Ok((n, note))
|
||||
}
|
||||
|
||||
fn cut_result(n: usize, msg: Option<String>) -> Value {
|
||||
let mut r = json!({ "cut": n });
|
||||
if let Some(m) = msg {
|
||||
r["message"] = json!(m);
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
fn run_cut(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let hs = targets(s, p)?;
|
||||
let (n, msg) = cut_to_clip(s, &hs, None)?;
|
||||
Ok(cut_result(n, msg))
|
||||
}
|
||||
|
||||
fn run_cutbase(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let base = point_req("cutbase", p, "base")?;
|
||||
let hs = targets(s, p)?;
|
||||
let (n, msg) = cut_to_clip(s, &hs, Some(base))?;
|
||||
Ok(cut_result(n, msg))
|
||||
}
|
||||
|
||||
/// Paste the clipboard moved by `offset` (the clipboard brings the layers, styles and blocks its
|
||||
@@ -649,23 +693,31 @@ impl Interactive for SelectM {
|
||||
}
|
||||
}
|
||||
|
||||
/// COPYBASE: base point first, then "Select objects:" (skipped with a pickfirst selection).
|
||||
/// COPYBASE and CUTBASE: base point first, then "Select objects:" (skipped with a pickfirst
|
||||
/// selection). CUTBASE also erases the objects it copies.
|
||||
#[derive(Default)]
|
||||
struct CopyBaseM {
|
||||
base: Option<Vec2>,
|
||||
sel: SelectPhase,
|
||||
cut: bool,
|
||||
}
|
||||
|
||||
impl CopyBaseM {
|
||||
fn copy(&self, s: &mut Session, hs: &[Handle]) -> Result<Step> {
|
||||
copy_to_clip(s, hs, self.base)?;
|
||||
if self.cut {
|
||||
if let (_, Some(m)) = cut_to_clip(s, hs, self.base)? {
|
||||
s.echo(m);
|
||||
}
|
||||
} else {
|
||||
copy_to_clip(s, hs, self.base)?;
|
||||
}
|
||||
Ok(Step::Done)
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for CopyBaseM {
|
||||
fn name(&self) -> &'static str {
|
||||
"COPYBASE"
|
||||
if self.cut { "CUTBASE" } else { "COPYBASE" }
|
||||
}
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
self.sel = SelectPhase::begin(s);
|
||||
|
||||
@@ -18,7 +18,8 @@ use cadcraft_render::raster::{RasterOptions, View};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::file::{base64_decode, base64_encode, io};
|
||||
use super::*;
|
||||
use super::{CommandSpec, bad, point_value, str_param};
|
||||
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
/// Formats EXPORT writes (the io layer picks the writer from the extension).
|
||||
@@ -85,11 +86,25 @@ fn write_file(cmd: &str, path: &str, bytes: &[u8]) -> Result<()> {
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let _ = (path, bytes);
|
||||
Err(bad(cmd, "paths are not available on the web; omit `path` to get the file as base64 `data`"))
|
||||
deliver_file(cmd, path, bytes, super::file::deliver())
|
||||
}
|
||||
}
|
||||
|
||||
/// No file system on the web: hand the bytes to the host's delivery hook (a browser download)
|
||||
/// named after `path`, like SAVE and TABLEEXPORT do. EXPORT and PLOT (PUBLISH, EXPORTPDF) use it.
|
||||
#[cfg(any(test, target_arch = "wasm32"))]
|
||||
pub(super) fn deliver_file(cmd: &str, path: &str, bytes: &[u8], hook: Option<super::file::DeliverHook>) -> Result<()> {
|
||||
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
|
||||
let mime = match extension(&name).as_str() {
|
||||
"svg" => "image/svg+xml",
|
||||
"png" => "image/png",
|
||||
"pdf" => "application/pdf",
|
||||
_ => "application/octet-stream",
|
||||
};
|
||||
let deliver = hook.ok_or_else(|| bad(cmd, "files can't be written in this build; omit `path` to get the file as base64 `data`"))?;
|
||||
deliver(&name, mime, bytes).map_err(|e| bad(cmd, format!("{name}: {e}")))
|
||||
}
|
||||
|
||||
// ---------- EXPORT ----------
|
||||
|
||||
fn run_export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -543,7 +558,27 @@ mod tests {
|
||||
use cadcraft_geom::Vec3;
|
||||
|
||||
use super::super::file::{IoHooks, set_io};
|
||||
|
||||
use super::super::resolve_alias;
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
static DELIVERED: std::sync::Mutex<Vec<(String, String, usize)>> = std::sync::Mutex::new(Vec::new());
|
||||
|
||||
fn record(name: &str, mime: &str, bytes: &[u8]) -> std::result::Result<(), String> {
|
||||
DELIVERED.lock().map_err(|e| e.to_string())?.push((name.into(), mime.into(), bytes.len()));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn web_export_delivers_a_download_instead_of_writing_a_path() {
|
||||
deliver_file("export", "Drawing1.svg", b"<svg/>", Some(record)).unwrap();
|
||||
deliver_file("export", "dir/Plan.pdf", b"%PDF", Some(record)).unwrap();
|
||||
let got = DELIVERED.lock().unwrap().clone();
|
||||
assert_eq!(got, [("Drawing1.svg".to_string(), "image/svg+xml".to_string(), 6), ("Plan.pdf".to_string(), "application/pdf".to_string(), 4)]);
|
||||
let e = deliver_file("export", "a.svg", b"x", None).unwrap_err().to_string();
|
||||
assert!(e.contains("base64"), "{e}");
|
||||
}
|
||||
|
||||
fn fake_plot(_: &Drawing, space: &Space, opts: &Value) -> std::result::Result<Vec<u8>, String> {
|
||||
// Same output as the layout tests' stand-in: whichever test installs first wins.
|
||||
|
||||
@@ -11,7 +11,8 @@ use cadcraft_doc::Drawing;
|
||||
pub use cadcraft_dxf::Version;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{CommandSpec, EngineError, Interactive, always, bad, bool_or, find_command, ok, str_param};
|
||||
|
||||
use crate::{Accept, Input, Prompt, Result, Session, Step};
|
||||
|
||||
/// The rules the Command Aliases dialog checks before it sends an alias to ALIASEDIT.
|
||||
@@ -163,7 +164,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.menu(&["File", "New Drawing..."])
|
||||
.key("Cmd+N")
|
||||
.alias(&["qnew"])
|
||||
.params("{metric?: bool, template?: path to a .dwt/.dxf template (\".\" for none; default QNEWTEMPLATE)}")
|
||||
.params("{metric?: bool (default MEASUREINIT), template?: path to a .dwt/.dxf template (\".\" for none; default QNEWTEMPLATE)}")
|
||||
.enabled(always)
|
||||
.noundo(),
|
||||
CommandSpec::new("open", "Open...", run_open)
|
||||
@@ -252,7 +253,8 @@ fn run_new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(r) = template::run_new(s, p)? {
|
||||
return Ok(r);
|
||||
}
|
||||
let i = s.new_drawing(bool_or(p, "metric", false));
|
||||
let metric = s.settings.measureinit;
|
||||
let i = s.new_drawing(bool_or(p, "metric", metric));
|
||||
Ok(json!({ "index": i, "title": s.docs.get(i).map(|d| d.title.clone()) }))
|
||||
}
|
||||
|
||||
@@ -361,6 +363,7 @@ fn run_open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(opened(s, i, false))
|
||||
}
|
||||
|
||||
/// `close [index]`: cancel the running command, remove the drawing (default: active), keep the active index valid, and bump the document epoch; an unknown index is an error.
|
||||
fn run_close(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = p.get("index").and_then(Value::as_u64).map(|i| i as usize).unwrap_or(s.active);
|
||||
if i >= s.docs.len() {
|
||||
@@ -375,13 +378,16 @@ fn run_close(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if s.active >= s.docs.len() {
|
||||
s.active = s.docs.len().saturating_sub(1);
|
||||
}
|
||||
s.bump_doc_epoch();
|
||||
ok()
|
||||
}
|
||||
|
||||
/// `closeall`: cancel the running command, remove every drawing, reset the active index to 0 and bump the document epoch.
|
||||
fn run_closeall(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
s.cancel();
|
||||
s.docs.clear();
|
||||
s.active = 0;
|
||||
s.bump_doc_epoch();
|
||||
ok()
|
||||
}
|
||||
|
||||
@@ -486,6 +492,7 @@ fn drawing_version(s: &Session, path: &str) -> Version {
|
||||
if is_dxf(path) { v } else { v.max(Version::R2004) }
|
||||
}
|
||||
|
||||
/// `document.switch index`: require the index, cancel the running command, activate that drawing and bump the epoch even if it was already active (screenshot identity).
|
||||
fn run_switch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("document.switch", "`index` is required"))? as usize;
|
||||
if i >= s.docs.len() {
|
||||
@@ -493,6 +500,11 @@ fn run_switch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
s.cancel();
|
||||
s.active = i;
|
||||
// Bump even when `i` is already active. This is intentionally conservative: the command
|
||||
// cancels the active command, and a screenshot client can only see observed state, not
|
||||
// whether the switch was a no-op. Rejecting an outstanding capture is safer than returning
|
||||
// an image whose queueing identity the client cannot distinguish from a real switch.
|
||||
s.bump_doc_epoch();
|
||||
ok()
|
||||
}
|
||||
|
||||
@@ -720,6 +732,7 @@ mod tests {
|
||||
#[cfg(test)]
|
||||
mod pr193_tests {
|
||||
use super::*;
|
||||
use crate::Session;
|
||||
|
||||
#[test]
|
||||
fn failed_store_keeps_unsaved_changes_and_delivered_save_clears_them() {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! drawing). Objects are matched by geometry and properties first (preferring the same handle),
|
||||
//! then by handle: the same handle with other geometry or properties is a change. What is left
|
||||
//! is only in this drawing or only in the other. The comparison stays on the session for the
|
||||
//! canvas overlay and next/previous navigation until COMPARECLOSE.
|
||||
//! canvas overlay, next/previous navigation and COMPAREEXPORT until COMPARECLOSE.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::hash::{Hash, Hasher};
|
||||
@@ -12,10 +12,13 @@ use cadcraft_doc::{Drawing, Entity, EntityKind, Handle, MAX_BLOCK_DEPTH, Space,
|
||||
use cadcraft_geom::{Bounds2, Vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::super::*;
|
||||
use super::super::{CommandSpec, bad, str_param};
|
||||
|
||||
use super::{base64_decode, io};
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
mod export;
|
||||
|
||||
/// Objects compared per drawing; more are left out (and the result says so).
|
||||
pub const MAX_OBJECTS: usize = 200_000;
|
||||
/// Objects listed per kind of difference in the JSON result.
|
||||
@@ -36,6 +39,9 @@ pub struct Comparison {
|
||||
pub other_name: String,
|
||||
/// The other drawing (its objects are drawn where they differ).
|
||||
pub other: Arc<Drawing>,
|
||||
/// The other drawing's file, when it has one: COMPAREEXPORT resolves its relative xref
|
||||
/// paths from that file's folder.
|
||||
pub other_path: Option<String>,
|
||||
/// Only in this drawing (handles here).
|
||||
pub added: Vec<Handle>,
|
||||
/// Only in the other drawing (handles there).
|
||||
@@ -71,6 +77,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("compare.next", "Next Change", |s, _| step(s, 1)).params("→ {region, of}: zoom to the next area with changes").noundo(),
|
||||
CommandSpec::new("compare.previous", "Previous Change", |s, _| step(s, -1)).params("→ {region, of}").noundo(),
|
||||
CommandSpec::new("compareclose", "Close Comparison", run_close).params("{}: end the comparison and its overlay").noundo(),
|
||||
export::spec(),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -133,11 +140,23 @@ struct Signer<'a> {
|
||||
d: &'a Drawing,
|
||||
blocks: HashMap<String, u64>,
|
||||
work: usize,
|
||||
/// When set, a block's signature also covers its base point, units, annotative and overlay
|
||||
/// flags and xref file, with relative xref, image and data link paths taken from the folder
|
||||
/// given, and the
|
||||
/// signatures of a multileader and a dimension cover their blocks'. Each object in a block
|
||||
/// then also counts with its visibility, transparency, thickness and extrusion, and an
|
||||
/// anonymous block's signature leaves out its name (a dimension also leaves its block's name
|
||||
/// out of its own geometry; an INSERT or MLEADER keeps it).
|
||||
frames: Option<Option<&'a std::path::Path>>,
|
||||
}
|
||||
|
||||
impl<'a> Signer<'a> {
|
||||
fn new(d: &'a Drawing) -> Self {
|
||||
Signer { d, blocks: HashMap::new(), work: 0 }
|
||||
Signer { d, blocks: HashMap::new(), work: 0, frames: None }
|
||||
}
|
||||
/// A signer whose block signatures also cover what changes how a reference draws the block.
|
||||
fn with_frames(d: &'a Drawing, folder: Option<&'a std::path::Path>) -> Self {
|
||||
Signer { frames: Some(folder), ..Signer::new(d) }
|
||||
}
|
||||
fn geometry(&mut self, k: &EntityKind, depth: usize) -> u64 {
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
@@ -156,6 +175,18 @@ impl<'a> Signer<'a> {
|
||||
if let EntityKind::Insert(i) = k {
|
||||
self.block(&i.block, depth).hash(&mut h);
|
||||
}
|
||||
if self.frames.is_some()
|
||||
&& let EntityKind::MLeader(m) = k
|
||||
&& let Some(b) = &m.block
|
||||
{
|
||||
self.block(&b.block, depth).hash(&mut h);
|
||||
}
|
||||
if self.frames.is_some()
|
||||
&& let EntityKind::Dimension(dm) = k
|
||||
&& let Some(b) = &dm.block
|
||||
{
|
||||
self.block(b, depth).hash(&mut h);
|
||||
}
|
||||
h.finish()
|
||||
}
|
||||
fn block(&mut self, name: &str, depth: usize) -> u64 {
|
||||
@@ -164,17 +195,42 @@ impl<'a> Signer<'a> {
|
||||
return *v;
|
||||
}
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
key.hash(&mut h);
|
||||
let block = self.d.block(name);
|
||||
// With frames, an anonymous block (`*D3`, `*U7`) is signed by its contents only: the
|
||||
// same block can have another generated name in another drawing. Only a dimension's
|
||||
// geometry leaves the name out, so this matches renamed `*D` blocks, not `*U` references.
|
||||
if !(self.frames.is_some() && block.is_some_and(|b| b.anonymous)) {
|
||||
key.hash(&mut h);
|
||||
}
|
||||
if depth < MAX_BLOCK_DEPTH
|
||||
&& let Some(b) = self.d.block(name)
|
||||
&& let Some(b) = block
|
||||
{
|
||||
if let Some(folder) = self.frames {
|
||||
let mut xref = b.xref_path.clone();
|
||||
if let Some(x) = xref.as_mut() {
|
||||
export::rebase_path(x, folder);
|
||||
}
|
||||
(format!("{:?}", b.base), b.units, b.annotative, b.xref_overlay, xref).hash(&mut h);
|
||||
}
|
||||
for e in b.entities.iter() {
|
||||
if self.work >= MAX_BLOCK_WORK {
|
||||
break;
|
||||
}
|
||||
self.work += 1;
|
||||
self.geometry(&e.kind, depth + 1).hash(&mut h);
|
||||
match self.frames {
|
||||
// An image's file and a table's data link, from the folder given.
|
||||
Some(folder) if matches!(e.kind, EntityKind::Image(_) | EntityKind::Table(_)) => {
|
||||
let mut k = e.kind.clone();
|
||||
export::rebase(&mut k, folder);
|
||||
self.geometry(&k, depth + 1).hash(&mut h);
|
||||
}
|
||||
_ => self.geometry(&e.kind, depth + 1).hash(&mut h),
|
||||
}
|
||||
properties(e).hash(&mut h);
|
||||
if self.frames.is_some() {
|
||||
let c = &e.common;
|
||||
format!("{:?}|{}|{}|{:?}", c.transparency, c.visible, (c.thickness * 1e6).round(), c.extrusion).hash(&mut h);
|
||||
}
|
||||
}
|
||||
}
|
||||
let v = h.finish();
|
||||
@@ -311,6 +367,7 @@ fn listed(d: &Drawing, hs: &[Handle]) -> Vec<Value> {
|
||||
|
||||
fn run_compare(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (other, name) = other_drawing(s, p)?;
|
||||
let other_path = export::source_path(s, p, &other);
|
||||
let st = s.state()?;
|
||||
let (uid, space, cur) = (st.uid, st.space.clone(), st.doc.clone());
|
||||
if Arc::ptr_eq(&cur, &other) {
|
||||
@@ -361,7 +418,7 @@ fn run_compare(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"message": message,
|
||||
});
|
||||
let changed = changed.into_iter().map(|(a, b, _, _)| (a, b)).collect();
|
||||
s.compare = Some(Comparison { uid, space, other_name: name, other, added, removed, changed, common, truncated, regions, at: None });
|
||||
s.compare = Some(Comparison { uid, space, other_name: name, other, other_path, added, removed, changed, common, truncated, regions, at: None });
|
||||
s.touch();
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,684 @@
|
||||
//! COMPAREEXPORT: the comparison as a new drawing (a snapshot). The drawings are compared again
|
||||
//! when the command runs, so edits made since COMPARE count. The new drawing's model space holds
|
||||
//! copies of the other drawing's objects that differ, then copies of the current drawing's
|
||||
//! objects in the compared tab, as two groups, `COMPARE_COMPARED` and `COMPARE_CURRENT`. Objects
|
||||
//! only in this drawing or changed in it are drawn in the overlay's green, objects only in the
|
||||
//! other drawing or changed there in its red, and objects in both in gray (color 8). A block
|
||||
//! reference, dimension or multileader refers to a copy of its block whose objects all have that
|
||||
//! color (`COMPARE_CURRENT_<name>`, `COMPARE_COMPARED_<name>`, `COMPARE_COMMON_<name>`; for a
|
||||
//! dimension's `*D` block, another `*D` block), and a table or tolerance uses a recolored copy
|
||||
//! of its style; xrefs keep their own colors. The new
|
||||
//! drawing has this drawing's layers, styles and blocks, plus the layers and styles of the other
|
||||
//! drawing that this one lacks and the blocks its copied objects refer to; an anonymous block of
|
||||
//! the other drawing whose name this one uses is copied under a free name. Relative xref, image
|
||||
//! and data link paths become absolute paths from the folder of the drawing they came from. It
|
||||
//! opens untitled as `compare_<this> vs <other>.dwg`, with unsaved changes; saving it asks for a
|
||||
//! file name.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_doc::color::{Color, Rgb};
|
||||
use cadcraft_doc::{Block, CellStyle, DimStyle, Drawing, Entity, EntityKind, EntityStore, Group, Handle, MAX_BLOCK_DEPTH, TableStyle};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::super::super::{CommandSpec, bad};
|
||||
use super::{Comparison, MAX_OBJECTS};
|
||||
use crate::{Result, Session};
|
||||
|
||||
/// Only in this drawing, or changed in it (the overlay's color).
|
||||
const THIS: Color = Color::True(Rgb(0x4c, 0xd9, 0x7b));
|
||||
/// Only in the other drawing, or changed there.
|
||||
const OTHER: Color = Color::True(Rgb(0xff, 0x5c, 0x5c));
|
||||
/// In both drawings.
|
||||
const COMMON: Color = Color::Index(8);
|
||||
/// Block definitions copied from the other drawing (the ones they reference included), and
|
||||
/// recolored copies of blocks and styles, per export.
|
||||
const MAX_BLOCKS: usize = 10_000;
|
||||
/// Objects copied into block definitions (the other drawing's blocks and the recolored
|
||||
/// copies), per export.
|
||||
const MAX_COPIED: usize = 1_000_000;
|
||||
|
||||
pub(super) fn spec() -> CommandSpec {
|
||||
CommandSpec::new("compareexport", "Export Comparison", run)
|
||||
.params(
|
||||
"{} → {index, title, current, compared: group names, counts: {current, compared}, truncated, message}: the comparison \
|
||||
as a new drawing, opened untitled",
|
||||
)
|
||||
.noundo()
|
||||
}
|
||||
|
||||
/// What [`snapshot`] made: the drawing, the two group names, how many objects each holds,
|
||||
/// whether a drawing had more objects than were compared, whether the other drawing had more
|
||||
/// blocks to copy than [`MAX_BLOCKS`], and whether a block was left in its own colors.
|
||||
struct Snapshot {
|
||||
drawing: Drawing,
|
||||
current: String,
|
||||
compared: String,
|
||||
counts: (usize, usize),
|
||||
truncated: bool,
|
||||
blocks_left_out: bool,
|
||||
uncolored: bool,
|
||||
}
|
||||
|
||||
fn run(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let st = s.state()?;
|
||||
let (uid, cur, title, cur_folder) = (st.uid, st.doc.clone(), st.title.clone(), folder_of(st.path.as_deref()));
|
||||
let c = s.compare.as_ref().filter(|c| c.uid == uid).ok_or_else(|| bad("compareexport", "no comparison: run COMPARE first"))?;
|
||||
let name = format!("compare_{} vs {}.dwg", stem(&title), stem(&c.other_name));
|
||||
let folders = Folders { here: cur_folder, there: folder_of(c.other_path.as_deref()) };
|
||||
let Snapshot { drawing, current, compared, counts, truncated, blocks_left_out, uncolored } = snapshot(&cur, c, &folders);
|
||||
let i = s.open_drawing(drawing, &name, None);
|
||||
// Not saved yet: closing it asks to save it under a name.
|
||||
let st = s.state_mut()?;
|
||||
st.saved = Arc::new((*st.doc).clone());
|
||||
let mut notes = Vec::new();
|
||||
if truncated {
|
||||
notes.push(format!("only the first {MAX_OBJECTS} objects of each drawing were compared"));
|
||||
}
|
||||
if blocks_left_out {
|
||||
notes.push("some blocks of the other drawing were too many or too large to copy".to_string());
|
||||
}
|
||||
if uncolored {
|
||||
notes.push("some blocks keep their own colors".to_string());
|
||||
}
|
||||
let notes = if notes.is_empty() { String::new() } else { format!(" ({})", notes.join("; ")) };
|
||||
Ok(json!({
|
||||
"index": i,
|
||||
"title": name,
|
||||
"current": current,
|
||||
"compared": compared,
|
||||
"counts": { "current": counts.0, "compared": counts.1 },
|
||||
"truncated": truncated || blocks_left_out || uncolored,
|
||||
"message": format!("The comparison was exported to the new drawing {name}{notes}."),
|
||||
}))
|
||||
}
|
||||
|
||||
/// The file of the drawing COMPARE compares with: the `path` it was read from, or the open
|
||||
/// drawing's own file. `None` for `{data}` and for an open drawing never saved.
|
||||
pub(super) fn source_path(s: &Session, p: &Value, other: &Arc<Drawing>) -> Option<String> {
|
||||
match p.get("path").and_then(Value::as_str) {
|
||||
Some(path) if p.get("document").is_none_or(Value::is_null) => Some(path.to_string()),
|
||||
_ => s.docs.iter().find(|d| Arc::ptr_eq(&d.doc, other)).and_then(|d| d.path.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The folders of the two drawings' files. The snapshot has no folder of its own, so the
|
||||
/// relative file paths it copies (xrefs, images, table data links) become absolute paths from
|
||||
/// the folder of the drawing they came from.
|
||||
struct Folders {
|
||||
here: Option<PathBuf>,
|
||||
there: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// The absolute folder of the file at `path`.
|
||||
fn folder_of(path: Option<&str>) -> Option<PathBuf> {
|
||||
let parent = Path::new(path?).parent()?;
|
||||
let parent = if parent.as_os_str().is_empty() { Path::new(".") } else { parent };
|
||||
Some(std::path::absolute(parent).unwrap_or_else(|_| parent.to_path_buf()))
|
||||
}
|
||||
|
||||
/// `p` as an absolute path from `folder` when it is relative.
|
||||
pub(super) fn rebase_path(p: &mut String, folder: Option<&Path>) {
|
||||
if let Some(folder) = folder
|
||||
&& !p.trim().is_empty()
|
||||
&& Path::new(p.as_str()).is_relative()
|
||||
{
|
||||
let joined = folder.join(p.as_str());
|
||||
*p = std::path::absolute(&joined).unwrap_or(joined).to_string_lossy().to_string();
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebase the file an object refers to: an image's file, a table's data link.
|
||||
pub(super) fn rebase(k: &mut EntityKind, folder: Option<&Path>) {
|
||||
match k {
|
||||
EntityKind::Image(i) => rebase_path(&mut i.path, folder),
|
||||
EntityKind::Table(t) => {
|
||||
if let Some(l) = t.data_link.as_mut() {
|
||||
rebase_path(&mut l.path, folder);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebase a block's xref path and the files its objects refer to.
|
||||
fn rebase_block(b: &mut Block, folder: Option<&Path>) {
|
||||
if let Some(x) = b.xref_path.as_mut() {
|
||||
rebase_path(x, folder);
|
||||
}
|
||||
let mut store = EntityStore::new();
|
||||
for e in b.entities.iter() {
|
||||
let mut e = (**e).clone();
|
||||
rebase(&mut e.kind, folder);
|
||||
store.push(e);
|
||||
}
|
||||
b.entities = store;
|
||||
}
|
||||
|
||||
/// Whether a block holds a relative file path that [`rebase_block`] would change.
|
||||
fn has_relative_paths(b: &Block) -> bool {
|
||||
let rel = |p: &str| !p.trim().is_empty() && Path::new(p).is_relative();
|
||||
b.xref_path.as_deref().is_some_and(rel)
|
||||
|| b.entities.iter().any(|e| match &e.kind {
|
||||
EntityKind::Image(i) => rel(&i.path),
|
||||
EntityKind::Table(t) => t.data_link.as_ref().is_some_and(|l| rel(&l.path)),
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
|
||||
/// A drawing title without its file extension.
|
||||
fn stem(title: &str) -> String {
|
||||
std::path::Path::new(title).file_stem().map(|n| n.to_string_lossy().to_string()).filter(|n| !n.is_empty()).unwrap_or_else(|| title.to_string())
|
||||
}
|
||||
|
||||
fn snapshot(cur: &Drawing, c: &Comparison, folders: &Folders) -> Snapshot {
|
||||
let other: &Drawing = &c.other;
|
||||
let mut d = cur.clone();
|
||||
if folders.here.is_some() {
|
||||
for b in d.blocks.values_mut().filter(|b| has_relative_paths(b)) {
|
||||
rebase_block(Arc::make_mut(b), folders.here.as_deref());
|
||||
}
|
||||
}
|
||||
d.model = EntityStore::new();
|
||||
for l in &mut d.layouts {
|
||||
l.entities = EntityStore::new();
|
||||
}
|
||||
d.foreign = Arc::default();
|
||||
d.groups.clear();
|
||||
d.constraints.clear();
|
||||
// The per-object constraint bar and dimension exceptions name objects the copy doesn't have.
|
||||
d.parametric.settings.bar_exceptions.clear();
|
||||
d.parametric.settings.dim_exceptions.clear();
|
||||
d.hidden_objects.clear();
|
||||
d.read_notes.clear();
|
||||
d.refedit = None;
|
||||
d.skipped_records.clear();
|
||||
d.handseed = d.handseed.max(other.handseed);
|
||||
|
||||
merge(&mut d.layers, &other.layers, |t| &t.name);
|
||||
merge(&mut d.linetypes, &other.linetypes, |t| &t.name);
|
||||
merge(&mut d.text_styles, &other.text_styles, |t| &t.name);
|
||||
merge(&mut d.dim_styles, &other.dim_styles, |t| &t.name);
|
||||
merge(&mut d.mleader_styles, &other.mleader_styles, |t| &t.name);
|
||||
merge(&mut d.table_styles, &other.table_styles, |t| &t.name);
|
||||
merge(&mut d.mline_styles, &other.mline_styles, |t| &t.name);
|
||||
|
||||
// Compared again, so edits made since COMPARE ran are classified as they are now.
|
||||
let diff = super::diff(cur, other, &c.space);
|
||||
let ours: HashSet<Handle> = diff.added.iter().copied().chain(diff.changed.iter().map(|(a, ..)| *a)).collect();
|
||||
let here: Vec<(&Entity, Color)> = cur
|
||||
.space(&c.space)
|
||||
.into_iter()
|
||||
.flat_map(EntityStore::iter)
|
||||
.filter(|e| !matches!(e.kind, EntityKind::Viewport(_)))
|
||||
.take(MAX_OBJECTS)
|
||||
.map(|e| (e.as_ref(), if ours.contains(&e.handle) { THIS } else { COMMON }))
|
||||
.collect();
|
||||
// In the other drawing's draw order.
|
||||
let theirs: HashSet<Handle> = diff.removed.iter().copied().chain(diff.changed.iter().map(|(_, b, ..)| *b)).collect();
|
||||
let there: Vec<(&Entity, Color)> = other
|
||||
.space(&c.space)
|
||||
.into_iter()
|
||||
.flat_map(EntityStore::iter)
|
||||
.filter(|e| theirs.contains(&e.handle) && !matches!(e.kind, EntityKind::Viewport(_)))
|
||||
.map(|e| (e.as_ref(), OTHER))
|
||||
.collect();
|
||||
let mut tinted = Tinted::default();
|
||||
let (renames, blocks_left_out) = copy_blocks(&mut d, other, there.iter().map(|(e, _)| *e), folders.there.as_deref(), &mut tinted);
|
||||
|
||||
// The other drawing's objects first, so this drawing's are drawn over them.
|
||||
let compared = add_group(&mut d, "COMPARE_COMPARED", &there, &renames, folders.there.as_deref(), &mut tinted);
|
||||
let current = add_group(&mut d, "COMPARE_CURRENT", &here, &Renames::new(), folders.here.as_deref(), &mut tinted);
|
||||
let uncolored = tinted.uncolored;
|
||||
Snapshot { drawing: d, current, compared, counts: (here.len(), there.len()), truncated: diff.truncated, blocks_left_out, uncolored }
|
||||
}
|
||||
|
||||
/// Add the entries of `from` whose names `to` lacks (compared without letter case).
|
||||
fn merge<T: Clone>(to: &mut Vec<T>, from: &[T], name: impl Fn(&T) -> &String) {
|
||||
for t in from {
|
||||
if !to.iter().any(|x| name(x).eq_ignore_ascii_case(name(t))) {
|
||||
to.push(t.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Add copies of `objs` to model space with new handles, their block references renamed by
|
||||
/// `renames` and their files rebased on `folder`, each painted in its comparison color, and
|
||||
/// group them as `name` (or `name_2`… when that group name is taken). Returns the group's name.
|
||||
fn add_group(d: &mut Drawing, name: &str, objs: &[(&Entity, Color)], renames: &Renames, folder: Option<&Path>, tinted: &mut Tinted) -> String {
|
||||
let mut members = Vec::new();
|
||||
for (e, color) in objs {
|
||||
d.ensure_layer(&e.common.layer);
|
||||
let h = d.new_handle();
|
||||
let mut copy = Entity { handle: h, ..(*e).clone() };
|
||||
detach(&mut copy.kind);
|
||||
rebase(&mut copy.kind, folder);
|
||||
rename_refs(&mut copy.kind, renames);
|
||||
paint(d, &mut copy, *color, tinted, 0);
|
||||
d.model.push(copy);
|
||||
members.push(h);
|
||||
}
|
||||
let name = free_name(name, d.groups.len(), |n| d.groups.iter().any(|g| g.name.eq_ignore_ascii_case(n)));
|
||||
if !members.is_empty() {
|
||||
d.groups.push(Group { name: name.clone(), description: String::new(), selectable: true, members });
|
||||
}
|
||||
name
|
||||
}
|
||||
|
||||
/// Drop the links of an associative dimension or hatch to the objects it follows: the copies
|
||||
/// have new handles, so the links would name objects the snapshot does not have.
|
||||
fn detach(k: &mut EntityKind) {
|
||||
match k {
|
||||
EntityKind::Dimension(dm) => dm.assoc.clear(),
|
||||
EntityKind::Hatch(h) => {
|
||||
h.associative = false;
|
||||
h.loops.iter_mut().for_each(|l| l.sources.clear());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// The block names an object refers to.
|
||||
fn block_refs(k: &EntityKind) -> Vec<String> {
|
||||
match k {
|
||||
EntityKind::Insert(i) => vec![i.block.clone()],
|
||||
EntityKind::Dimension(dm) => dm.block.iter().cloned().collect(),
|
||||
EntityKind::MLeader(m) => m.block.iter().map(|b| b.block.clone()).collect(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Call `f` on each block name an object refers to.
|
||||
fn each_ref(k: &mut EntityKind, mut f: impl FnMut(&mut String)) {
|
||||
match k {
|
||||
EntityKind::Insert(i) => f(&mut i.block),
|
||||
EntityKind::Dimension(dm) => {
|
||||
if let Some(n) = dm.block.as_mut() {
|
||||
f(n);
|
||||
}
|
||||
}
|
||||
EntityKind::MLeader(m) => {
|
||||
if let Some(b) = m.block.as_mut() {
|
||||
f(&mut b.block);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Point the block references of an object at the names its blocks were copied under.
|
||||
fn rename_refs(k: &mut EntityKind, renames: &Renames) {
|
||||
each_ref(k, |n| {
|
||||
if let Some(new) = renames.get(&n.to_ascii_uppercase()) {
|
||||
n.clone_from(new);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Recolored copies of blocks and styles made so far, and whether a block was left in its own
|
||||
/// colors because the nesting or copy limit was reached.
|
||||
#[derive(Default)]
|
||||
struct Tinted {
|
||||
/// (comparison tag, "block", "table style" or "dimension style", upper-case name) → the
|
||||
/// copy's name.
|
||||
names: HashMap<(&'static str, &'static str, String), String>,
|
||||
uncolored: bool,
|
||||
/// Objects copied into block definitions so far, counted against [`MAX_COPIED`].
|
||||
copied: usize,
|
||||
next: NextFree,
|
||||
/// The drawing's block names in upper case, filled on the first block copy. Only
|
||||
/// [`tinted_block`] adds blocks after that, and it adds their names here too.
|
||||
blocks: Option<HashSet<String>>,
|
||||
}
|
||||
|
||||
/// The next index to try for each name base. Names are only added while a snapshot is built,
|
||||
/// so an index found taken stays taken, and each search starts where the last one for the same
|
||||
/// base stopped: a name costs O(1) amortised instead of a scan from 1.
|
||||
#[derive(Default)]
|
||||
struct NextFree(HashMap<(&'static str, String), usize>);
|
||||
|
||||
impl NextFree {
|
||||
/// The first of `name(i)` for `i` from 1 that `taken` doesn't hold, skipping the indices
|
||||
/// earlier searches for `base` in namespace `kind` passed. `n` is how many names there are,
|
||||
/// so one of `name(1)`…`name(n + 1)` is free. The caller adds the returned name.
|
||||
fn find(&mut self, kind: &'static str, base: &str, n: usize, name: impl Fn(usize) -> String, taken: impl Fn(&str) -> bool) -> String {
|
||||
let key = (kind, base.to_ascii_uppercase());
|
||||
let start = self.0.get(&key).copied().unwrap_or(1);
|
||||
let i = (start..=n.saturating_add(1)).find(|&i| !taken(&name(i))).unwrap_or_else(|| n.saturating_add(2).max(start));
|
||||
self.0.insert(key, i.saturating_add(1));
|
||||
name(i)
|
||||
}
|
||||
}
|
||||
|
||||
/// `base` for 1, `base_2`, `base_3`… after it.
|
||||
fn suffixed(base: &str, i: usize) -> String {
|
||||
if i == 1 { base.to_string() } else { format!("{base}_{i}") }
|
||||
}
|
||||
|
||||
/// The word a comparison color adds to the names of its recolored copies.
|
||||
fn tag(color: Color) -> &'static str {
|
||||
if color == THIS {
|
||||
"CURRENT"
|
||||
} else if color == OTHER {
|
||||
"COMPARED"
|
||||
} else {
|
||||
"COMMON"
|
||||
}
|
||||
}
|
||||
|
||||
/// `base`, or `base_2`, `base_3`… when `taken` holds that name. `n` is how many names there are.
|
||||
fn free_name(base: &str, n: usize, taken: impl Fn(&str) -> bool) -> String {
|
||||
(1..=n + 1).map(|i| suffixed(base, i)).find(|c| !taken(c)).unwrap_or_else(|| suffixed(base, n + 2))
|
||||
}
|
||||
|
||||
/// Give an object its comparison color: its own color, its attributes' colors, and, through
|
||||
/// recolored copies of the blocks and styles it refers to, the color of everything they draw.
|
||||
fn paint(d: &mut Drawing, e: &mut Entity, color: Color, tinted: &mut Tinted, depth: usize) {
|
||||
e.common.color = color;
|
||||
let attribs = match &mut e.kind {
|
||||
EntityKind::Insert(i) => Some(&mut i.attribs),
|
||||
EntityKind::MLeader(m) => m.block.as_mut().map(|b| &mut b.attribs),
|
||||
_ => None,
|
||||
};
|
||||
for a in attribs.into_iter().flatten() {
|
||||
a.props.color = Some(color);
|
||||
if let Some(ml) = a.multiline.as_mut() {
|
||||
ml.mtext.contents = without_color_codes(&ml.mtext.contents);
|
||||
}
|
||||
}
|
||||
// Colors an object carries apart from its own: inline MTEXT colors (in multiline attributes
|
||||
// too), a dimension style's DIMCLRD, DIMCLRE and DIMCLRT, gradient colors, multiline element
|
||||
// colors, the style colors of a table or a tolerance (through a recolored copy of the style)
|
||||
// and inline cell colors. Hatch backgrounds, multiline fills and table cell fills are left out.
|
||||
match &mut e.kind {
|
||||
EntityKind::MText(t) => t.contents = without_color_codes(&t.contents),
|
||||
EntityKind::AttDef(a) => {
|
||||
if let Some(ml) = a.multiline.as_mut() {
|
||||
ml.mtext.contents = without_color_codes(&ml.mtext.contents);
|
||||
}
|
||||
}
|
||||
EntityKind::MLeader(m) => {
|
||||
if let Some(t) = m.text.as_mut() {
|
||||
t.contents = without_color_codes(&t.contents);
|
||||
}
|
||||
}
|
||||
EntityKind::Dimension(dm) => {
|
||||
if let Ok(by_block) = serde_json::to_value(Color::ByBlock) {
|
||||
for var in ["DIMCLRD", "DIMCLRE", "DIMCLRT"] {
|
||||
dm.overrides.retain(|k, _| DimStyle::field_name(k) != DimStyle::field_name(var));
|
||||
dm.overrides.insert(var.into(), by_block.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
EntityKind::Tolerance(t) => {
|
||||
if let Some(style) = tinted_dim_style(d, &t.style, color, tinted) {
|
||||
t.style = style;
|
||||
}
|
||||
}
|
||||
EntityKind::Hatch(h) => {
|
||||
if let Some(g) = h.gradient.as_mut() {
|
||||
(g.color1, g.color2) = (color, color);
|
||||
}
|
||||
h.background = None;
|
||||
}
|
||||
EntityKind::MLine(m) => {
|
||||
for el in &mut m.def.elements {
|
||||
el.color = Color::ByBlock;
|
||||
}
|
||||
m.def.fill = None;
|
||||
}
|
||||
EntityKind::Table(t) => {
|
||||
for cell in t.cells.iter_mut().flatten() {
|
||||
cell.text = without_color_codes(&cell.text);
|
||||
}
|
||||
if let Some(style) = tinted_table_style(d, &t.style, color, tinted) {
|
||||
t.style = style;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if let EntityKind::Dimension(dm) = &mut e.kind {
|
||||
// A dimension whose block can't be copied (past the limits) is drawn again from its
|
||||
// geometry: its DIMCLR* overrides are ByBlock, so the original block would keep its colors.
|
||||
dm.block = dm.block.take().and_then(|n| tinted_block(d, &n, color, tinted, depth));
|
||||
return;
|
||||
}
|
||||
each_ref(&mut e.kind, |n| {
|
||||
if let Some(copy) = tinted_block(d, n, color, tinted, depth) {
|
||||
*n = copy;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The name of a copy of dimension style `name` that draws lines and text in the object's
|
||||
/// color, made on first use. `None` when the drawing has no such style.
|
||||
fn tinted_dim_style(d: &mut Drawing, name: &str, color: Color, tinted: &mut Tinted) -> Option<String> {
|
||||
let key = (tag(color), "dimension style", name.to_ascii_uppercase());
|
||||
if let Some(n) = tinted.names.get(&key) {
|
||||
return Some(n.clone());
|
||||
}
|
||||
let st = d.dim_styles.iter().find(|s| s.name.eq_ignore_ascii_case(name))?.clone();
|
||||
let base = format!("COMPARE_{}_{}", tag(color), st.name);
|
||||
let new = tinted.next.find(
|
||||
"dimension style",
|
||||
&base,
|
||||
d.dim_styles.len(),
|
||||
|i| suffixed(&base, i),
|
||||
|n| d.dim_styles.iter().any(|s| s.name.eq_ignore_ascii_case(n)),
|
||||
);
|
||||
d.dim_styles.push(DimStyle {
|
||||
name: new.clone(),
|
||||
dim_line_color: Color::ByBlock,
|
||||
ext_line_color: Color::ByBlock,
|
||||
text_color: Color::ByBlock,
|
||||
..st
|
||||
});
|
||||
tinted.names.insert(key, new.clone());
|
||||
Some(new)
|
||||
}
|
||||
|
||||
/// The name of a copy of table style `name` whose cell styles draw text and borders in the
|
||||
/// table's color and fill no cells, made on first use. `None` when the drawing has no such style.
|
||||
fn tinted_table_style(d: &mut Drawing, name: &str, color: Color, tinted: &mut Tinted) -> Option<String> {
|
||||
let key = (tag(color), "table style", name.to_ascii_uppercase());
|
||||
if let Some(n) = tinted.names.get(&key) {
|
||||
return Some(n.clone());
|
||||
}
|
||||
let st = d.table_styles.iter().find(|s| s.name.eq_ignore_ascii_case(name))?.clone();
|
||||
let base = format!("COMPARE_{}_{}", tag(color), st.name);
|
||||
let new = tinted.next.find(
|
||||
"table style",
|
||||
&base,
|
||||
d.table_styles.len(),
|
||||
|i| suffixed(&base, i),
|
||||
|n| d.table_styles.iter().any(|s| s.name.eq_ignore_ascii_case(n)),
|
||||
);
|
||||
let names =
|
||||
[CellStyle::TITLE, CellStyle::HEADER, CellStyle::DATA].into_iter().map(str::to_string).chain(st.cell_styles.iter().map(|c| c.name.clone()));
|
||||
let mut cell_styles: Vec<CellStyle> = Vec::new();
|
||||
for n in names {
|
||||
if cell_styles.iter().any(|c| c.name.eq_ignore_ascii_case(&n)) {
|
||||
continue;
|
||||
}
|
||||
let mut cs = st.cell_style(&n);
|
||||
cs.name = n;
|
||||
cs.text_color = Color::ByBlock;
|
||||
cs.fill = None;
|
||||
let b = &mut cs.borders;
|
||||
for border in [&mut b.top, &mut b.inside_h, &mut b.bottom, &mut b.left, &mut b.inside_v, &mut b.right] {
|
||||
border.color = Color::ByBlock;
|
||||
}
|
||||
cell_styles.push(cs);
|
||||
}
|
||||
d.table_styles.push(TableStyle { name: new.clone(), cell_styles, ..st });
|
||||
tinted.names.insert(key, new.clone());
|
||||
Some(new)
|
||||
}
|
||||
|
||||
/// MTEXT contents without inline color codes (`\C1;`, `\c255;`), so the text takes the
|
||||
/// object's color. An escaped backslash (`\\`) stays.
|
||||
fn without_color_codes(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut it = s.chars().peekable();
|
||||
while let Some(c) = it.next() {
|
||||
if c != '\\' {
|
||||
out.push(c);
|
||||
continue;
|
||||
}
|
||||
match it.peek().copied() {
|
||||
Some('C' | 'c') => {
|
||||
for d in it.by_ref() {
|
||||
if d == ';' {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(n) => {
|
||||
out.push('\\');
|
||||
out.push(n);
|
||||
it.next();
|
||||
}
|
||||
None => out.push('\\'),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The name of a copy of block `name` whose objects all have `color`, made on first use. `None`
|
||||
/// (the reference keeps the original block) for an xref, a missing block, or past the nesting
|
||||
/// and copy limits.
|
||||
fn tinted_block(d: &mut Drawing, name: &str, color: Color, tinted: &mut Tinted, depth: usize) -> Option<String> {
|
||||
let key = (tag(color), "block", name.to_ascii_uppercase());
|
||||
if let Some(n) = tinted.names.get(&key) {
|
||||
return Some(n.clone());
|
||||
}
|
||||
let b = d.block(name)?.clone();
|
||||
if b.xref_path.is_some() {
|
||||
return None;
|
||||
}
|
||||
if depth >= MAX_BLOCK_DEPTH || tinted.names.len() >= MAX_BLOCKS || tinted.copied.saturating_add(b.entities.len()) > MAX_COPIED {
|
||||
tinted.uncolored = true;
|
||||
return None;
|
||||
}
|
||||
tinted.copied += b.entities.len();
|
||||
// A dimension's block copy stays a `*D` block, so saving keeps its geometry instead of
|
||||
// drawing the dimension again.
|
||||
let dim_block = b.anonymous && b.name.get(..2).is_some_and(|p| p.eq_ignore_ascii_case("*D"));
|
||||
let Tinted { next, blocks, .. } = tinted;
|
||||
let taken = blocks.get_or_insert_with(|| d.blocks.keys().map(|k| k.to_ascii_uppercase()).collect());
|
||||
let is_taken = |n: &str| taken.contains(&n.to_ascii_uppercase());
|
||||
let new = if dim_block {
|
||||
next.find("block", "*D", d.blocks.len(), |i| format!("*D{i}"), is_taken)
|
||||
} else {
|
||||
let base = format!("COMPARE_{}_{}", tag(color), b.name.trim_start_matches('*'));
|
||||
next.find("block", &base, d.blocks.len(), |i| suffixed(&base, i), is_taken)
|
||||
};
|
||||
taken.insert(new.to_ascii_uppercase());
|
||||
tinted.names.insert(key, new.clone());
|
||||
// Hold the name while the nested copies pick theirs.
|
||||
d.blocks.insert(new.clone(), Arc::new(Block::new(&new)));
|
||||
let mut copy = Block { name: new.clone(), anonymous: dim_block, entities: EntityStore::new(), ..(*b).clone() };
|
||||
for e in b.entities.iter() {
|
||||
let h = d.new_handle();
|
||||
let mut e = Entity { handle: h, ..(**e).clone() };
|
||||
detach(&mut e.kind);
|
||||
paint(d, &mut e, color, tinted, depth + 1);
|
||||
copy.entities.push(e);
|
||||
}
|
||||
d.blocks.insert(new.clone(), Arc::new(copy));
|
||||
Some(new)
|
||||
}
|
||||
|
||||
/// The name each block copied from the other drawing has in the snapshot, by its upper-case
|
||||
/// name there.
|
||||
type Renames = HashMap<String, String>;
|
||||
|
||||
/// Copy into `d` the blocks of `other` that `objs` refer to, directly or through other blocks.
|
||||
/// A named block `d` already has keeps `d`'s definition when it draws the same thing there (the
|
||||
/// same objects, base point, units, flags and xref file, in it and in the blocks it refers to);
|
||||
/// otherwise the other drawing's block is copied as `PART_2` (or `PART_3`…). An anonymous block
|
||||
/// (`*D3`, `*U7`…) whose name `d` already uses is copied under the next free name with the same
|
||||
/// prefix. Copied
|
||||
/// objects get new handles, and relative file paths are rebased on `folder`. The flag is true
|
||||
/// when more than [`MAX_BLOCKS`] blocks or [`MAX_COPIED`] objects were needed and the rest were
|
||||
/// left out.
|
||||
fn copy_blocks<'a>(
|
||||
d: &mut Drawing,
|
||||
other: &Drawing,
|
||||
objs: impl Iterator<Item = &'a Entity>,
|
||||
folder: Option<&Path>,
|
||||
tinted: &mut Tinted,
|
||||
) -> (Renames, bool) {
|
||||
let mut frontier: Vec<String> = objs.flat_map(|e| block_refs(&e.kind)).collect();
|
||||
let mut seen = HashSet::new();
|
||||
let mut needed: Vec<Arc<Block>> = Vec::new();
|
||||
let mut left_out = false;
|
||||
// `d`'s relative paths were made absolute from its own folder before the copy.
|
||||
let (mut mine, mut theirs) = (super::Signer::with_frames(d, None), super::Signer::with_frames(other, folder));
|
||||
// Whether `d`'s block `n` draws what `other`'s block `b` draws. Past the signers' work limit
|
||||
// the signatures no longer cover every object, so the blocks count as different.
|
||||
let mut same = |n: &str, b: &Block| {
|
||||
!b.anonymous
|
||||
&& d.block(n).is_some()
|
||||
&& mine.block(n, 0) == theirs.block(n, 0)
|
||||
&& mine.work < super::MAX_BLOCK_WORK
|
||||
&& theirs.work < super::MAX_BLOCK_WORK
|
||||
};
|
||||
while let Some(n) = frontier.pop() {
|
||||
let key = n.to_ascii_uppercase();
|
||||
if seen.contains(&key) {
|
||||
continue;
|
||||
}
|
||||
if seen.len() >= MAX_BLOCKS {
|
||||
left_out |= other.block(&n).is_some_and(|b| !same(&n, b));
|
||||
continue;
|
||||
}
|
||||
seen.insert(key);
|
||||
let Some(b) = other.block(&n) else { continue };
|
||||
if same(&n, b) {
|
||||
continue;
|
||||
}
|
||||
if tinted.copied.saturating_add(b.entities.len()) > MAX_COPIED {
|
||||
left_out = true;
|
||||
continue;
|
||||
}
|
||||
tinted.copied += b.entities.len();
|
||||
frontier.extend(b.entities.iter().flat_map(|e| block_refs(&e.kind)));
|
||||
needed.push(b.clone());
|
||||
}
|
||||
// Names `d` does not use stay; the others get free names, avoiding the kept ones.
|
||||
let mut renames = Renames::new();
|
||||
let mut taken: HashSet<String> = d.blocks.keys().map(|k| k.to_ascii_uppercase()).collect();
|
||||
let mut next = NextFree::default();
|
||||
for b in needed.iter().filter(|b| d.block(&b.name).is_none()) {
|
||||
taken.insert(b.name.to_ascii_uppercase());
|
||||
renames.insert(b.name.to_ascii_uppercase(), b.name.clone());
|
||||
}
|
||||
for b in needed.iter().filter(|b| d.block(&b.name).is_some()) {
|
||||
let is_taken = |n: &str| taken.contains(&n.to_ascii_uppercase());
|
||||
let free = if b.anonymous {
|
||||
let prefix: String = b.name.chars().take_while(|c| *c == '*' || c.is_ascii_alphabetic()).collect();
|
||||
next.find("anonymous block", &prefix, taken.len(), |i| format!("{prefix}{i}"), is_taken)
|
||||
} else {
|
||||
next.find("block", &b.name, taken.len(), |i| suffixed(&b.name, i), is_taken)
|
||||
};
|
||||
taken.insert(free.to_ascii_uppercase());
|
||||
renames.insert(b.name.to_ascii_uppercase(), free);
|
||||
}
|
||||
for b in needed {
|
||||
let Some(name) = renames.get(&b.name.to_ascii_uppercase()).cloned() else { continue };
|
||||
let mut copy = Block { name: name.clone(), entities: EntityStore::new(), ..(*b).clone() };
|
||||
for e in b.entities.iter() {
|
||||
let h = d.new_handle();
|
||||
let mut e = Entity { handle: h, ..(**e).clone() };
|
||||
detach(&mut e.kind);
|
||||
rename_refs(&mut e.kind, &renames);
|
||||
copy.entities.push(e);
|
||||
}
|
||||
rebase_block(&mut copy, folder);
|
||||
d.blocks.insert(name, Arc::new(copy));
|
||||
}
|
||||
(renames, left_out)
|
||||
}
|
||||
@@ -12,8 +12,9 @@ use std::sync::{Mutex, MutexGuard, OnceLock, PoisonError};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::super::always;
|
||||
use super::super::helpers::support_text;
|
||||
use super::*;
|
||||
use super::{CommandSpec, Result, Session, bad, bool_or, find_command};
|
||||
|
||||
/// Most user aliases held.
|
||||
pub const MAX_ALIASES: usize = 10_000;
|
||||
|
||||
@@ -10,7 +10,7 @@ use std::sync::{Mutex, MutexGuard, OnceLock, PoisonError};
|
||||
use cadcraft_doc::Drawing;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{Result, Session, bad, io};
|
||||
|
||||
/// The default template (profile setting, process-wide like FONTALT): empty = none.
|
||||
fn default_template() -> MutexGuard<'static, String> {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user