mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-14 14:26:45 -04:00
Compare commits
110
Commits
00c7613f1a
...
fc4e98315a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc4e98315a | ||
|
|
e707ab4d7c | ||
|
|
8cd0d46d58 | ||
|
|
1e075f76c5 | ||
|
|
7fc29b056e | ||
|
|
aed2ae9615 | ||
|
|
033ee15e01 | ||
|
|
4a9dd31a58 | ||
|
|
26179f7428 | ||
|
|
261d46adcf | ||
|
|
e74b276dfd | ||
|
|
d826450e03 | ||
|
|
abe352564e | ||
|
|
d80920cc25 | ||
|
|
43972c82e8 | ||
|
|
767576ec9e | ||
|
|
62fb5ae79a | ||
|
|
3199de4951 | ||
|
|
48352f2baf | ||
|
|
0d138c2954 | ||
|
|
f8521ab299 | ||
|
|
73b8edac61 | ||
|
|
fbc7037a10 | ||
|
|
e773d93164 | ||
|
|
a2b6c9ac4a | ||
|
|
8abc0909f3 | ||
|
|
8166e4a809 | ||
|
|
dc92f38772 | ||
|
|
1c4d0a543f | ||
|
|
ef9898d57f | ||
|
|
33fe98a6dc | ||
|
|
5630529464 | ||
|
|
3b16f2d516 | ||
|
|
2f5bbbd68d | ||
|
|
76b0ef8897 | ||
|
|
966356545d | ||
|
|
4257674dfa | ||
|
|
64b7825d08 | ||
|
|
d2a9159e51 | ||
|
|
678ff555ae | ||
|
|
d0438577ed | ||
|
|
63b448bee9 | ||
|
|
7b767ddab9 | ||
|
|
79efd97515 | ||
|
|
cddcbe7a2a | ||
|
|
b209bb53e9 | ||
|
|
5d39f8710c | ||
|
|
6394c7c6f3 | ||
|
|
2483993218 | ||
|
|
cc57dff527 | ||
|
|
724c18c6af | ||
|
|
8e0b867f6c | ||
|
|
36f938e90a | ||
|
|
636b041a4e | ||
|
|
bb7e1046be | ||
|
|
f807e65e30 | ||
|
|
69f2b9724a | ||
|
|
28b8160407 | ||
|
|
5b037b8f92 | ||
|
|
5fd23f75fc | ||
|
|
a6200fa34f | ||
|
|
25e4900fcd | ||
|
|
4efb01332e | ||
|
|
d134079f11 | ||
|
|
591692a264 | ||
|
|
50246683c3 | ||
|
|
2effc8bf3e | ||
|
|
5b0238fc26 | ||
|
|
7b723b7bc0 | ||
|
|
a10e43cb2a | ||
|
|
7e38081b54 | ||
|
|
3f709bed2e | ||
|
|
caad180e98 | ||
|
|
80ec2dcc60 | ||
|
|
46ab40d211 | ||
|
|
9464945f82 | ||
|
|
3dd678ff3d | ||
|
|
ea768ee92a | ||
|
|
5ce44acfb2 | ||
|
|
853b2915b6 | ||
|
|
0b92b59fcd | ||
|
|
f26588a357 | ||
|
|
8073453d86 | ||
|
|
89c834155d | ||
|
|
14343212a9 | ||
|
|
ecdb8f96b2 | ||
|
|
4c63ba1470 | ||
|
|
c0335161c3 | ||
|
|
419d8848d6 | ||
|
|
c781169b95 | ||
|
|
f314d3a591 | ||
|
|
e9d8462828 | ||
|
|
6ab800e64c | ||
|
|
565cadaa51 | ||
|
|
bca698a588 | ||
|
|
5de2324973 | ||
|
|
266ee7e6ea | ||
|
|
045212139a | ||
|
|
6772150bc4 | ||
|
|
b2ba585251 | ||
|
|
7fcdc6e3bc | ||
|
|
b19188919d | ||
|
|
488308a2de | ||
|
|
f5d58913cc | ||
|
|
5ce1ea3dfc | ||
|
|
483a9304ca | ||
|
|
5183f63bc5 | ||
|
|
bcd6322ee9 | ||
|
|
e71e17a8df | ||
|
|
08cfbd5dd7 |
No files matched your search
@@ -64,3 +64,7 @@ Generated-in-code assets are original and have no file to list:
|
||||
| `crates/ui-egui/src/i18n/ru.tsv` | @pechnikov2026-star | Original Russian translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
|
||||
| `crates/ui-egui/src/i18n/fr.tsv` | @pechnikov2026-star | Original French translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
|
||||
| `crates/ui-egui/src/i18n/zh.tsv` | @mayloglog | Original Simplified Chinese translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
|
||||
| `crates/ui-egui/src/i18n/sv.tsv` | Erik Holmbom (@holmbom) with Claude Code (AI-assisted) | Original Swedish translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied; terminology and the running app reviewed by a native speaker |
|
||||
| `crates/ui-egui/src/i18n/it.tsv` | Emanuele Bottelli (AI-assisted) | Original Italian translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
|
||||
| `crates/ui-egui/src/i18n/ko.tsv` | @huk082 (AI-assisted) | Original Korean translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied; no font assets added |
|
||||
| `crates/ui-egui/src/i18n/ar.tsv` | @hasan-sdaif | Original Arabic translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
|
||||
Generated
+54
-22
@@ -4,9 +4,9 @@ version = 4
|
||||
|
||||
[[package]]
|
||||
name = "acadrust"
|
||||
version = "0.6.3"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7df99ae4f5a6d0147907f9976fe1dd166d01e72ab1a93820b50242937c5797e6"
|
||||
checksum = "94862189cab6e282ed0433a801a216cd1a712953813284a48fa81c67223e38f6"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags 2.13.2",
|
||||
@@ -18,6 +18,7 @@ dependencies = [
|
||||
"itoa",
|
||||
"memmap2",
|
||||
"nom",
|
||||
"oem_cp",
|
||||
"once_cell",
|
||||
"rayon",
|
||||
"ryu",
|
||||
@@ -45,7 +46,7 @@ dependencies = [
|
||||
"accesskit",
|
||||
"accesskit_consumer 0.36.0",
|
||||
"atspi-common",
|
||||
"phf",
|
||||
"phf 0.13.1",
|
||||
"serde",
|
||||
"zvariant",
|
||||
]
|
||||
@@ -546,7 +547,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft"
|
||||
version = "0.6.2"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"cadcraft-engine",
|
||||
"cadcraft-io",
|
||||
@@ -556,9 +557,11 @@ dependencies = [
|
||||
"hidapi",
|
||||
"log",
|
||||
"muda",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"rfd",
|
||||
"serde_json",
|
||||
"sys-locale",
|
||||
"windows",
|
||||
"winit",
|
||||
"winresource",
|
||||
"zbus",
|
||||
@@ -566,7 +569,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-cli"
|
||||
version = "0.6.2"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"cadcraft-engine",
|
||||
"cadcraft-io",
|
||||
@@ -577,14 +580,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-color"
|
||||
version = "0.6.2"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-constraints"
|
||||
version = "0.6.2"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"cadcraft-doc",
|
||||
"cadcraft-geom",
|
||||
@@ -594,7 +597,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-doc"
|
||||
version = "0.6.2"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-geom",
|
||||
@@ -605,14 +608,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-dwg"
|
||||
version = "0.6.2"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"acadrust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-dxf"
|
||||
version = "0.6.2"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"log",
|
||||
@@ -621,7 +624,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-engine"
|
||||
version = "0.6.2"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-constraints",
|
||||
@@ -640,7 +643,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-fonts"
|
||||
version = "0.6.2"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"cadcraft-geom",
|
||||
"harfrust",
|
||||
@@ -650,14 +653,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-geom"
|
||||
version = "0.6.2"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-io"
|
||||
version = "0.6.2"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-doc",
|
||||
@@ -671,11 +674,12 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
"tiny-skia",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-mcp"
|
||||
version = "0.6.2"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"cadcraft-engine",
|
||||
"cadcraft-io",
|
||||
@@ -686,7 +690,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-render"
|
||||
version = "0.6.2"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-doc",
|
||||
@@ -700,11 +704,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-ui-egui"
|
||||
version = "0.6.2"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-doc",
|
||||
"cadcraft-engine",
|
||||
"cadcraft-fonts",
|
||||
"cadcraft-geom",
|
||||
"cadcraft-render",
|
||||
"craft-ui",
|
||||
@@ -721,7 +726,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-web"
|
||||
version = "0.6.2"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"cadcraft-engine",
|
||||
"cadcraft-io",
|
||||
@@ -2707,6 +2712,15 @@ dependencies = [
|
||||
"objc2-foundation 0.2.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oem_cp"
|
||||
version = "2.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8c7959d3349f484aec36462f98226983578bf7d33f9b034c32967489c7000e1f"
|
||||
dependencies = [
|
||||
"phf 0.11.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
@@ -2800,6 +2814,15 @@ version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "phf"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
|
||||
dependencies = [
|
||||
"phf_shared 0.11.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf"
|
||||
version = "0.13.1"
|
||||
@@ -2807,7 +2830,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
|
||||
dependencies = [
|
||||
"phf_macros",
|
||||
"phf_shared",
|
||||
"phf_shared 0.13.1",
|
||||
"serde",
|
||||
]
|
||||
|
||||
@@ -2818,7 +2841,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"phf_shared",
|
||||
"phf_shared 0.13.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2828,12 +2851,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
|
||||
dependencies = [
|
||||
"phf_generator",
|
||||
"phf_shared",
|
||||
"phf_shared 0.13.1",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_shared"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
|
||||
dependencies = [
|
||||
"siphasher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_shared"
|
||||
version = "0.13.1"
|
||||
@@ -4772,7 +4804,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.6.2"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
# default-members
|
||||
|
||||
[workspace.package]
|
||||
version = "0.6.2"
|
||||
version = "0.7.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
|
||||
@@ -111,7 +111,7 @@ numbers):
|
||||
| Layouts & plotting | Paper-space layouts, viewports (scale, lock, per-viewport layer freeze), MSPACE/PSPACE through viewports, page setups, PLOT and EXPORTPDF |
|
||||
| Parametric | Geometric and dimensional constraints, AUTOCONSTRAIN, PARAMETERS with expressions, conflict detection; constraints re-solve after every edit |
|
||||
| Grips | Hot grips with Space to cycle Stretch, Move, Rotate, Scale and Mirror |
|
||||
| Interface languages | English, Ukrainian (Українська), Brazilian Portuguese (Português (Brasil)), German (Deutsch), Spanish (Español), Czech (Čeština), Russian (Русский), French (Français) and Simplified Chinese (简体中文, with an installed CJK font), live switching and a remembered Auto/system-language choice; [details](docs/localization.md) |
|
||||
| Interface languages | English, Ukrainian (Українська), Brazilian Portuguese (Português (Brasil)), German (Deutsch), Spanish (Español), Czech (Čeština), Russian (Русский), French (Français), Simplified Chinese (简体中文, with an installed CJK font), Italian (Italiano), Korean (한국어, with an installed Hangul font), Swedish (Svenska) and Arabic (العربية, with an installed Arabic font), live switching and a remembered Auto/system-language choice; [details](docs/localization.md) |
|
||||
| Automation | MCP server, JSON control channel, `cadcraft-cli` (info, convert, run, commands, mcp) |
|
||||
|
||||
## Quick start
|
||||
@@ -252,8 +252,25 @@ have accessible labels and visible keyboard focus. Property controls remain appl
|
||||
|
||||
## Accessibility
|
||||
|
||||
### System cursor
|
||||
|
||||
Over the drawing area CADCraft hides the system cursor and draws its own crosshair. Tools that follow the system cursor (Windows Magnifier set to follow or centre the mouse pointer, other screen magnifiers, screen recorders, remote desktops) lose track of it there. Turn on **View ▸ Accessibility ▸ Show System Cursor** to keep a small system crosshair visible at the centre of the drawn one, or start CADCraft with the environment variable `CADCRAFT_SYSTEM_CURSOR=1` (on Windows, set it once with `setx CADCRAFT_SYSTEM_CURSOR 1` and start CADCraft again). Agents can switch it with the control channel: `ui.set {"systemCursor": true}`.
|
||||
|
||||
### Animation effects
|
||||
|
||||
CADCraft follows the operating system's animation setting. When the system is set to show no animations, dialogs, menus and tooltips appear at once instead of fading in, collapsing sections and scroll bars change at once, and the mouse wheel scrolls lists a whole step at a time instead of gliding. Zooming the drawing with the wheel always moves a whole step; with Ctrl held (a touchpad pinch, on Windows) it glides to the new zoom only while animation effects show.
|
||||
|
||||
| System | Where the setting is |
|
||||
|---|---|
|
||||
| Windows | Settings ▸ Accessibility ▸ Visual effects ▸ Animation effects |
|
||||
| macOS | System Settings ▸ Accessibility ▸ Reduce motion |
|
||||
| Linux, BSD | the desktop's reduced-motion (or animations) setting, read through the XDG Desktop Portal |
|
||||
| Web | the browser's `prefers-reduced-motion` |
|
||||
|
||||
A change to the system setting is picked up while CADCraft runs, within a few seconds. To choose for CADCraft alone, use **View ▸ Accessibility ▸ Animation Effects**: **Use System Setting** (the default), **On** or **Off**. Options ▸ Display has the same choice. Agents can set it with the control channel: `ui.set {"animations": "off"}` (`"system"`, `"on"` or `"off"`), and `ui.inspect` reports whether animations show (`animations`).
|
||||
|
||||
Drafting feedback is not an animation effect and stays as it is: rubber bands and previews, snap markers, the pause that acquires an object snap tracking point, the text cursor's blink, the spinner shown while a drawing opens, and pan and zoom, which follow the pointer and the wheel directly.
|
||||
|
||||
## Roadmap
|
||||
|
||||
See [ROADMAP.md](ROADMAP.md) for the stage, measured parity and the estimate of remaining work, and [docs/gaps.md](docs/gaps.md) for everything still missing.
|
||||
|
||||
+6
-2
@@ -10,7 +10,7 @@ scriptable CLI, a web build, Linux and FreeBSD builds, and a free licence.
|
||||
|
||||
**Why pre-alpha:** four of AutoCAD's five core workflows fail the [alpha gate](docs/roadmap.md#alpha-gate).
|
||||
Precise drafting lacks object snap tracking and has stubbed TRIM/EXTEND/OFFSET options; blocks
|
||||
can't be edited (no block editor, REFEDIT or xrefs); plots ignore plot styles; and a DWG saved by
|
||||
can't be edited in a block editor (REFEDIT edits them in place, xrefs not yet); plots ignore plot styles; and a DWG saved by
|
||||
CADCraft drops everything it doesn't model and has never been checked in
|
||||
AutoCAD. Dimensioning passes. Ready for real work (≈ 38%) is also under the ~40% alpha bar. Beta
|
||||
is ≈ 600–930 h away.
|
||||
@@ -98,7 +98,7 @@ Ukrainian and German. Detail: [localization-parity.md](docs/localization-parity.
|
||||
| Korean | none | 0% |
|
||||
| Vietnamese | none | 0% |
|
||||
|
||||
Other languages shipped: Ukrainian and German, both partial (989 catalog rows each; Ukrainian landed with #36; engine messages stay English).
|
||||
Other languages shipped: Ukrainian and German (989 catalog rows each; Ukrainian landed with #36), Brazilian Portuguese, Spanish, Czech, Russian, French, Simplified Chinese and Italian (1,227 rows, 2026-10-12); engine messages stay English.
|
||||
|
||||
## Upcoming
|
||||
|
||||
@@ -118,6 +118,8 @@ Ranked; detail and the full beta checklist in [docs/roadmap.md](docs/roadmap.md)
|
||||
|
||||
| Date | Entry |
|
||||
|---|---|
|
||||
| 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) |
|
||||
| 2026-10-10 | Merged main: Ukrainian interface (#36, first string catalog), Dynamic Input pointer boxes with Tab and relative entry (#60), press-drag selection windows (#63), WIPEOUT and hatch gradients kept through DXF/DWG save (#147, #162), SETVAR DIM* as style overrides (#129), UNITS dialog (#38), close-window save prompt (#47), DIMANGULAR on circles (#20), cargo fmt fixed on main. UI/UX 40% → 43% (dynamic input 15% → 45%), localization 3% → 5%; ready for real work ≈ 37% → 38% (weighted 37.2% → 37.7%), mainstream 27% → 28%, essentials 45% → 46%. Beta is now ~37 points away; hours unchanged within rounding. |
|
||||
| 2026-10-10 | Added mainstream practitioner (≈ 27%) and essentials user (≈ 45%) numbers; ready for real work unchanged at ≈ 37%. User evidence: 2 praise comments, 0 "switched from AutoCAD" reports, 27 of 37 open issues on the core path. |
|
||||
| 2026-10-10 | Stage re-normalized from alpha to **pre-alpha** under the craftrules core-workflow gate: drafting with tracking, blocks, plotting and lossless DWG save fail; dimensioning passes. Alpha is ≈ 125–190 h away. |
|
||||
@@ -131,6 +133,8 @@ Ranked; detail and the full beta checklist in [docs/roadmap.md](docs/roadmap.md)
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | trivial | Progress log: closed-polyline TRIM (#726) |
|
||||
| 2026-10-12 | trivial | Progress log: HATCHEDIT numeric validation (#724) |
|
||||
| 2026-10-10 | minor | Merged main's changes (Ukrainian catalog, dynamic input pointer boxes, DXF fixes); UI/UX and localization numbers updated |
|
||||
| 2026-10-10 | minor | Readiness-by-audience table with hours to ~95% for each number |
|
||||
| 2026-10-10 | minor | Mainstream practitioner and essentials user headline numbers |
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! cadcraft-cli convert IN.dxf OUT.(dxf|svg|png) [--window x0,y0,x1,y1 | --view extents|current]
|
||||
//! cadcraft-cli run [FILE|--sample|--metric] [--script TEXT|--script-file F.scr] [--cmd 'id {json}']... [--save OUT] [--export OUT.png] [--window ... | --view ...]
|
||||
//! cadcraft-cli commands [FILTER] command catalog (JSON)
|
||||
//! cadcraft-cli mcp [--connect HOST:PORT] MCP server on stdio (headless or bridged to the app)
|
||||
//! cadcraft-cli mcp [--connect HOST:PORT [--lazy] [--launch]] MCP server on stdio (headless or bridged to the app)
|
||||
//! cadcraft-cli perf [N] timing table on a synthetic N-entity drawing
|
||||
//! cadcraft-cli perf FILE canvas display-list build of a drawing, zoomed in step by step
|
||||
//! cadcraft-cli sample (bracket|floorplan) OUT write a built-in sample drawing
|
||||
@@ -60,7 +60,7 @@ const USAGE: &str = "usage:
|
||||
cadcraft-cli convert IN.dxf OUT.(dxf|dwg|svg|png|pdf) [FRAMING]
|
||||
cadcraft-cli run [FILE | --sample | --metric] [--script TEXT] [--script-file F.scr] [--cmd 'id {json}']... [--save OUT.dxf] [--export OUT.(png|svg|pdf)] [FRAMING]
|
||||
cadcraft-cli commands [FILTER]
|
||||
cadcraft-cli mcp [--connect HOST:PORT]
|
||||
cadcraft-cli mcp [--connect HOST:PORT [--lazy] [--launch]]
|
||||
cadcraft-cli perf [N | FILE]
|
||||
cadcraft-cli sample (bracket|floorplan) OUT.(dxf|dwg|svg|png)
|
||||
cadcraft-cli --version
|
||||
@@ -172,6 +172,11 @@ fn install_io() {
|
||||
cadcraft_engine::cmd::file::set_write_as(|d, name, o| {
|
||||
cadcraft_io::write_with(d, name, &cadcraft_io::SaveOptions { version: o.version, binary: o.binary }).map_err(|e| e.to_string())
|
||||
});
|
||||
// AutoLISP's entget, entmake and entmod see entities as DXF groups.
|
||||
cadcraft_engine::cmd::file::set_entity_codec(cadcraft_engine::cmd::file::EntityCodec {
|
||||
groups: cadcraft_io::entity_groups,
|
||||
entity: cadcraft_io::entity_from_groups,
|
||||
});
|
||||
}
|
||||
|
||||
fn open(s: &mut Session, path: &str) -> Result<(), String> {
|
||||
@@ -267,10 +272,24 @@ fn convert(args: &[String]) -> Result<(), String> {
|
||||
check_framed_output(framing, Some(o))?;
|
||||
let mut s = Session::empty();
|
||||
open(&mut s, i)?;
|
||||
if let Some(m) = skipped_message(s.doc().map_err(|e| e.to_string())?, i) {
|
||||
eprintln!("{m}");
|
||||
}
|
||||
let window = export_window(&s, framing, None)?;
|
||||
export_framed(&s, o, window)
|
||||
}
|
||||
|
||||
/// The warning for the records of `path` the reader could not place, or `None` when there are none.
|
||||
fn skipped_message(d: &cadcraft_engine::doc::Drawing, path: &str) -> Option<String> {
|
||||
let n = d.skipped_records.values().fold(0usize, |a, n| a.saturating_add(*n));
|
||||
if n == 0 {
|
||||
return None;
|
||||
}
|
||||
let list: Vec<String> = d.skipped_records.iter().map(|(section, n)| format!("{section}: {n}")).collect();
|
||||
let records = if n == 1 { "record" } else { "records" };
|
||||
Some(format!("{path}: {n} {records} could not be read and are left out ({})", list.join(", ")))
|
||||
}
|
||||
|
||||
fn run(args: &[String]) -> Result<(), String> {
|
||||
let mut s = Session::empty();
|
||||
let mut it = args.iter();
|
||||
@@ -524,37 +543,47 @@ fn perf(args: &[String]) -> Result<(), String> {
|
||||
enum McpTarget {
|
||||
/// An in-process drawing session (no `--connect`).
|
||||
Headless,
|
||||
/// The running app's control channel at `HOST:PORT`.
|
||||
Connect(String),
|
||||
/// The running app's control channel at `HOST:PORT`; `lazy`: reached at the first call;
|
||||
/// `launch`: opened then if it isn't running.
|
||||
Connect { addr: String, lazy: bool, launch: bool },
|
||||
}
|
||||
|
||||
/// Parses the arguments after `mcp`. `--connect` must be followed by a `HOST:PORT`; a missing,
|
||||
/// empty or flag-like value is an error, never a silent fall back to a headless session (#103).
|
||||
fn parse_mcp_args(args: &[String]) -> Result<McpTarget, String> {
|
||||
let mut target = McpTarget::Headless;
|
||||
let mut addr: Option<String> = None;
|
||||
let (mut lazy, mut launch) = (false, false);
|
||||
let mut it = args.iter();
|
||||
while let Some(a) = it.next() {
|
||||
match a.as_str() {
|
||||
"--connect" => {
|
||||
let addr = it
|
||||
let a = it
|
||||
.next()
|
||||
.map(|v| v.trim())
|
||||
.filter(|v| !v.is_empty() && !v.starts_with('-'))
|
||||
.ok_or("--connect needs HOST:PORT (the app's --control port, e.g. 127.0.0.1:7979)")?;
|
||||
if target != McpTarget::Headless {
|
||||
if addr.is_some() {
|
||||
return Err("--connect given more than once".into());
|
||||
}
|
||||
target = McpTarget::Connect(addr.to_string());
|
||||
addr = Some(a.to_string());
|
||||
}
|
||||
"--lazy" => lazy = true,
|
||||
"--launch" => launch = true,
|
||||
other => return Err(format!("mcp: unknown argument {other}\n{USAGE}")),
|
||||
}
|
||||
}
|
||||
Ok(target)
|
||||
match addr {
|
||||
// `--launch` connects at the first call too (the app opens then).
|
||||
Some(addr) => Ok(McpTarget::Connect { addr, lazy: lazy || launch, launch }),
|
||||
None if lazy || launch => Err("--lazy and --launch need --connect HOST:PORT".into()),
|
||||
None => Ok(McpTarget::Headless),
|
||||
}
|
||||
}
|
||||
|
||||
fn mcp(args: &[String]) -> Result<(), String> {
|
||||
let backend: Box<dyn cadcraft_mcp::Backend> = match parse_mcp_args(args)? {
|
||||
McpTarget::Connect(addr) => match cadcraft_mcp::Remote::connect(&addr) {
|
||||
McpTarget::Connect { addr, lazy: true, launch } => Box::new(cadcraft_mcp::Remote::lazy(&addr, launch)),
|
||||
McpTarget::Connect { addr, .. } => match cadcraft_mcp::Remote::connect(&addr) {
|
||||
Ok(r) => Box::new(r),
|
||||
Err(e) => return Err(format!("cadcraft-cli: cannot connect to {addr}: {e}")),
|
||||
},
|
||||
@@ -580,8 +609,14 @@ mod mcp_args_tests {
|
||||
|
||||
#[test]
|
||||
fn mcp_connect_with_address_selects_remote() {
|
||||
assert_eq!(parse_mcp_args(&args(&["--connect", "127.0.0.1:7979"])), Ok(McpTarget::Connect("127.0.0.1:7979".into())));
|
||||
assert_eq!(parse_mcp_args(&args(&["--connect", "localhost:7979"])), Ok(McpTarget::Connect("localhost:7979".into())));
|
||||
let to = |addr: &str, lazy, launch| Ok(McpTarget::Connect { addr: addr.into(), lazy, launch });
|
||||
assert_eq!(parse_mcp_args(&args(&["--connect", "127.0.0.1:7979"])), to("127.0.0.1:7979", false, false));
|
||||
assert_eq!(parse_mcp_args(&args(&["--connect", "localhost:7979"])), to("localhost:7979", false, false));
|
||||
assert_eq!(parse_mcp_args(&args(&["--lazy", "--connect", "localhost:7979"])), to("localhost:7979", true, false));
|
||||
assert_eq!(parse_mcp_args(&args(&["--connect", "localhost:7979", "--launch"])), to("localhost:7979", true, true));
|
||||
// Without an app to reach they mean nothing: never a headless session.
|
||||
assert!(parse_mcp_args(&args(&["--lazy"])).is_err());
|
||||
assert!(parse_mcp_args(&args(&["--launch"])).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
//! `info` reports the records a DXF file lost on reading, by section, and `convert` warns about
|
||||
//! them before writing the copy (#865). The exit code stays 0.
|
||||
|
||||
use std::process::{Command, Output};
|
||||
|
||||
fn cli(args: &[&str]) -> Output {
|
||||
Command::new(env!("CARGO_BIN_EXE_cadcraft-cli")).args(args).output().unwrap()
|
||||
}
|
||||
|
||||
/// A LINE after the last ENDSEC and a VERTEX with no POLYLINE before it.
|
||||
const DAMAGED: &str = "0\nSECTION\n2\nENTITIES\n0\nLINE\n8\n0\n10\n0\n20\n0\n11\n5\n21\n5\n0\nVERTEX\n8\n0\n10\n1\n20\n1\n0\nENDSEC\n0\nLINE\n8\n0\n10\n9\n20\n9\n11\n8\n21\n8\n0\nEOF\n";
|
||||
|
||||
#[test]
|
||||
fn info_counts_and_convert_warns_about_skipped_records() {
|
||||
let dir = std::env::temp_dir().join(format!("cc-cli-skipped-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let damaged = dir.join("damaged.dxf");
|
||||
std::fs::write(&damaged, DAMAGED).unwrap();
|
||||
let damaged = damaged.to_str().unwrap();
|
||||
|
||||
let out = cli(&["info", damaged]);
|
||||
assert!(out.status.success(), "{:?}: {}", out.status, String::from_utf8_lossy(&out.stderr));
|
||||
let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
|
||||
assert_eq!(v["skipped"], serde_json::json!({ "count": 2, "sections": { "(no section)": 1, "ENTITIES": 1 } }));
|
||||
|
||||
let copy = dir.join("copy.dxf");
|
||||
let out = cli(&["convert", damaged, copy.to_str().unwrap()]);
|
||||
assert!(out.status.success(), "{:?}: {}", out.status, String::from_utf8_lossy(&out.stderr));
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
let warnings: Vec<&str> = err.lines().filter(|l| l.contains("could not be read")).collect();
|
||||
assert_eq!(warnings.len(), 1, "{err}");
|
||||
assert!(warnings[0].contains("2 records") && warnings[0].contains("ENTITIES: 1") && warnings[0].contains("(no section): 1"), "{err}");
|
||||
|
||||
// An intact file: a zero count, and no warning.
|
||||
let sample = dir.join("sample.dxf");
|
||||
assert!(cli(&["sample", "floorplan", sample.to_str().unwrap()]).status.success());
|
||||
let out = cli(&["info", sample.to_str().unwrap()]);
|
||||
let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
|
||||
assert_eq!(v["skipped"], serde_json::json!({ "count": 0, "sections": {} }));
|
||||
let out = cli(&["convert", sample.to_str().unwrap(), copy.to_str().unwrap()]);
|
||||
assert!(out.status.success());
|
||||
assert!(!String::from_utf8_lossy(&out.stderr).contains("could not be read"), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -29,4 +29,4 @@ web-sys = { version = "0.3", features = ["HtmlInputElement", "FileList", "File"]
|
||||
eframe = { workspace = true, features = ["persistence", "default_fonts", "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"] }
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Location", "Navigator", "Blob", "BlobPropertyBag", "Url", "MessageEvent", "UrlSearchParams", "MediaQueryList"] }
|
||||
@@ -23,14 +23,16 @@ mod web {
|
||||
const CANVAS_ID: &str = "cadcraft_canvas";
|
||||
const LOADING_ID: &str = "cadcraft_loading";
|
||||
|
||||
/// The app, the files File > Open… and drag and drop read for it in the background, and the
|
||||
/// page embedding it (`?host=parent`).
|
||||
struct Shell(CadApp, crate::upload::Inbox, Option<(crate::host::Bridge, crate::host::DirtyReport)>);
|
||||
/// 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>);
|
||||
|
||||
impl eframe::App for Shell {
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
self.1.take_dropped(ctx);
|
||||
self.1.drain(&mut self.0);
|
||||
self.0.system_animations = self.3.as_ref().map(|reduced| !reduced.matches());
|
||||
self.0.logic(ctx);
|
||||
if let Some((bridge, reported)) = &self.2 {
|
||||
bridge.report_dirty(&self.0, reported);
|
||||
@@ -80,6 +82,11 @@ mod web {
|
||||
cadcraft_engine::cmd::file::set_write_as(|d, name, o| {
|
||||
cadcraft_io::write_with(d, name, &cadcraft_io::SaveOptions { version: o.version, binary: o.binary }).map_err(|e| e.to_string())
|
||||
});
|
||||
// AutoLISP's entget, entmake and entmod see entities as DXF groups.
|
||||
cadcraft_engine::cmd::file::set_entity_codec(cadcraft_engine::cmd::file::EntityCodec {
|
||||
groups: cadcraft_io::entity_groups,
|
||||
entity: cadcraft_io::entity_from_groups,
|
||||
});
|
||||
// `?host=parent`: a same-origin page framing CADCraft (such as a Nextcloud app) opens
|
||||
// drawings in it and stores what it saves. Otherwise there is no file system here: saved
|
||||
// files reach the user as browser downloads.
|
||||
@@ -124,7 +131,9 @@ mod web {
|
||||
if !crate::wants_cpu(&query())
|
||||
&& let Some(rs) = &cc.wgpu_render_state
|
||||
{
|
||||
app.set_wgpu(rs);
|
||||
// eframe's web runner has no stencil buffer: clipped layout
|
||||
// viewports stay in the layout's display list.
|
||||
app.set_wgpu(rs, None);
|
||||
}
|
||||
if query().contains("sample") {
|
||||
let _ = app.run("ui.sample", serde_json::json!({}));
|
||||
@@ -142,7 +151,8 @@ mod web {
|
||||
bridge.post_ready();
|
||||
(bridge, crate::host::DirtyReport::default())
|
||||
});
|
||||
Ok(Box::new(Shell(app, inbox, host)))
|
||||
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)))
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -35,10 +35,16 @@ sys-locale = "0.3"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
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.
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSWorkspace", "NSAccessibility"] }
|
||||
|
||||
# Rust HID backends: no proprietary SDK or C HID library.
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
hidapi = { version = "2.6.7", default-features = false, features = ["windows-native"] }
|
||||
# View ▸ Accessibility ▸ Animation Effects ▸ Use System Setting: Windows' Animation effects
|
||||
# setting (system_animations.rs). Already in Cargo.lock through wgpu's DirectX 12 backend.
|
||||
windows = { version = "0.62", features = ["UI_ViewManagement"] }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
hidapi = { version = "2.6.7", default-features = false, features = ["linux-native-basic-udev"] }
|
||||
@@ -22,6 +22,7 @@ mod logging;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod native_menu;
|
||||
mod spacemouse;
|
||||
mod system_animations;
|
||||
mod workspace;
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
@@ -32,6 +33,8 @@ struct App {
|
||||
spacemouse: spacemouse::SpaceMouse,
|
||||
/// The desktop's light/dark choice where winit reports none (Linux, the BSDs).
|
||||
desktop_theme: desktop_theme::DesktopTheme,
|
||||
/// Whether the OS shows animations, for the System choice of View ▸ Accessibility ▸ Animation Effects.
|
||||
system_animations: system_animations::SystemAnimations,
|
||||
workspace: workspace::Workspace,
|
||||
workspace_error: Option<String>,
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -72,6 +75,7 @@ impl eframe::App for App {
|
||||
m.poll(&mut self.cad, ctx);
|
||||
}
|
||||
}
|
||||
self.cad.system_animations = self.system_animations.current();
|
||||
self.cad.logic(ctx);
|
||||
self.spacemouse.poll(&mut self.cad, ctx);
|
||||
self.persist_workspace(false);
|
||||
@@ -115,6 +119,11 @@ pub fn install_io() {
|
||||
cadcraft_engine::cmd::file::set_write_as(|d, name, o| {
|
||||
cadcraft_io::write_with(d, name, &cadcraft_io::SaveOptions { version: o.version, binary: o.binary }).map_err(|e| e.to_string())
|
||||
});
|
||||
// AutoLISP's entget, entmake and entmod see entities as DXF groups.
|
||||
cadcraft_engine::cmd::file::set_entity_codec(cadcraft_engine::cmd::file::EntityCodec {
|
||||
groups: cadcraft_io::entity_groups,
|
||||
entity: cadcraft_io::entity_from_groups,
|
||||
});
|
||||
}
|
||||
|
||||
fn services() -> Services {
|
||||
@@ -175,6 +184,9 @@ fn config_dir_from(var: impl Fn(&str) -> Option<std::ffi::OsString>) -> Option<s
|
||||
|
||||
const APP_ID: &str = "ai.storyteller.cadcraft";
|
||||
|
||||
/// Stencil bits of the window's render pass (see `NativeOptions::stencil_buffer`).
|
||||
const STENCIL_BITS: u8 = 8;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
const APP_ICON_PNG: &[u8] = include_bytes!("../../../assets/app-icon/cadcraft-macos-512.png");
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
@@ -200,6 +212,19 @@ fn control_port_from(source: &str, value: Option<String>) -> Option<u16> {
|
||||
port
|
||||
}
|
||||
|
||||
/// `--control` followed by `value`: a bare `--control` (last argument) keeps the port already
|
||||
/// read from `CADCRAFT_CONTROL_PORT` (`current`) instead of turning the control channel off.
|
||||
fn control_flag(current: Option<u16>, value: Option<String>) -> Option<u16> {
|
||||
if value.is_some() {
|
||||
return control_port_from("--control", value);
|
||||
}
|
||||
match current {
|
||||
Some(port) => log::warn!("--control: no port given; using CADCRAFT_CONTROL_PORT ({port})"),
|
||||
None => log::warn!("--control: no port given; the control channel is off"),
|
||||
}
|
||||
current
|
||||
}
|
||||
|
||||
fn main() -> eframe::Result {
|
||||
// First, so every start-up record is captured; see `logging`.
|
||||
let logger = logging::install();
|
||||
@@ -211,7 +236,7 @@ fn main() -> eframe::Result {
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(a) = args.next() {
|
||||
match a.as_str() {
|
||||
"--control" => control_port = control_port_from("--control", args.next()),
|
||||
"--control" => control_port = control_flag(control_port, args.next()),
|
||||
"--sample" => sample = true,
|
||||
"--version" | "-V" => {
|
||||
println!("{}", version_string());
|
||||
@@ -248,6 +273,8 @@ fn main() -> eframe::Result {
|
||||
.with_title_shown(false)
|
||||
.with_app_id(APP_ID),
|
||||
persist_window: false,
|
||||
// A stencil buffer clips the contents of clipped layout viewports on the GPU.
|
||||
stencil_buffer: STENCIL_BITS,
|
||||
..Default::default()
|
||||
};
|
||||
if let Some(icon) = app_icon() {
|
||||
@@ -304,13 +331,20 @@ fn main() -> eframe::Result {
|
||||
// Autosave files (SAVEFILEPATH) default to `<settings dir>/autosave`.
|
||||
if let Some(dir) = config_dir() {
|
||||
app.enable_autosave(&dir.join("autosave"));
|
||||
// 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");
|
||||
if let Err(e) = std::fs::create_dir_all(&support) {
|
||||
log::warn!("no LISP support folder {}: {e}", support.display());
|
||||
}
|
||||
app.session.set_lisp_support_dir(Some(support));
|
||||
}
|
||||
app.integrated_titlebar = cfg!(target_os = "macos");
|
||||
if surface.present_mode == eframe::wgpu::PresentMode::AutoNoVsync {
|
||||
app.frame_cap = Some(cadcraft_ui_egui::gpu::FrameCap::new(240));
|
||||
}
|
||||
if let Some(rs) = &cc.wgpu_render_state {
|
||||
app.set_wgpu(rs);
|
||||
app.set_wgpu(rs, eframe::egui_wgpu::depth_format_from_bits(0, STENCIL_BITS));
|
||||
}
|
||||
if let Some(port) = control_port {
|
||||
let rx = control_server::start(port, cc.egui_ctx.clone());
|
||||
@@ -327,8 +361,11 @@ fn main() -> eframe::Result {
|
||||
}
|
||||
let ctx = cc.egui_ctx.clone();
|
||||
let desktop_theme = desktop_theme::DesktopTheme::start(move || ctx.request_repaint());
|
||||
let ctx = cc.egui_ctx.clone();
|
||||
let system_animations = system_animations::SystemAnimations::start(move || ctx.request_repaint());
|
||||
Ok(Box::new(App {
|
||||
desktop_theme,
|
||||
system_animations,
|
||||
cad: app,
|
||||
spacemouse: spacemouse::SpaceMouse::start(cc.egui_ctx.clone()),
|
||||
workspace,
|
||||
@@ -342,7 +379,7 @@ fn main() -> eframe::Result {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{config_dir_from, control_port_from, filters_for_save, follow_desktop};
|
||||
use super::{config_dir_from, control_flag, control_port_from, filters_for_save, follow_desktop};
|
||||
use std::ffi::OsString;
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -376,6 +413,16 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_without_a_port_keeps_the_environment_port() {
|
||||
// `CADCRAFT_CONTROL_PORT=47321 cadcraft --control` (#844)
|
||||
assert_eq!(control_flag(Some(47321), None), Some(47321));
|
||||
assert_eq!(control_flag(None, None), None);
|
||||
// A port given on the command line still wins over the variable.
|
||||
assert_eq!(control_flag(Some(47321), Some("7979".into())), Some(7979));
|
||||
assert_eq!(control_flag(Some(47321), Some("abc".into())), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_override_variable_wins_and_empty_values_are_unset() {
|
||||
let vars = [("CADCRAFT_CONFIG_DIR", "/tmp/cc"), ("HOME", "/home/u"), ("XDG_CONFIG_HOME", "/x"), ("APPDATA", "C:\\a")];
|
||||
|
||||
@@ -16,6 +16,9 @@ pub struct NativeMenu {
|
||||
predefined: Vec<(&'static str, PredefinedMenuItem)>,
|
||||
language: &'static str,
|
||||
last_refresh: f64,
|
||||
/// The recent files File ▸ Open Recent lists now (it is rebuilt when they change).
|
||||
recent: Vec<String>,
|
||||
counter: usize,
|
||||
}
|
||||
|
||||
/// Accelerator for "Cmd+Shift+S" (modifier-less shortcuts stay in the app so typing works).
|
||||
@@ -63,7 +66,25 @@ impl NativeMenu {
|
||||
submenus.push((title, sub));
|
||||
}
|
||||
menu.init_for_nsapp();
|
||||
Self { _menu: menu, items, submenus, predefined, language, last_refresh: 0.0 }
|
||||
Self { _menu: menu, items, submenus, predefined, language, last_refresh: 0.0, recent: app.ui.recent_files.clone(), counter }
|
||||
}
|
||||
|
||||
/// Rebuild File ▸ Open Recent after a drawing was opened or the list was cleared: the native
|
||||
/// menu is built once, and its items name the files by position.
|
||||
fn refresh_recent(&mut self, app: &CadApp) {
|
||||
if self.recent == app.ui.recent_files {
|
||||
return;
|
||||
}
|
||||
self.recent = app.ui.recent_files.clone();
|
||||
let Some(sub) = self.submenus.iter().find(|(label, _)| label == menus::OPEN_RECENT).map(|(_, s)| s.clone()) else { return };
|
||||
for _ in sub.items() {
|
||||
sub.remove_at(0);
|
||||
}
|
||||
let ids: Vec<String> = self.items.iter().filter(|(_, (cmd, _, _))| cmd.starts_with("ui.recent.")).map(|(id, _)| id.clone()).collect();
|
||||
for id in ids {
|
||||
self.items.remove(&id);
|
||||
}
|
||||
build(&sub, &menus::recent_entries(app), &mut self.items, &mut self.submenus, &mut self.counter, self.language);
|
||||
}
|
||||
|
||||
pub fn poll(&mut self, app: &mut CadApp, ctx: &egui::Context) {
|
||||
@@ -77,6 +98,7 @@ impl NativeMenu {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
self.refresh_recent(app);
|
||||
let language = app.language();
|
||||
if self.language != language {
|
||||
self.language = language;
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
//! Whether the operating system shows animations, for View ▸ Accessibility ▸ Animation Effects ▸
|
||||
//! Use System Setting.
|
||||
//!
|
||||
//! egui animates whatever the OS says (windows and menus fade in, wheel scrolling glides), and
|
||||
//! winit reports no such setting. This asks the OS itself:
|
||||
//!
|
||||
//! - Windows: Settings ▸ Accessibility ▸ Visual effects ▸ Animation effects.
|
||||
//! - macOS: System Settings ▸ Accessibility ▸ Reduce motion.
|
||||
//! - Linux and the BSDs: the XDG Desktop Portal's Settings interface, as for the theme
|
||||
//! (`desktop_theme.rs`): its `reduced-motion`, or GNOME's own `enable-animations`.
|
||||
//!
|
||||
//! The answer goes to `CadApp::system_animations` once a frame.
|
||||
|
||||
use std::sync::mpsc::{Receiver, TryRecvError, channel};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// How often the OS is asked again, so a change to its setting is followed while CADCraft runs.
|
||||
const POLL: Duration = Duration::from_secs(3);
|
||||
|
||||
/// Watches the OS setting.
|
||||
#[derive(Default)]
|
||||
pub struct SystemAnimations {
|
||||
/// The watcher thread's answers, where asking can block (the portal); `None` elsewhere, or
|
||||
/// when no thread could be started.
|
||||
changes: Option<Receiver<Option<bool>>>,
|
||||
/// The OS's last answer.
|
||||
current: Option<bool>,
|
||||
/// When the OS was last asked from the UI thread, where asking is one quick call.
|
||||
asked: Option<Instant>,
|
||||
}
|
||||
|
||||
impl SystemAnimations {
|
||||
/// Start watching. Where a thread does the asking, `wake` is called after each answer, so an
|
||||
/// idle window repaints and picks it up.
|
||||
pub fn start(wake: impl Fn() + Send + 'static) -> Self {
|
||||
if !ASKS_THE_PORTAL {
|
||||
return Self::default();
|
||||
}
|
||||
let (tx, rx) = channel();
|
||||
let started = std::thread::Builder::new().name("cadcraft-system-animations".into()).spawn(move || {
|
||||
let mut last = read();
|
||||
if tx.send(last).is_err() {
|
||||
return;
|
||||
}
|
||||
wake();
|
||||
loop {
|
||||
std::thread::sleep(POLL);
|
||||
let now = read();
|
||||
if now != last {
|
||||
last = now;
|
||||
if tx.send(now).is_err() {
|
||||
return;
|
||||
}
|
||||
wake();
|
||||
}
|
||||
}
|
||||
});
|
||||
Self { changes: started.ok().map(|_| rx), ..Self::default() }
|
||||
}
|
||||
|
||||
/// Whether the OS shows animations; `None` when it states nothing or there is nobody to ask.
|
||||
/// Polled once a frame; an OS that stops answering keeps its last answer.
|
||||
pub fn current(&mut self) -> Option<bool> {
|
||||
if let Some(changes) = &self.changes {
|
||||
loop {
|
||||
match changes.try_recv() {
|
||||
Ok(Some(animate)) => self.current = Some(animate),
|
||||
Ok(None) => {}
|
||||
Err(TryRecvError::Empty) | Err(TryRecvError::Disconnected) => break,
|
||||
}
|
||||
}
|
||||
} else if !ASKS_THE_PORTAL && self.asked.is_none_or(|at| at.elapsed() >= POLL) {
|
||||
self.asked = Some(Instant::now());
|
||||
if let Some(animate) = read() {
|
||||
self.current = Some(animate);
|
||||
}
|
||||
}
|
||||
self.current
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the answer comes over D-Bus, which can block: a thread asks. Elsewhere the UI thread asks.
|
||||
const ASKS_THE_PORTAL: bool = cfg!(all(unix, not(target_os = "macos")));
|
||||
|
||||
/// Ask Windows once: `UISettings::AnimationsEnabled`, the setting behind
|
||||
/// `SPI_GETCLIENTAREAANIMATION`.
|
||||
#[cfg(windows)]
|
||||
fn read() -> Option<bool> {
|
||||
windows::UI::ViewManagement::UISettings::new().and_then(|settings| settings.AnimationsEnabled()).ok()
|
||||
}
|
||||
|
||||
/// Ask macOS once. Called on the main thread (`SystemAnimations::current`).
|
||||
#[cfg(target_os = "macos")]
|
||||
fn read() -> Option<bool> {
|
||||
Some(!objc2_app_kit::NSWorkspace::sharedWorkspace().accessibilityDisplayShouldReduceMotion())
|
||||
}
|
||||
|
||||
/// Ask the portal once. D-Bus calls block, so this belongs off the UI thread: the watcher's
|
||||
/// thread is its only caller.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
fn read() -> Option<bool> {
|
||||
const PORTAL: &str = "org.freedesktop.portal.Desktop";
|
||||
const PATH: &str = "/org/freedesktop/portal/desktop";
|
||||
const SETTINGS: &str = "org.freedesktop.portal.Settings";
|
||||
// The standard key first (0 no preference, 1 reduced motion); then GNOME's own switch, which
|
||||
// its portal answered before the standard key existed.
|
||||
const KEYS: [(&str, &str); 2] = [("org.freedesktop.appearance", "reduced-motion"), ("org.gnome.desktop.interface", "enable-animations")];
|
||||
let connection = zbus::blocking::Connection::session().ok()?;
|
||||
for (namespace, key) in KEYS {
|
||||
// `ReadOne` (Settings version 2) answers the value; older portals only have `Read`, which
|
||||
// wraps it in one more variant. `parse` unwraps either.
|
||||
let reply = connection
|
||||
.call_method(Some(PORTAL), PATH, Some(SETTINGS), "ReadOne", &(namespace, key))
|
||||
.or_else(|_| connection.call_method(Some(PORTAL), PATH, Some(SETTINGS), "Read", &(namespace, key)));
|
||||
let Ok(reply) = reply else { continue };
|
||||
let Ok((answer,)) = reply.body().deserialize::<(zbus::zvariant::OwnedValue,)>() else { continue };
|
||||
if let Some(animate) = parse(&answer, 0) {
|
||||
return Some(animate);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The portal's answer as "show animations": `reduced-motion` is a number (1 asks for less
|
||||
/// motion; 0 and values from a later version state no preference), `enable-animations` a
|
||||
/// boolean. `None` when it reads as neither. Variants are unwrapped a few levels deep.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
fn parse(value: &zbus::zvariant::Value<'_>, depth: u8) -> Option<bool> {
|
||||
use zbus::zvariant::Value;
|
||||
match value {
|
||||
Value::U32(reduced) => Some(*reduced != 1),
|
||||
Value::Bool(enabled) => Some(*enabled),
|
||||
Value::Value(inner) if depth < 4 => parse(inner, depth + 1),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
fn read() -> Option<bool> {
|
||||
None
|
||||
}
|
||||
|
||||
// macOS is asked on the main thread only, so there is nothing to test off it.
|
||||
#[cfg(all(test, not(target_os = "macos")))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Windows is asked on the first frame, then not again until the poll interval has passed.
|
||||
#[test]
|
||||
#[cfg(windows)]
|
||||
fn the_os_is_asked_on_the_first_frame_and_its_answer_kept() {
|
||||
let mut watch = SystemAnimations::start(|| {});
|
||||
assert!(watch.changes.is_none(), "no thread: asking is one quick call");
|
||||
let first = watch.current();
|
||||
assert_eq!(first, read());
|
||||
let asked = watch.asked;
|
||||
assert!(asked.is_some());
|
||||
assert_eq!(watch.current(), first);
|
||||
assert_eq!(watch.asked, asked, "not asked again within the poll interval");
|
||||
}
|
||||
|
||||
/// The standard key answers a number, GNOME's switch a boolean; either may come wrapped in
|
||||
/// variants (the older `Read`).
|
||||
#[test]
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
fn the_portal_answer_reads_as_show_animations_or_as_none() {
|
||||
use zbus::zvariant::Value;
|
||||
assert_eq!(parse(&Value::U32(1), 0), Some(false));
|
||||
assert_eq!(parse(&Value::U32(0), 0), Some(true));
|
||||
assert_eq!(parse(&Value::U32(7), 0), Some(true), "unknown values are no preference");
|
||||
assert_eq!(parse(&Value::Bool(false), 0), Some(false));
|
||||
assert_eq!(parse(&Value::Bool(true), 0), Some(true));
|
||||
assert_eq!(parse(&Value::Value(Box::new(Value::U32(1))), 0), Some(false));
|
||||
assert_eq!(parse(&Value::Str("off".into()), 0), None);
|
||||
// A hostile answer nested without end is given up on.
|
||||
let mut nested = Value::U32(1);
|
||||
for _ in 0..16 {
|
||||
nested = Value::Value(Box::new(nested));
|
||||
}
|
||||
assert_eq!(parse(&nested, 0), None);
|
||||
}
|
||||
}
|
||||
@@ -48,3 +48,16 @@ sync_github_name = true
|
||||
|
||||
[people.Losminek]
|
||||
sync_github_name = true
|
||||
|
||||
[people.GJoe2]
|
||||
sync_github_name = true
|
||||
|
||||
[people.holmbom]
|
||||
real_name = "Erik Holmbom"
|
||||
display_name = "Erik Holmbom"
|
||||
sync_github_name = false
|
||||
|
||||
[people.cyfung1031]
|
||||
real_name = "Chester Fung"
|
||||
display_name = "Chester Fung"
|
||||
sync_github_name = true
|
||||
@@ -500,11 +500,14 @@ impl Model {
|
||||
}
|
||||
(EntityKind::Arc(a), Shape::Arc) => {
|
||||
if let [cx, cy, r, s0, s1] = new {
|
||||
// A negative radius puts every point opposite its angle: turning both
|
||||
// ends half a turn keeps the same points with a positive radius.
|
||||
let turn = if *r < 0.0 { std::f64::consts::PI } else { 0.0 };
|
||||
a.center.x = *cx;
|
||||
a.center.y = *cy;
|
||||
a.radius = r.abs().max(1e-12);
|
||||
a.start = cadcraft_geom::norm_angle(*s0);
|
||||
a.end = cadcraft_geom::norm_angle(*s1);
|
||||
a.start = cadcraft_geom::norm_angle(*s0 + turn);
|
||||
a.end = cadcraft_geom::norm_angle(*s1 + turn);
|
||||
}
|
||||
}
|
||||
(EntityKind::LwPolyline(p), Shape::Poly { n, .. }) if p.vertices.len() == n => {
|
||||
|
||||
@@ -463,3 +463,29 @@ fn set_parameter_rejects_out_of_domain_dimension() {
|
||||
set_parameter(&mut d, "rad1", "2", &opts).unwrap();
|
||||
assert!(all_satisfied(&d));
|
||||
}
|
||||
|
||||
/// The solver may meet a coincidence with a negative arc radius; the stored arc must still put
|
||||
/// the referenced point where the solver did (issue #703), through `add` and through `solve`.
|
||||
#[test]
|
||||
fn arc_solved_with_a_negative_radius_keeps_its_points() {
|
||||
use std::f64::consts::FRAC_PI_2;
|
||||
// (arc centre, radius, start, end, point): a semicircle with the point behind it, and the
|
||||
// asymmetric case from the issue.
|
||||
let cases = [((0.0, 0.0), 1.0, -FRAC_PI_2, FRAC_PI_2, (-5.0, 0.0)), ((6.694, -0.686), 0.62075, 3.4824, 1.1766, (-7.562, 4.946))];
|
||||
for (c, r, a0, a1, p) in cases {
|
||||
for through_solve in [false, true] {
|
||||
let mut d = Drawing::default();
|
||||
let a = arc(&mut d, c, r, a0, a1);
|
||||
let q = add_e(&mut d, EntityKind::Point(DPoint { p: v(p.0, p.1), angle: 0.0 }));
|
||||
let k = con(ConstraintKind::Coincident, &[s(a, Sub::Mid), w(q)]);
|
||||
if through_solve {
|
||||
d.constraints.push(Constraint { id: 1, ..k });
|
||||
solve(&mut d, &SolveOptions::default()).unwrap();
|
||||
} else {
|
||||
ok(&mut d, k);
|
||||
}
|
||||
assert!(all_satisfied(&d), "{c:?} solve {through_solve}: {:?}", residuals(&d));
|
||||
assert!(circ(&d, a).1 > 0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -159,6 +159,9 @@ pub fn anno_scaled(e: &Entity, f: f64) -> Entity {
|
||||
EntityKind::MText(t) => {
|
||||
t.height *= f;
|
||||
t.width *= f;
|
||||
if let Some(c) = &mut t.columns {
|
||||
c.scale(f);
|
||||
}
|
||||
}
|
||||
EntityKind::Dimension(dm) => {
|
||||
dm.overrides.insert("scale".into(), serde_json::json!(f));
|
||||
@@ -172,6 +175,9 @@ pub fn anno_scaled(e: &Entity, f: f64) -> Entity {
|
||||
t.insert = land + (t.insert - land) * f;
|
||||
t.height *= f;
|
||||
t.width *= f;
|
||||
if let Some(c) = &mut t.columns {
|
||||
c.scale(f);
|
||||
}
|
||||
}
|
||||
if let Some(ins) = &mut m.block {
|
||||
ins.insert = land + (ins.insert - land) * f;
|
||||
@@ -188,6 +194,11 @@ pub fn anno_scaled(e: &Entity, f: f64) -> Entity {
|
||||
a.text.insert = o + (a.text.insert - o) * f;
|
||||
a.text.align_pt = a.text.align_pt.map(|p| o + (p - o) * f);
|
||||
a.text.height *= f;
|
||||
if let Some(ml) = &mut a.multiline {
|
||||
ml.mtext.insert = o + (ml.mtext.insert - o) * f;
|
||||
ml.mtext.height *= f;
|
||||
ml.mtext.width *= f;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
|
||||
@@ -35,7 +35,7 @@ impl Sub {
|
||||
"mid" | "midpoint" | "m" => Sub::Mid,
|
||||
"center" | "centre" | "c" => Sub::Center,
|
||||
_ => {
|
||||
for (p, vertex) in [("vertex", true), ("segment", false), ("seg", false), ("v", true)] {
|
||||
for (p, vertex) in [("vertex", true), ("segment", false), ("seg", false), ("s", false), ("v", true)] {
|
||||
if let Some(n) = l.strip_prefix(p) {
|
||||
let i: u32 = n.trim().parse().ok()?;
|
||||
return Some(if vertex { Sub::Vertex(i) } else { Sub::Segment(i) });
|
||||
|
||||
@@ -0,0 +1,561 @@
|
||||
//! Dynamic blocks made in another program, read from the data CADCraft keeps for them.
|
||||
//!
|
||||
//! A dynamic block's behaviour lives in objects CADCraft does not write itself and keeps as
|
||||
//! [`Foreign`](crate::foreign::Foreign) data: the block's BLOCK_RECORD has an extension dictionary
|
||||
//! whose `ACAD_ENHANCEDBLOCK` entry is an evaluation graph (`ACAD_EVALUATION_GRAPH`), and the
|
||||
//! graph lists the block's *parameters* (a distance, a point, visibility states, a lookup…) and
|
||||
//! *actions* (what changing a parameter does to the block's objects: stretch, move, look up…).
|
||||
//! [`dynamic_block`] reads them into the model below without changing the kept data, so a drawing
|
||||
//! saved again still carries them exactly as read.
|
||||
//!
|
||||
//! Every count and index here comes from the file: they are capped by the data actually present
|
||||
//! and unknown or damaged objects are skipped, never trusted.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use cadcraft_geom::{Vec2, Vec3};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::foreign::ForeignObject;
|
||||
use crate::{Drawing, Handle, RawTag};
|
||||
|
||||
mod apply;
|
||||
mod grips;
|
||||
mod reference;
|
||||
pub use apply::{drag_grip, set_property};
|
||||
pub use grips::{DynGrip, GripPart, GripShape, grips};
|
||||
pub use reference::{CustomProperty, Reference, Value, custom_properties, reference};
|
||||
|
||||
/// Most objects one evaluation graph is read with.
|
||||
const MAX_NODES: usize = 10_000;
|
||||
|
||||
/// Handles as hex strings in JSON, as commands report them.
|
||||
fn hex<S: serde::Serializer>(h: &Handle, s: S) -> Result<S::Ok, S::Error> {
|
||||
s.serialize_str(&h.hex())
|
||||
}
|
||||
fn hexes<S: serde::Serializer>(hs: &[Handle], s: S) -> Result<S::Ok, S::Error> {
|
||||
s.collect_seq(hs.iter().map(|h| h.hex()))
|
||||
}
|
||||
fn hex_vertices<S: serde::Serializer>(v: &[(Handle, Vec<i32>)], s: S) -> Result<S::Ok, S::Error> {
|
||||
s.collect_seq(v.iter().map(|(h, i)| (h.hex(), i)))
|
||||
}
|
||||
|
||||
/// A dynamic block's parameters and actions.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DynamicBlock {
|
||||
pub parameters: Vec<Parameter>,
|
||||
pub actions: Vec<Action>,
|
||||
/// Graph members of types not modelled here (grips, constraints…), by DXF type.
|
||||
pub other: Vec<String>,
|
||||
/// The grip objects by graph id (parameters name their grips by id; visibility states list
|
||||
/// the grips shown in them by object).
|
||||
#[serde(skip)]
|
||||
pub grip_objects: Vec<(i32, Handle)>,
|
||||
}
|
||||
|
||||
/// One parameter: a property a reference to the block can change.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Parameter {
|
||||
/// Its id in the evaluation graph (actions and lookups refer to parameters by it).
|
||||
pub node: i32,
|
||||
#[serde(serialize_with = "hex")]
|
||||
pub handle: Handle,
|
||||
/// The name the block editor shows ("Linear", "Visibility State").
|
||||
pub element: String,
|
||||
/// The parameter's properties appear in a reference's Properties palette.
|
||||
pub show_properties: bool,
|
||||
pub kind: ParameterKind,
|
||||
/// Graph ids of its grips: a two-point parameter's base and end grips (0 for none), a
|
||||
/// one-point parameter's grip. A reference's history records grip drags by these.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub grips: Vec<i32>,
|
||||
}
|
||||
|
||||
/// What a parameter is, with its definition geometry in block coordinates.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")]
|
||||
pub enum ParameterKind {
|
||||
/// A distance between two points (Distance1).
|
||||
Linear {
|
||||
base: Vec3,
|
||||
end: Vec3,
|
||||
label: String,
|
||||
description: String,
|
||||
values: ValueSet,
|
||||
/// The value changes the end point (`false`) or both points from the midpoint (`true`).
|
||||
from_midpoint: bool,
|
||||
},
|
||||
/// A distance and an angle from a base point.
|
||||
Polar { base: Vec3, end: Vec3 },
|
||||
/// Horizontal and vertical distances from a base point.
|
||||
Xy { base: Vec3, end: Vec3 },
|
||||
/// An angle about a base point.
|
||||
Rotation { base: Vec3, end: Vec3 },
|
||||
/// Aligns the reference with nearby geometry.
|
||||
Alignment { base: Vec3, end: Vec3 },
|
||||
/// Mirrors the objects about a reflection line.
|
||||
Flip { base: Vec3, end: Vec3 },
|
||||
/// A position (Position1).
|
||||
Point { at: Vec3, label: String, description: String },
|
||||
/// Named sets of visible objects.
|
||||
Visibility {
|
||||
at: Vec3,
|
||||
label: String,
|
||||
description: String,
|
||||
#[serde(serialize_with = "hexes")]
|
||||
controlled: Vec<Handle>,
|
||||
states: Vec<VisibilityState>,
|
||||
},
|
||||
/// A named value chosen from a lookup table.
|
||||
Lookup { at: Vec3, label: String, description: String },
|
||||
/// Moves the block's base point.
|
||||
BasePoint { at: Vec3 },
|
||||
/// A parameter type not modelled yet.
|
||||
Other { dxf_type: String },
|
||||
}
|
||||
|
||||
impl ParameterKind {
|
||||
/// The property name a reference shows ("Distance1", "Visibility1"), when it has one.
|
||||
pub fn label(&self) -> Option<&str> {
|
||||
match self {
|
||||
ParameterKind::Linear { label, .. }
|
||||
| ParameterKind::Point { label, .. }
|
||||
| ParameterKind::Visibility { label, .. }
|
||||
| ParameterKind::Lookup { label, .. } => Some(label),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One visibility state: its name and the objects shown in it (the parameter's other controlled
|
||||
/// objects are hidden).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct VisibilityState {
|
||||
pub name: String,
|
||||
#[serde(serialize_with = "hexes")]
|
||||
pub visible: Vec<Handle>,
|
||||
/// The parameters and actions active in this state (their objects).
|
||||
#[serde(serialize_with = "hexes")]
|
||||
pub active: Vec<Handle>,
|
||||
}
|
||||
|
||||
/// The values a distance or angle may take.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ValueSet {
|
||||
/// Which of the limits below apply (see the methods).
|
||||
pub flags: i32,
|
||||
pub minimum: f64,
|
||||
pub maximum: f64,
|
||||
pub increment: f64,
|
||||
/// The allowed values, for a list.
|
||||
pub list: Vec<f64>,
|
||||
}
|
||||
|
||||
impl ValueSet {
|
||||
pub fn has_minimum(&self) -> bool {
|
||||
self.flags & 1 != 0
|
||||
}
|
||||
pub fn has_maximum(&self) -> bool {
|
||||
self.flags & 2 != 0
|
||||
}
|
||||
pub fn has_increment(&self) -> bool {
|
||||
self.flags & 4 != 0
|
||||
}
|
||||
pub fn is_list(&self) -> bool {
|
||||
self.flags & 8 != 0
|
||||
}
|
||||
}
|
||||
|
||||
/// One action: what changing parameters does to the block's objects.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Action {
|
||||
pub node: i32,
|
||||
#[serde(serialize_with = "hex")]
|
||||
pub handle: Handle,
|
||||
/// Its name in the block editor ("Stretch", "Lookup1").
|
||||
pub name: String,
|
||||
/// Where the block editor shows it.
|
||||
pub at: Vec3,
|
||||
/// The objects it acts on.
|
||||
#[serde(serialize_with = "hexes")]
|
||||
pub selection: Vec<Handle>,
|
||||
/// Graph ids of the parameters it follows, with the parameter value each connection reads
|
||||
/// ("EndXDelta").
|
||||
pub connections: Vec<(i32, String)>,
|
||||
pub kind: ActionKind,
|
||||
}
|
||||
|
||||
/// What an action does.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")]
|
||||
pub enum ActionKind {
|
||||
/// Moves the vertices inside `frame` of the objects listed (all of an object not listed).
|
||||
Stretch {
|
||||
frame: Vec<Vec2>,
|
||||
#[serde(serialize_with = "hex_vertices")]
|
||||
vertices: Vec<(Handle, Vec<i32>)>,
|
||||
multiplier: f64,
|
||||
angle_offset: f64,
|
||||
},
|
||||
/// A table: each row sets the listed parameter values together.
|
||||
Lookup {
|
||||
columns: Vec<LookupColumn>,
|
||||
rows: Vec<Vec<String>>,
|
||||
},
|
||||
/// Moves the objects by the change of the parameter values it follows.
|
||||
Move {
|
||||
multiplier: f64,
|
||||
angle_offset: f64,
|
||||
},
|
||||
Scale,
|
||||
Rotate,
|
||||
Flip,
|
||||
Array,
|
||||
PolarStretch,
|
||||
/// An action type not modelled yet.
|
||||
Other {
|
||||
dxf_type: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// A lookup table column: the parameter value it reads or sets.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LookupColumn {
|
||||
/// The parameter's graph id.
|
||||
pub node: i32,
|
||||
/// The value it stands for ("VisibilityState", "lookupString").
|
||||
pub property: String,
|
||||
/// A lookup property (shown on the reference) rather than an input.
|
||||
pub lookup: bool,
|
||||
/// What the reference shows when no row matches ("Custom").
|
||||
pub unmatched: String,
|
||||
}
|
||||
|
||||
/// The groups of an object split at its subclass markers (100): `(marker, groups after it)`.
|
||||
fn sections(tags: &[RawTag]) -> Vec<(&str, &[RawTag])> {
|
||||
let mut out: Vec<(&str, &[RawTag])> = Vec::new();
|
||||
let mut start = 0;
|
||||
let mut name = "";
|
||||
for (i, t) in tags.iter().enumerate() {
|
||||
if t.code == 100 {
|
||||
out.push((name, tags.get(start..i).unwrap_or_default()));
|
||||
name = t.value.as_str();
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
out.push((name, tags.get(start..).unwrap_or_default()));
|
||||
out
|
||||
}
|
||||
|
||||
/// The groups after subclass marker `marker`, empty when the object has none.
|
||||
fn section<'a>(secs: &[(&str, &'a [RawTag])], marker: &str) -> &'a [RawTag] {
|
||||
secs.iter().find(|(n, _)| n.eq_ignore_ascii_case(marker)).map_or(&[], |(_, t)| t)
|
||||
}
|
||||
|
||||
/// Reads groups in order, as an object writes them.
|
||||
struct Cursor<'a> {
|
||||
tags: &'a [RawTag],
|
||||
at: usize,
|
||||
/// Groups scanned so far. A missing group neither moves the cursor nor ends the loops that
|
||||
/// ask for it, so a hostile record could make nested counts rescan it again and again; past
|
||||
/// `budget` every lookup fails at once and every count is zero.
|
||||
work: usize,
|
||||
budget: usize,
|
||||
}
|
||||
|
||||
impl<'a> Cursor<'a> {
|
||||
fn new(tags: &'a [RawTag]) -> Self {
|
||||
Cursor { tags, at: 0, work: 0, budget: tags.len().saturating_mul(16).saturating_add(1024) }
|
||||
}
|
||||
/// The next group with `code` from here on (the cursor moves past it).
|
||||
fn next(&mut self, code: i32) -> Option<&'a str> {
|
||||
if self.work >= self.budget {
|
||||
return None;
|
||||
}
|
||||
let rest = self.tags.get(self.at..)?;
|
||||
let Some(rel) = rest.iter().position(|t| t.code == code) else {
|
||||
self.work = self.work.saturating_add(rest.len().max(1));
|
||||
return None;
|
||||
};
|
||||
self.work = self.work.saturating_add(rel + 1);
|
||||
self.at += rel + 1;
|
||||
self.tags.get(self.at - 1).map(|t| t.value.as_str())
|
||||
}
|
||||
fn string(&mut self, code: i32) -> String {
|
||||
self.next(code).unwrap_or_default().to_string()
|
||||
}
|
||||
fn int(&mut self, code: i32) -> i32 {
|
||||
self.next(code).and_then(|v| v.trim().parse().ok()).unwrap_or(0)
|
||||
}
|
||||
fn real(&mut self, code: i32) -> f64 {
|
||||
self.next(code).and_then(|v| v.trim().parse::<f64>().ok()).filter(|v| v.is_finite()).unwrap_or(0.0)
|
||||
}
|
||||
/// A count read from the file, capped by the groups left (each item needs at least one).
|
||||
fn count(&mut self, code: i32) -> usize {
|
||||
let n = usize::try_from(self.int(code)).unwrap_or(0).min(self.tags.len().saturating_sub(self.at));
|
||||
if self.work >= self.budget { 0 } else { n }
|
||||
}
|
||||
fn handle(&mut self, code: i32) -> Option<Handle> {
|
||||
self.next(code).and_then(Handle::parse_hex)
|
||||
}
|
||||
fn handles(&mut self, code: i32, n: usize) -> Vec<Handle> {
|
||||
(0..n).filter_map(|_| self.handle(code)).collect()
|
||||
}
|
||||
/// A point written with codes `x`, `x + 10`, `x + 20`.
|
||||
fn point(&mut self, x: i32) -> Vec3 {
|
||||
let px = self.real(x);
|
||||
let py = self.real(x + 10);
|
||||
let at = self.at;
|
||||
// Z is optional: only take it when it is the very next group.
|
||||
let pz = match self.tags.get(at) {
|
||||
Some(t) if t.code == x + 20 => self.real(x + 20),
|
||||
_ => 0.0,
|
||||
};
|
||||
Vec3::new(px, py, pz)
|
||||
}
|
||||
}
|
||||
|
||||
/// The objects `xdict`'s `ACAD_ENHANCEDBLOCK` evaluation graph lists, when the dictionary has one.
|
||||
fn graph_members<'a>(objects: &HashMap<Handle, &'a ForeignObject>, xdict: Handle) -> Option<Vec<&'a ForeignObject>> {
|
||||
let dict = objects.get(&xdict)?;
|
||||
let mut graph = None;
|
||||
let mut tags = dict.tags.iter();
|
||||
while let Some(t) = tags.next() {
|
||||
if t.code == 3 && t.value.eq_ignore_ascii_case("ACAD_ENHANCEDBLOCK") {
|
||||
graph = tags.next().filter(|t| t.code == 350 || t.code == 360).and_then(|t| Handle::parse_hex(&t.value));
|
||||
break;
|
||||
}
|
||||
}
|
||||
let graph = objects.get(&graph?).filter(|o| o.kind.eq_ignore_ascii_case("ACAD_EVALUATION_GRAPH"))?;
|
||||
let members = section(§ions(&graph.tags), "AcDbEvalGraph").iter().filter(|t| t.code == 360).filter_map(|t| Handle::parse_hex(&t.value));
|
||||
Some(members.take(MAX_NODES).filter_map(|h| objects.get(&h).copied()).collect())
|
||||
}
|
||||
|
||||
/// The dynamic block data of block `name` (any case; `*Model_Space` for a drawing that is a
|
||||
/// block's own drawing), or `None` when it has none.
|
||||
pub fn dynamic_block(d: &Drawing, name: &str) -> Option<DynamicBlock> {
|
||||
let xdict = d.foreign.records.get(&("BLOCK_RECORD".to_string(), name.trim().to_ascii_uppercase()))?.xdict?;
|
||||
let objects: HashMap<Handle, &ForeignObject> = d.foreign.objects.iter().map(|o| (o.handle, o)).collect();
|
||||
let mut out = DynamicBlock::default();
|
||||
for o in graph_members(&objects, xdict)? {
|
||||
let secs = sections(&o.tags);
|
||||
let node = Cursor::new(section(&secs, "AcDbEvalExpr")).int(90);
|
||||
let element = Cursor::new(section(&secs, "AcDbBlockElement")).string(300);
|
||||
let kind = o.kind.to_ascii_uppercase();
|
||||
if kind.ends_with("PARAMETER") {
|
||||
let show_properties = Cursor::new(section(&secs, "AcDbBlockParameter")).int(280) != 0;
|
||||
let grips = grip_ids(&secs);
|
||||
out.parameters.push(Parameter { node, handle: o.handle, element, show_properties, kind: parameter(&kind, &secs), grips });
|
||||
} else if kind.ends_with("ACTION") {
|
||||
out.actions.push(action(o.handle, node, element, &kind, &secs));
|
||||
} else {
|
||||
if kind.ends_with("GRIP") {
|
||||
out.grip_objects.push((node, o.handle));
|
||||
}
|
||||
out.other.push(o.kind.clone());
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// A parameter's grip ids: after a two-point parameter's points, the grip count (170) and the
|
||||
/// base and end grips (91); after a one-point parameter's point, its grip (93).
|
||||
fn grip_ids(secs: &[(&str, &[RawTag])]) -> Vec<i32> {
|
||||
let two = section(secs, "AcDbBlock2PtParameter");
|
||||
if !two.is_empty() {
|
||||
let mut c = Cursor::new(two);
|
||||
c.point(1010);
|
||||
c.point(1011);
|
||||
let n = c.count(170).min(2);
|
||||
return (0..n).map(|_| c.int(91)).collect();
|
||||
}
|
||||
let mut c = Cursor::new(section(secs, "AcDbBlock1PtParameter"));
|
||||
c.point(1010);
|
||||
c.next(93).and_then(|v| v.trim().parse().ok()).into_iter().collect()
|
||||
}
|
||||
|
||||
fn parameter(kind: &str, secs: &[(&str, &[RawTag])]) -> ParameterKind {
|
||||
let two = || {
|
||||
let mut c = Cursor::new(section(secs, "AcDbBlock2PtParameter"));
|
||||
(c.point(1010), c.point(1011))
|
||||
};
|
||||
let one = || Cursor::new(section(secs, "AcDbBlock1PtParameter")).point(1010);
|
||||
match kind {
|
||||
"BLOCKLINEARPARAMETER" => {
|
||||
let (base, end) = two();
|
||||
let mut c = Cursor::new(section(secs, "AcDbBlockLinearParameter"));
|
||||
let label = c.string(305);
|
||||
let description = c.string(306);
|
||||
let from_midpoint = Cursor::new(section(secs, "AcDbBlock2PtParameter")).int(177) == 1;
|
||||
ParameterKind::Linear { base, end, label, description, values: value_set(&mut c), from_midpoint }
|
||||
}
|
||||
"BLOCKPOINTPARAMETER" => {
|
||||
let mut c = Cursor::new(section(secs, "AcDbBlockPointParameter"));
|
||||
ParameterKind::Point { at: one(), label: c.string(303), description: c.string(304) }
|
||||
}
|
||||
"BLOCKVISIBILITYPARAMETER" => visibility(one(), section(secs, "AcDbBlockVisibilityParameter")),
|
||||
"BLOCKLOOKUPPARAMETER" => {
|
||||
let mut c = Cursor::new(section(secs, "AcDbBlockLookupParameter"));
|
||||
ParameterKind::Lookup { at: one(), label: c.string(303), description: c.string(304) }
|
||||
}
|
||||
"BLOCKBASEPOINTPARAMETER" => ParameterKind::BasePoint { at: one() },
|
||||
"BLOCKPOLARPARAMETER" | "BLOCKXYPARAMETER" | "BLOCKROTATIONPARAMETER" | "BLOCKALIGNMENTPARAMETER" | "BLOCKFLIPPARAMETER" => {
|
||||
let (base, end) = two();
|
||||
match kind {
|
||||
"BLOCKPOLARPARAMETER" => ParameterKind::Polar { base, end },
|
||||
"BLOCKXYPARAMETER" => ParameterKind::Xy { base, end },
|
||||
"BLOCKROTATIONPARAMETER" => ParameterKind::Rotation { base, end },
|
||||
"BLOCKALIGNMENTPARAMETER" => ParameterKind::Alignment { base, end },
|
||||
_ => ParameterKind::Flip { base, end },
|
||||
}
|
||||
}
|
||||
other => ParameterKind::Other { dxf_type: other.to_string() },
|
||||
}
|
||||
}
|
||||
|
||||
/// A value set after a parameter's name and description: description (307), flags (96),
|
||||
/// minimum, maximum and increment (141–143), then the listed values (175 count, 144 each).
|
||||
fn value_set(c: &mut Cursor) -> ValueSet {
|
||||
c.next(307);
|
||||
let flags = c.int(96);
|
||||
let (minimum, maximum, increment) = (c.real(141), c.real(142), c.real(143));
|
||||
let n = c.count(175);
|
||||
let list = (0..n).filter_map(|_| c.next(144).and_then(|v| v.trim().parse::<f64>().ok()).filter(|v| v.is_finite())).collect();
|
||||
ValueSet { flags, minimum, maximum, increment, list }
|
||||
}
|
||||
|
||||
/// A visibility parameter: its name and description (301, 302), the objects it controls (93
|
||||
/// count, 331 each), then each state (92 count): name (303), visible objects (94, 332) and the
|
||||
/// parameters and actions active in it (95, 333).
|
||||
fn visibility(at: Vec3, tags: &[RawTag]) -> ParameterKind {
|
||||
let mut c = Cursor::new(tags);
|
||||
let label = c.string(301);
|
||||
let description = c.string(302);
|
||||
let n = c.count(93);
|
||||
let controlled = c.handles(331, n);
|
||||
let states = (0..c.count(92))
|
||||
.map(|_| {
|
||||
let name = c.string(303);
|
||||
let n = c.count(94);
|
||||
let visible = c.handles(332, n);
|
||||
let n = c.count(95);
|
||||
VisibilityState { name, visible, active: c.handles(333, n) }
|
||||
})
|
||||
.collect();
|
||||
ParameterKind::Visibility { at, label, description, controlled, states }
|
||||
}
|
||||
|
||||
fn action(handle: Handle, node: i32, name: String, kind: &str, secs: &[(&str, &[RawTag])]) -> Action {
|
||||
let mut c = Cursor::new(section(secs, "AcDbBlockAction"));
|
||||
c.next(70);
|
||||
let n = c.count(71);
|
||||
let selection = c.handles(330, n);
|
||||
let at = c.point(1010);
|
||||
let (connections, kind) = match kind {
|
||||
"BLOCKSTRETCHACTION" => stretch(section(secs, "AcDbBlockStretchAction")),
|
||||
"BLOCKLOOKUPACTION" => (Vec::new(), lookup(section(secs, "AcDbBlockLookupAction"))),
|
||||
"BLOCKMOVEACTION" => {
|
||||
let tags = section(secs, "AcDbBlockMoveAction");
|
||||
let mut c = Cursor::new(tags);
|
||||
let (multiplier, angle_offset) = (c.real(140), c.real(141));
|
||||
(two_connections(tags), ActionKind::Move { multiplier, angle_offset })
|
||||
}
|
||||
"BLOCKSCALEACTION" => (Vec::new(), ActionKind::Scale),
|
||||
"BLOCKROTATEACTION" => (Vec::new(), ActionKind::Rotate),
|
||||
"BLOCKFLIPACTION" => (Vec::new(), ActionKind::Flip),
|
||||
"BLOCKARRAYACTION" => (Vec::new(), ActionKind::Array),
|
||||
"BLOCKPOLARSTRETCHACTION" => (Vec::new(), ActionKind::PolarStretch),
|
||||
other => (Vec::new(), ActionKind::Other { dxf_type: other.to_string() }),
|
||||
};
|
||||
Action { node, handle, name, at, selection, connections, kind }
|
||||
}
|
||||
|
||||
/// Two connections: parameter ids (92, 93) and the values they read (301, 302). Writers order
|
||||
/// these groups differently (92 93 301 302, or 92 301 93 302), so each is read on its own.
|
||||
fn two_connections(tags: &[RawTag]) -> Vec<(i32, String)> {
|
||||
let first = |code: i32| Cursor::new(tags).next(code).unwrap_or_default().to_string();
|
||||
let int = |code: i32| first(code).trim().parse().unwrap_or(0);
|
||||
vec![(int(92), first(301)), (int(93), first(302))]
|
||||
}
|
||||
|
||||
/// A stretch action: its two connections, the frame (72 count, 1011/1021 points), the objects
|
||||
/// whose vertices it moves (73 count; each a 331 handle, a 74 count and 94 vertex indices), then
|
||||
/// the distance multiplier (140) and angle offset (141).
|
||||
fn stretch(tags: &[RawTag]) -> (Vec<(i32, String)>, ActionKind) {
|
||||
let connections = two_connections(tags);
|
||||
let mut c = Cursor::new(tags);
|
||||
let n = c.count(72);
|
||||
let frame = (0..n)
|
||||
.map(|_| {
|
||||
let x = c.real(1011);
|
||||
Vec2::new(x, c.real(1021))
|
||||
})
|
||||
.collect();
|
||||
let objects = c.count(73);
|
||||
let vertices = (0..objects)
|
||||
.filter_map(|_| {
|
||||
let h = c.handle(331)?;
|
||||
let n = c.count(74);
|
||||
Some((h, (0..n).map(|_| c.int(94)).collect()))
|
||||
})
|
||||
.collect();
|
||||
c.next(75);
|
||||
let (multiplier, angle_offset) = (c.real(140), c.real(141));
|
||||
(connections, ActionKind::Stretch { frame, vertices, multiplier, angle_offset })
|
||||
}
|
||||
|
||||
/// A lookup table: rows (92) and columns (93), the cells row by row (302, after a 301 header),
|
||||
/// then each column: unmatched name (303 then 305), parameter id (94), lookup flag (282) and the
|
||||
/// parameter value it stands for (304).
|
||||
fn lookup(tags: &[RawTag]) -> ActionKind {
|
||||
let mut c = Cursor::new(tags);
|
||||
let rows = c.count(92);
|
||||
let cols = c.count(93);
|
||||
c.next(301);
|
||||
let cells = rows.saturating_mul(cols).min(tags.len());
|
||||
let flat: Vec<String> = (0..cells).map(|_| c.string(302)).collect();
|
||||
let rows = flat.chunks(cols.max(1)).map(<[String]>::to_vec).collect();
|
||||
let columns = (0..cols)
|
||||
.map(|_| {
|
||||
c.next(303);
|
||||
let node = c.int(94);
|
||||
let lookup = c.int(282) != 0;
|
||||
let unmatched = c.string(305);
|
||||
let property = c.string(304);
|
||||
LookupColumn { node, property, lookup, unmatched }
|
||||
})
|
||||
.collect();
|
||||
ActionKind::Lookup { columns, rows }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn tag(code: i32, value: &str) -> RawTag {
|
||||
RawTag { code, value: value.to_string() }
|
||||
}
|
||||
|
||||
/// Counts that promise groups the record doesn't have: each nested loop would rescan the
|
||||
/// record for them. The work budget keeps this linear in the record's size.
|
||||
#[test]
|
||||
fn hostile_counts_with_missing_groups_stay_linear() {
|
||||
let n = 20_000;
|
||||
let mut tags = vec![tag(93, "0"), tag(92, &n.to_string())];
|
||||
tags.extend((0..n).map(|_| tag(94, &n.to_string())));
|
||||
let ParameterKind::Visibility { states, .. } = visibility(Vec3::default(), &tags) else { panic!("a visibility parameter") };
|
||||
assert!(states.len() <= n && states.iter().all(|s| s.visible.is_empty()));
|
||||
|
||||
let mut tags = vec![tag(73, &n.to_string())];
|
||||
tags.extend((0..n).flat_map(|_| [tag(331, "1F"), tag(74, &n.to_string())]));
|
||||
let (_, ActionKind::Stretch { vertices, .. }) = stretch(&tags) else { panic!("a stretch action") };
|
||||
let ints: usize = vertices.iter().map(|(_, v)| v.len()).sum();
|
||||
assert!(ints <= 16 * tags.len() + 1024, "{ints}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,958 @@
|
||||
//! Changing a dynamic block reference's custom properties.
|
||||
//!
|
||||
//! A changed reference shows an anonymous block (`*U12`) made from its current one with the
|
||||
//! change applied, and says so the way AutoCAD's own references do, so AutoCAD still reads it as
|
||||
//! a reference to the dynamic block: the anonymous block's BLOCK_RECORD carries
|
||||
//! `AcDbBlockRepBTag` xdata naming the dynamic block's record, each copied object keeps the
|
||||
//! `AcDbBlockRepETag` xdata that pairs it with its object in the dynamic block, and the reference's
|
||||
//! `AcDbBlockRepresentation` data holds its values (created when it had none) and its history
|
||||
//! says what was done to it (AutoCAD replays the history when the dynamic block is redefined).
|
||||
//!
|
||||
//! What can change so far: a visibility state, a lookup value, a distance and a position, with
|
||||
//! the stretch and move actions they drive and the lookups that follow. The other parameters'
|
||||
//! actions come later.
|
||||
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
|
||||
use cadcraft_geom::{Mat3, Vec2, Vec3};
|
||||
|
||||
use super::{ActionKind, DynamicBlock, ParameterKind, Value, dynamic_block, reference};
|
||||
use crate::foreign::{ForeignExtra, ForeignObject};
|
||||
use crate::{Block, Drawing, Entity, EntityKind, Handle, RawTag};
|
||||
|
||||
/// Xdata application naming a copied object's object in the dynamic block (its element tag).
|
||||
const ETAG: &str = "AcDbBlockRepETag";
|
||||
/// Xdata application naming an anonymous block's dynamic block.
|
||||
const BTAG: &str = "AcDbBlockRepBTag";
|
||||
|
||||
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;
|
||||
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())
|
||||
}
|
||||
|
||||
/// 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()
|
||||
}
|
||||
|
||||
/// The file handle of block `name`'s BLOCK_RECORD, as the kept data refers to it.
|
||||
fn record_handle(d: &Drawing, name: &str) -> Option<Handle> {
|
||||
let key = name.to_ascii_uppercase();
|
||||
d.foreign.targets.iter().find(|(_, (k, n))| k == "BLOCK_RECORD" && *n == key).map(|(h, _)| *h)
|
||||
}
|
||||
|
||||
/// The object entry `key` of dictionary `dict` refers to.
|
||||
fn entry(d: &Drawing, dict: Handle, key: &str) -> Option<Handle> {
|
||||
let o = d.foreign.objects.iter().find(|o| o.handle == dict)?;
|
||||
let mut tags = o.tags.iter();
|
||||
while let Some(t) = tags.next() {
|
||||
if t.code == 3 && t.value.eq_ignore_ascii_case(key) {
|
||||
return tags.next().filter(|t| t.code == 350 || t.code == 360).and_then(|t| Handle::parse_hex(&t.value));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Every entry of dictionary `dict`: (key, object).
|
||||
fn entries(d: &Drawing, dict: Handle) -> Vec<(String, Handle)> {
|
||||
let Some(o) = d.foreign.objects.iter().find(|o| o.handle == dict) else { return Vec::new() };
|
||||
let mut out = Vec::new();
|
||||
let mut key: Option<&str> = None;
|
||||
for t in &o.tags {
|
||||
match t.code {
|
||||
3 => key = Some(&t.value),
|
||||
350 | 360 => {
|
||||
if let (Some(k), Some(h)) = (key.take(), Handle::parse_hex(&t.value)) {
|
||||
out.push((k.to_string(), h));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A parameter's value as a change sets it (block coordinates).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
enum Val {
|
||||
/// A visibility state or lookup value.
|
||||
Text(String),
|
||||
/// A point parameter's position.
|
||||
Point(Vec3),
|
||||
/// A two-point parameter's base and end points.
|
||||
Points(Vec3, Vec3),
|
||||
}
|
||||
|
||||
/// A parameter's current value on the reference: the value it was given, else the block's own.
|
||||
fn current(m: &DynamicBlock, values: &BTreeMap<i32, Value>, node: i32) -> Option<Val> {
|
||||
let p = m.parameters.iter().find(|p| p.node == node)?;
|
||||
let given = values.get(&node);
|
||||
Some(match (&p.kind, given) {
|
||||
(_, Some(Value::Text { value })) => Val::Text(value.clone()),
|
||||
(_, Some(Value::Point { at })) => Val::Point(*at),
|
||||
(_, Some(Value::Points { base, end })) => Val::Points(*base, *end),
|
||||
(ParameterKind::Visibility { states, .. }, None) => Val::Text(states.first().map(|s| s.name.clone()).unwrap_or_default()),
|
||||
(ParameterKind::Point { at, .. }, None) => Val::Point(*at),
|
||||
(
|
||||
ParameterKind::Linear { base, end, .. }
|
||||
| ParameterKind::Polar { base, end }
|
||||
| ParameterKind::Xy { base, end }
|
||||
| ParameterKind::Rotation { base, end }
|
||||
| ParameterKind::Alignment { base, end }
|
||||
| ParameterKind::Flip { base, end },
|
||||
None,
|
||||
) => Val::Points(*base, *end),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// `v` as a value set allows it: the nearest listed value, a multiple of the increment from the
|
||||
/// minimum, within the minimum and maximum.
|
||||
fn allowed(set: &super::ValueSet, v: f64) -> f64 {
|
||||
let mut v = v;
|
||||
if set.is_list() && !set.list.is_empty() {
|
||||
v = set.list.iter().copied().fold(f64::NAN, |best, x| if best.is_nan() || (x - v).abs() < (best - v).abs() { x } else { best });
|
||||
}
|
||||
if set.has_increment() && set.increment > 0.0 {
|
||||
let from = if set.has_minimum() { set.minimum } else { 0.0 };
|
||||
v = from + ((v - from) / set.increment).round() * set.increment;
|
||||
}
|
||||
if set.has_minimum() {
|
||||
v = v.max(set.minimum);
|
||||
}
|
||||
if set.has_maximum() && set.maximum > 0.0 {
|
||||
v = v.min(set.maximum);
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// A linear parameter's points for distance `dist`: the end point moves along the parameter, or
|
||||
/// both points move apart from the midpoint.
|
||||
fn linear_points(base: Vec3, end: Vec3, dist: f64, from_midpoint: bool) -> (Vec3, Vec3) {
|
||||
let dir = end - base;
|
||||
let len = dir.len();
|
||||
let u = if len > 1e-12 { dir * (1.0 / len) } else { Vec3::new(1.0, 0.0, 0.0) };
|
||||
if from_midpoint {
|
||||
let mid = (base + end) * 0.5;
|
||||
(mid - u * (dist / 2.0), mid + u * (dist / 2.0))
|
||||
} else {
|
||||
(base, base + u * dist)
|
||||
}
|
||||
}
|
||||
|
||||
/// The changes one property change makes, before actions run: parameter values by graph id.
|
||||
#[derive(Default)]
|
||||
struct Plan {
|
||||
set: BTreeMap<i32, Val>,
|
||||
}
|
||||
|
||||
impl Plan {
|
||||
/// Set the value a lookup table cell gives property `property` of parameter `node`.
|
||||
fn cell(&mut self, m: &DynamicBlock, values: &BTreeMap<i32, Value>, node: i32, property: &str, cell: &str) -> Result<(), String> {
|
||||
let p = m.parameters.iter().find(|p| p.node == node).ok_or("A lookup table refers to a missing parameter.")?;
|
||||
match (&p.kind, property.to_ascii_lowercase().as_str()) {
|
||||
(_, "lookupstring") | (ParameterKind::Visibility { .. }, "visibilitystate") => {
|
||||
self.set.insert(node, Val::Text(cell.to_string()));
|
||||
}
|
||||
(ParameterKind::Linear { from_midpoint, .. }, "updateddistance" | "distance") => {
|
||||
let dist = cell
|
||||
.trim()
|
||||
.parse::<f64>()
|
||||
.ok()
|
||||
.filter(|v| v.is_finite())
|
||||
.ok_or_else(|| format!("A lookup table gives \"{cell}\" for a distance."))?;
|
||||
let Some(Val::Points(b, e)) = self.set.get(&node).cloned().or_else(|| current(m, values, node)) else { return Ok(()) };
|
||||
let (b2, e2) = linear_points(b, e, dist, *from_midpoint);
|
||||
self.set.insert(node, Val::Points(b2, e2));
|
||||
}
|
||||
(_, other) => return Err(format!("A lookup table sets {other}, which CADCraft can't change yet.")),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Lookup parameter `node` set to `value`: every lookup table that sets it gives its row's
|
||||
/// inputs. A table without that value is left alone; none having it is an error.
|
||||
fn lookup(&mut self, m: &DynamicBlock, values: &BTreeMap<i32, Value>, node: i32, value: &str, skip: Option<usize>) -> Result<bool, String> {
|
||||
let mut found = false;
|
||||
for (i, a) in m.actions.iter().enumerate() {
|
||||
let ActionKind::Lookup { columns, rows } = &a.kind else { continue };
|
||||
if Some(i) == skip {
|
||||
continue;
|
||||
}
|
||||
let Some(out) = columns.iter().position(|c| c.lookup && c.node == node) else { continue };
|
||||
let Some(row) = rows.iter().find(|r| r.get(out).is_some_and(|v| v.trim().eq_ignore_ascii_case(value.trim()))) else { continue };
|
||||
found = true;
|
||||
for (c, cell) in columns.iter().zip(row) {
|
||||
self.cell(m, values, c.node, &c.property, cell)?;
|
||||
}
|
||||
}
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
/// After input property `property` of parameter `node` changed: a lookup table with that input
|
||||
/// whose inputs (with the new value) match a row gives its lookup value, and the lookup's
|
||||
/// other tables then set their inputs for that value (as AutoCAD does).
|
||||
fn chain(&mut self, m: &DynamicBlock, values: &BTreeMap<i32, Value>, node: i32, property: &str) -> Result<(), String> {
|
||||
let read = |plan: &Plan, n: i32, prop: &str| -> Option<String> {
|
||||
let v = plan.set.get(&n).cloned().or_else(|| current(m, values, n))?;
|
||||
match (v, prop.to_ascii_lowercase().as_str()) {
|
||||
(Val::Text(t), "visibilitystate" | "lookupstring") => Some(t),
|
||||
(Val::Points(b, e), "updateddistance" | "distance") => Some(format!("{}", (e - b).len())),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
for (i, a) in m.actions.iter().enumerate() {
|
||||
let ActionKind::Lookup { columns, rows } = &a.kind else { continue };
|
||||
if !columns.iter().any(|c| !c.lookup && c.node == node && c.property.eq_ignore_ascii_case(property)) {
|
||||
continue;
|
||||
}
|
||||
let Some(out) = columns.iter().position(|c| c.lookup) else { continue };
|
||||
let lookup_node = columns.get(out).map_or(0, |c| c.node);
|
||||
let hit = rows.iter().find(|r| {
|
||||
columns.iter().enumerate().filter(|(_, c)| !c.lookup).all(|(j, c)| match (r.get(j), read(self, c.node, &c.property)) {
|
||||
(Some(cell), Some(v)) => same(cell, &v),
|
||||
_ => false,
|
||||
})
|
||||
});
|
||||
if let Some(v) = hit.and_then(|r| r.get(out)).cloned() {
|
||||
self.set.insert(lookup_node, Val::Text(v.clone()));
|
||||
self.lookup(m, values, lookup_node, &v, Some(i))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether table cell `cell` reads as `value` (numbers compare as numbers).
|
||||
fn same(cell: &str, value: &str) -> bool {
|
||||
match (cell.trim().parse::<f64>(), value.trim().parse::<f64>()) {
|
||||
(Ok(a), Ok(b)) => (a - b).abs() <= 1e-6 * a.abs().max(b.abs()).max(1.0),
|
||||
_ => cell.trim().eq_ignore_ascii_case(value.trim()),
|
||||
}
|
||||
}
|
||||
|
||||
/// A number typed for a property, in drawing units.
|
||||
fn number(value: &str) -> Result<f64, String> {
|
||||
value.trim().parse::<f64>().ok().filter(|v| v.is_finite()).ok_or_else(|| format!("\"{value}\" is not a number."))
|
||||
}
|
||||
|
||||
/// Set custom property `name` (as the Properties palette names it, any case) of dynamic block
|
||||
/// reference `insert` to `value` (lengths and positions in drawing units, as Properties shows them).
|
||||
pub fn set_property(d: &mut Drawing, insert: Handle, name: &str, value: &str) -> Result<(), String> {
|
||||
let r = reference(d, insert).ok_or("Not a dynamic block reference.")?;
|
||||
let m = dynamic_block(d, &r.definition).ok_or("Not a dynamic block reference.")?;
|
||||
let EntityKind::Insert(ins) = &d.entity(insert).ok_or("No such object.")?.kind else { return Err("Not a block reference.".into()) };
|
||||
let (sx, sy) = (ins.scale.x.abs().max(1e-12), ins.scale.y.abs().max(1e-12));
|
||||
let block_base = d.block(&r.definition).map(|b| b.base).unwrap_or_default();
|
||||
let want = name.trim();
|
||||
// A point parameter's position is two properties, "Position1 X" and "Position1 Y".
|
||||
let (label, axis) = match want.rsplit_once(' ') {
|
||||
Some((l, a)) if a.eq_ignore_ascii_case("X") || a.eq_ignore_ascii_case("Y") => (l, Some(a.eq_ignore_ascii_case("X"))),
|
||||
_ => (want, None),
|
||||
};
|
||||
let p = m
|
||||
.parameters
|
||||
.iter()
|
||||
.find(|p| {
|
||||
p.show_properties && p.kind.label().is_some_and(|l| l.eq_ignore_ascii_case(want) || (axis.is_some() && l.eq_ignore_ascii_case(label)))
|
||||
})
|
||||
.ok_or_else(|| format!("The reference has no property \"{name}\"."))?;
|
||||
let mut plan = Plan::default();
|
||||
match (&p.kind, axis) {
|
||||
(ParameterKind::Visibility { states, label, .. }, _) => {
|
||||
let s = states.iter().find(|s| s.name.eq_ignore_ascii_case(value.trim())).ok_or_else(|| {
|
||||
format!("\"{value}\" is not a state of {label}: {}.", states.iter().map(|s| s.name.as_str()).collect::<Vec<_>>().join(", "))
|
||||
})?;
|
||||
plan.set.insert(p.node, Val::Text(s.name.clone()));
|
||||
plan.chain(&m, &r.values, p.node, "VisibilityState")?;
|
||||
}
|
||||
(ParameterKind::Lookup { label, .. }, _) => {
|
||||
plan.set.insert(p.node, Val::Text(value.trim().to_string()));
|
||||
if !plan.lookup(&m, &r.values, p.node, value, None)? {
|
||||
return Err(format!("\"{value}\" is not a value of {label}."));
|
||||
}
|
||||
}
|
||||
(ParameterKind::Linear { values, from_midpoint, .. }, None) => {
|
||||
let typed = number(value)? / sx;
|
||||
if typed <= 0.0 {
|
||||
return Err(format!("{name} must be more than 0."));
|
||||
}
|
||||
let dist = allowed(values, typed);
|
||||
if dist <= 0.0 {
|
||||
return Err(format!("{name} must be more than 0."));
|
||||
}
|
||||
let Some(Val::Points(b, e)) = current(&m, &r.values, p.node) else { return Err(format!("{name} has no points.")) };
|
||||
let (b2, e2) = linear_points(b, e, dist, *from_midpoint);
|
||||
plan.set.insert(p.node, Val::Points(b2, e2));
|
||||
plan.chain(&m, &r.values, p.node, "UpdatedDistance")?;
|
||||
}
|
||||
(ParameterKind::Point { .. }, Some(is_x)) => {
|
||||
let Some(Val::Point(at)) = current(&m, &r.values, p.node) else { return Err(format!("{name} has no position.")) };
|
||||
let v = number(value)?;
|
||||
let at = if is_x { Vec3::new(block_base.x + v / sx, at.y, at.z) } else { Vec3::new(at.x, block_base.y + v / sy, at.z) };
|
||||
plan.set.insert(p.node, Val::Point(at));
|
||||
}
|
||||
_ => return Err(format!("CADCraft can't change {name} yet.")),
|
||||
}
|
||||
apply(d, insert, &r.definition, &r.values, &plan.set, p.node)
|
||||
}
|
||||
|
||||
/// Drag grip `part` of parameter `node` of dynamic block reference `insert` to `to` (drawing
|
||||
/// coordinates), as AutoCAD's dynamic grips do: a distance's end grip (or base grip) changes the
|
||||
/// distance by the drag along the parameter, snapped to its value set (both ends move when the
|
||||
/// distance is measured from its midpoint); a position grip moves the position. The actions and
|
||||
/// lookups follow as for [`set_property`].
|
||||
pub fn drag_grip(d: &mut Drawing, insert: Handle, node: i32, part: super::GripPart, to: Vec2) -> Result<(), String> {
|
||||
use super::GripPart;
|
||||
if !to.is_finite() {
|
||||
return Err("Invalid point.".into());
|
||||
}
|
||||
let r = reference(d, insert).ok_or("Not a dynamic block reference.")?;
|
||||
let m = dynamic_block(d, &r.definition).ok_or("Not a dynamic block reference.")?;
|
||||
let EntityKind::Insert(ins) = &d.entity(insert).ok_or("No such object.")?.kind else { return Err("Not a block reference.".into()) };
|
||||
let base = d.block(&r.definition).map(|b| b.base.xy()).unwrap_or_default();
|
||||
let q = ins.transform(base).inverse().ok_or("The reference has a zero scale.")?.apply(to);
|
||||
let p = m.parameters.iter().find(|p| p.node == node).ok_or("The reference has no such parameter.")?;
|
||||
let mut plan = Plan::default();
|
||||
match (&p.kind, part) {
|
||||
(ParameterKind::Linear { values, from_midpoint, label, .. }, GripPart::Base | GripPart::End) => {
|
||||
let Some(Val::Points(b, e)) = current(&m, &r.values, node) else { return Err(format!("{label} has no points.")) };
|
||||
let len = (e - b).len();
|
||||
if len < 1e-12 {
|
||||
return Err(format!("{label} has no direction."));
|
||||
}
|
||||
let u = (e - b) * (1.0 / len);
|
||||
let q = Vec3::new(q.x, q.y, b.z);
|
||||
let raw = if *from_midpoint {
|
||||
2.0 * (q - (b + e) * 0.5).dot(u).abs()
|
||||
} else if part == GripPart::End {
|
||||
(q - b).dot(u)
|
||||
} else {
|
||||
(e - q).dot(u)
|
||||
};
|
||||
let dist = allowed(values, raw);
|
||||
if !(raw > 0.0 && dist > 0.0 && dist.is_finite()) {
|
||||
return Err(format!("{label} must be more than 0."));
|
||||
}
|
||||
let (b2, e2) = match (from_midpoint, part) {
|
||||
(true, _) => linear_points(b, e, dist, true),
|
||||
(false, GripPart::Base) => (e - u * dist, e),
|
||||
(false, _) => (b, b + u * dist),
|
||||
};
|
||||
plan.set.insert(node, Val::Points(b2, e2));
|
||||
plan.chain(&m, &r.values, node, "UpdatedDistance")?;
|
||||
}
|
||||
(ParameterKind::Point { label, .. }, GripPart::At) => {
|
||||
let Some(Val::Point(at)) = current(&m, &r.values, node) else { return Err(format!("{label} has no position.")) };
|
||||
plan.set.insert(node, Val::Point(Vec3::new(q.x, q.y, at.z)));
|
||||
}
|
||||
_ => return Err("That grip can't be dragged.".into()),
|
||||
}
|
||||
apply(d, insert, &r.definition, &r.values, &plan.set, node)
|
||||
}
|
||||
|
||||
/// The change of each parameter value an action can follow, by parameter graph id and the
|
||||
/// connection's name ("EndXDelta", "XDelta"…), from `old` and `new` values.
|
||||
fn delta(old: &Val, new: &Val, connection: &str) -> f64 {
|
||||
let c = connection.to_ascii_lowercase();
|
||||
let pick = |v: Vec3| if c.contains('x') { v.x } else { v.y };
|
||||
match (old, new) {
|
||||
(Val::Point(a), Val::Point(b)) => pick(*b - *a),
|
||||
(Val::Points(ab, ae), Val::Points(bb, be)) => {
|
||||
if c.starts_with("base") || c.starts_with("updatedbase") {
|
||||
pick(*bb - *ab)
|
||||
} else {
|
||||
pick(*be - *ae)
|
||||
}
|
||||
}
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Move entity `e` by `v`: the listed vertices of a line or polyline, else the whole object.
|
||||
fn move_entity(e: &mut Entity, vertices: Option<&[i32]>, v: Vec2) {
|
||||
match (&mut e.kind, vertices) {
|
||||
(EntityKind::LwPolyline(pl), Some(idx)) => {
|
||||
for i in idx {
|
||||
if let Some(p) = usize::try_from(*i).ok().and_then(|i| pl.vertices.get_mut(i)) {
|
||||
p.p += v;
|
||||
}
|
||||
}
|
||||
}
|
||||
(EntityKind::Line(l), Some(idx)) => {
|
||||
for i in idx {
|
||||
match i {
|
||||
0 => l.a = l.a + v.to3(0.0),
|
||||
1 => l.b = l.b + v.to3(0.0),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => e.kind.transform(&Mat3::translate(v)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether point `p` lies inside a stretch frame (two corners, or a polygon).
|
||||
fn in_frame(frame: &[Vec2], p: Vec2) -> bool {
|
||||
match frame {
|
||||
[a, b] => p.x >= a.x.min(b.x) && p.x <= a.x.max(b.x) && p.y >= a.y.min(b.y) && p.y <= a.y.max(b.y),
|
||||
f if f.len() >= 3 => {
|
||||
let mut inside = false;
|
||||
let mut j = f.len() - 1;
|
||||
for i in 0..f.len() {
|
||||
let (Some(pi), Some(pj)) = (f.get(i), f.get(j)) else { break };
|
||||
if (pi.y > p.y) != (pj.y > p.y) && p.x < (pj.x - pi.x) * (p.y - pi.y) / (pj.y - pi.y) + pi.x {
|
||||
inside = !inside;
|
||||
}
|
||||
j = i;
|
||||
}
|
||||
inside
|
||||
}
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Give `insert` a new anonymous block with the plan's values applied (visibility states, and
|
||||
/// the stretch and move actions of the changed parameters, with the parameters in their selections
|
||||
/// carried along), and record the new values on it.
|
||||
fn apply(
|
||||
d: &mut Drawing,
|
||||
insert: Handle,
|
||||
definition: &str,
|
||||
values: &BTreeMap<i32, Value>,
|
||||
set: &BTreeMap<i32, Val>,
|
||||
direct: i32,
|
||||
) -> Result<(), String> {
|
||||
let m = dynamic_block(d, definition).ok_or("Not a dynamic block reference.")?;
|
||||
let EntityKind::Insert(ins) = &d.entity(insert).ok_or("No such object.")?.kind else { return Err("Not a block reference.".into()) };
|
||||
let ins = ins.clone();
|
||||
let def = d.block(definition).cloned().ok_or("The dynamic block is missing.")?;
|
||||
let src = d.block(&ins.block).cloned().ok_or("The reference's block is missing.")?;
|
||||
// The dynamic block's objects and the reference's copies of them, by element tag.
|
||||
let def_tags: HashMap<Handle, i64> = by_tag(d, &def).into_iter().map(|(t, h)| (h, t)).collect();
|
||||
let src_by_tag: HashMap<i64, Handle> = by_tag(d, &src).into_iter().collect();
|
||||
let copy_of = |dh: &Handle| def_tags.get(dh).and_then(|t| src_by_tag.get(t)).copied();
|
||||
// New values, starting from the change; actions add the parameters they carry along.
|
||||
let mut new: BTreeMap<i32, Val> = set.clone();
|
||||
let mut moves: HashMap<Handle, Vec<(Option<Vec<i32>>, Vec2)>> = HashMap::new();
|
||||
let param_of: HashMap<Handle, i32> = m.parameters.iter().map(|p| (p.handle, p.node)).collect();
|
||||
for a in &m.actions {
|
||||
let changed: Vec<&(i32, String)> = a.connections.iter().filter(|(n, _)| set.contains_key(n)).collect();
|
||||
if changed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let (mult, angle, frame, vertices): (f64, f64, &[Vec2], &[(Handle, Vec<i32>)]) = match &a.kind {
|
||||
ActionKind::Stretch { frame, vertices, multiplier, angle_offset } => (*multiplier, *angle_offset, frame, vertices),
|
||||
ActionKind::Move { multiplier, angle_offset } => (*multiplier, *angle_offset, &[], &[]),
|
||||
_ => continue,
|
||||
};
|
||||
// The two connections give the change in x and y.
|
||||
let comp = |i: usize| {
|
||||
a.connections.get(i).map_or(0.0, |(n, conn)| match (set.get(n), current(&m, values, *n)) {
|
||||
(Some(nv), Some(ov)) => delta(&ov, nv, conn),
|
||||
_ => 0.0,
|
||||
})
|
||||
};
|
||||
let mult = if mult == 0.0 { 1.0 } else { mult };
|
||||
let v = Vec2::new(comp(0), comp(1)).rotate(angle) * mult;
|
||||
if !(v.x.is_finite() && v.y.is_finite()) || (v.x == 0.0 && v.y == 0.0) {
|
||||
continue;
|
||||
}
|
||||
for h in &a.selection {
|
||||
if let Some(node) = param_of.get(h) {
|
||||
// A parameter in the selection is carried along: its points in the frame. Which
|
||||
// points are in it is decided where the dynamic block has them (AutoCAD evaluates
|
||||
// the actions from the block itself, so a point another action has moved out of
|
||||
// the frame still follows).
|
||||
if set.contains_key(node) {
|
||||
continue;
|
||||
}
|
||||
let Some(cur) = new.get(node).cloned().or_else(|| current(&m, values, *node)) else { continue };
|
||||
let home = current(&m, &BTreeMap::new(), *node);
|
||||
let shift = |p: Vec3, at: Option<Vec3>| {
|
||||
if frame.is_empty() || in_frame(frame, at.unwrap_or(p).xy()) { p + v.to3(0.0) } else { p }
|
||||
};
|
||||
let moved = match (cur, home) {
|
||||
(Val::Point(p), Some(Val::Point(h))) => Val::Point(shift(p, Some(h))),
|
||||
(Val::Point(p), _) => Val::Point(shift(p, None)),
|
||||
(Val::Points(b, e), Some(Val::Points(hb, he))) => Val::Points(shift(b, Some(hb)), shift(e, Some(he))),
|
||||
(Val::Points(b, e), _) => Val::Points(shift(b, None), shift(e, None)),
|
||||
(t, _) => t,
|
||||
};
|
||||
new.insert(*node, moved);
|
||||
continue;
|
||||
}
|
||||
let Some(c) = copy_of(h) else { continue };
|
||||
let idx = vertices.iter().find(|(vh, _)| vh == h).map(|(_, i)| i.clone());
|
||||
moves.entry(c).or_default().push((idx, v));
|
||||
}
|
||||
}
|
||||
// Visibility states.
|
||||
let mut shown: HashMap<Handle, bool> = HashMap::new();
|
||||
for (node, v) in &new {
|
||||
let (Some(ParameterKind::Visibility { controlled, states, .. }), Val::Text(state)) =
|
||||
(m.parameters.iter().find(|p| p.node == *node).map(|p| &p.kind), v)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let visible: HashSet<Handle> =
|
||||
states.iter().find(|s| s.name.eq_ignore_ascii_case(state)).map(|s| s.visible.iter().copied().collect()).unwrap_or_default();
|
||||
for h in controlled {
|
||||
shown.insert(*h, visible.contains(h));
|
||||
}
|
||||
}
|
||||
let shown_copy: HashMap<Handle, bool> = shown.iter().filter_map(|(dh, v)| copy_of(dh).map(|c| (c, *v))).collect();
|
||||
// The new anonymous block.
|
||||
let name = d.anonymous_block_name("*U");
|
||||
// AutoCAD's anonymous copies have no units of their own.
|
||||
let mut b =
|
||||
Block { name: name.clone(), anonymous: true, xref_path: None, array: None, units: 0, entities: crate::EntityStore::new(), ..(*src).clone() };
|
||||
let mut copies: HashMap<Handle, Handle> = HashMap::new();
|
||||
for e in src.entities.iter() {
|
||||
copies.insert(e.handle, d.new_handle());
|
||||
}
|
||||
// How far each attribute definition moves (block coordinates): the reference's attribute of
|
||||
// that tag moves with it, as AutoCAD moves it.
|
||||
let attrib_moves: HashMap<String, Vec2> = src
|
||||
.entities
|
||||
.iter()
|
||||
.filter_map(|e| match &e.kind {
|
||||
EntityKind::AttDef(ad) => {
|
||||
let v = moves.get(&e.handle).into_iter().flatten().fold(Vec2::ZERO, |acc, (_, v)| acc + *v);
|
||||
(v != Vec2::ZERO).then(|| (ad.tag.to_ascii_uppercase(), v))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
for e in src.entities.iter() {
|
||||
let Some(nh) = copies.get(&e.handle).copied() else { continue };
|
||||
let mut c: Entity = (**e).clone();
|
||||
c.handle = nh;
|
||||
if let Some(v) = shown_copy.get(&e.handle) {
|
||||
c.common.visible = *v;
|
||||
}
|
||||
for (idx, v) in moves.get(&e.handle).into_iter().flatten() {
|
||||
move_entity(&mut c, idx.as_deref(), *v);
|
||||
}
|
||||
if let EntityKind::Hatch(h) = &mut c.kind {
|
||||
for l in &mut h.loops {
|
||||
l.sources = l.sources.iter().map(|s| copies.get(s).copied().unwrap_or(*s)).collect();
|
||||
}
|
||||
}
|
||||
b.entities.push(c);
|
||||
}
|
||||
refill_hatches(&mut b);
|
||||
let def_record = Some(record_handle(d, definition).unwrap_or_else(|| {
|
||||
let h = d.new_handle();
|
||||
let f = std::sync::Arc::make_mut(&mut d.foreign);
|
||||
f.targets.insert(h, ("BLOCK_RECORD".to_string(), definition.to_ascii_uppercase()));
|
||||
h
|
||||
}));
|
||||
let old = ins.block.clone();
|
||||
d.blocks.insert(name.clone(), std::sync::Arc::new(b));
|
||||
let f = std::sync::Arc::make_mut(&mut d.foreign);
|
||||
for (old_h, new_h) in &copies {
|
||||
if let Some(mut x) = f.entities.get(old_h).cloned() {
|
||||
for t in &mut x.xdata {
|
||||
if t.code == 1005 && Handle::parse_hex(&t.value) == Some(*old_h) {
|
||||
t.value = new_h.hex();
|
||||
}
|
||||
}
|
||||
x.xdict = None;
|
||||
// Reactors within the block (an associative hatch's outline) follow to the copies;
|
||||
// ones outside it are not ours.
|
||||
x.reactors = x.reactors.iter().filter_map(|r| copies.get(r).copied()).collect();
|
||||
f.entities.insert(*new_h, x);
|
||||
}
|
||||
}
|
||||
if let Some(rec) = def_record {
|
||||
let xdata = vec![tag(1001, BTAG), tag(1070, "1"), tag(1005, rec.hex())];
|
||||
f.records.insert(("BLOCK_RECORD".into(), name.to_ascii_uppercase()), ForeignExtra { xdata, ..Default::default() });
|
||||
}
|
||||
// The block-to-drawing transform's linear part turns a block-coordinate move into the move of
|
||||
// the reference's attribute.
|
||||
let to_world = ins.transform(src.base.xy());
|
||||
let origin = to_world.apply(Vec2::ZERO);
|
||||
d.modify_entity(insert, |e| {
|
||||
if let EntityKind::Insert(i) = &mut e.kind {
|
||||
i.block = name.clone();
|
||||
for a in &mut i.attribs {
|
||||
if let Some(v) = attrib_moves.get(&a.tag.to_ascii_uppercase()) {
|
||||
let w = to_world.apply(*v) - origin;
|
||||
let mut k = EntityKind::Text(a.text.clone());
|
||||
k.transform(&Mat3::translate(w));
|
||||
if let EntityKind::Text(t) = k {
|
||||
a.text = t;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
let was = current(&m, values, direct);
|
||||
record_values(d, insert, def_record, &m, &new, (direct, was.as_ref()));
|
||||
drop_if_unused(d, &old);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Associative hatches of block `b` whose loop is one closed polyline of the block take its
|
||||
/// outline again (it may have been stretched).
|
||||
fn refill_hatches(b: &mut Block) {
|
||||
let outlines: HashMap<Handle, Vec<cadcraft_geom::PolyVertex>> = b
|
||||
.entities
|
||||
.iter()
|
||||
.filter_map(|e| match &e.kind {
|
||||
EntityKind::LwPolyline(pl) if pl.closed => Some((e.handle, pl.vertices.clone())),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let hatches: Vec<Handle> = b.entities.iter().filter(|e| matches!(&e.kind, EntityKind::Hatch(h) if h.associative)).map(|e| e.handle).collect();
|
||||
for h in hatches {
|
||||
b.entities.modify(h, |e| {
|
||||
if let EntityKind::Hatch(hh) = &mut e.kind {
|
||||
for l in &mut hh.loops {
|
||||
if let [s] = l.sources.as_slice()
|
||||
&& let Some(vs) = outlines.get(s)
|
||||
{
|
||||
l.vertices.clone_from(vs);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// The value records of the references to the dynamic block whose record is `def_record` (from
|
||||
/// any one of them, by graph id): AutoCAD's layout of each record, class ids included.
|
||||
fn templates(d: &Drawing, def_record: Handle) -> HashMap<String, Vec<RawTag>> {
|
||||
let objects: HashMap<Handle, &ForeignObject> = d.foreign.objects.iter().map(|o| (o.handle, o)).collect();
|
||||
let mut out: HashMap<String, Vec<RawTag>> = HashMap::new();
|
||||
for rep in d.foreign.objects.iter().filter(|o| o.kind.eq_ignore_ascii_case("DICTIONARY")) {
|
||||
// A representation dictionary whose data names this dynamic block.
|
||||
let Some(rd) = entry(d, rep.handle, "AcDbRepData").and_then(|h| objects.get(&h)) else { continue };
|
||||
if !rd.tags.iter().any(|t| t.code == 340 && Handle::parse_hex(&t.value) == Some(def_record)) {
|
||||
continue;
|
||||
}
|
||||
let Some(values) = entry(d, rep.handle, "AppDataCache").and_then(|c| entry(d, c, "ACAD_ENHANCEDBLOCKDATA")) else { continue };
|
||||
for (k, h) in entries(d, values) {
|
||||
if let Some(x) = objects.get(&h).filter(|x| x.kind.eq_ignore_ascii_case("XRECORD")) {
|
||||
out.entry(k).or_insert_with(|| x.tags.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A dictionary object `h` owned by `owner` with `entries`.
|
||||
fn dictionary(h: Handle, owner: Handle, entries: &[(&str, Handle)]) -> ForeignObject {
|
||||
let mut tags = vec![tag(5, h.hex()), tag(330, owner.hex()), tag(100, "AcDbDictionary"), tag(280, "1"), tag(281, "1")];
|
||||
for (k, v) in entries {
|
||||
tags.push(tag(3, *k));
|
||||
tags.push(tag(360, v.hex()));
|
||||
}
|
||||
ForeignObject { handle: h, kind: "DICTIONARY".into(), tags }
|
||||
}
|
||||
|
||||
/// `tags` (a template's groups) as new record `h` owned by `owner`: the template's handle, owner
|
||||
/// and reactors are not ours (inside reactor brackets, 102, everything goes).
|
||||
fn own(mut tags: Vec<RawTag>, h: Handle, owner: Handle) -> Vec<RawTag> {
|
||||
let mut inside = false;
|
||||
tags.retain(|t| {
|
||||
if t.code == 102 {
|
||||
inside = t.value.starts_with('{');
|
||||
return false;
|
||||
}
|
||||
!(inside || t.code == 5 || t.code == 330)
|
||||
});
|
||||
let at = tags.iter().position(|t| t.code == 100).unwrap_or(0);
|
||||
tags.insert(at, tag(330, owner.hex()));
|
||||
tags.insert(0, tag(5, h.hex()));
|
||||
tags
|
||||
}
|
||||
|
||||
/// Record values `set` (by parameter graph id) in `insert`'s representation data, and the direct
|
||||
/// change (of parameter `direct`, from value `old`) in its history, creating them when the reference has none. A
|
||||
/// new reference's data gets a record for everything other references of the dynamic block have
|
||||
/// (AutoCAD keeps one for each parameter and stretch action), with its own values; with no other
|
||||
/// reference to copy, a changed value gets a minimal record.
|
||||
fn record_values(
|
||||
d: &mut Drawing,
|
||||
insert: Handle,
|
||||
def_record: Option<Handle>,
|
||||
m: &DynamicBlock,
|
||||
set: &BTreeMap<i32, Val>,
|
||||
(direct, old): (i32, Option<&Val>),
|
||||
) {
|
||||
let xdict = d.foreign.entities.get(&insert).and_then(|x| x.xdict);
|
||||
let rep = xdict.and_then(|x| entry(d, x, "AcDbBlockRepresentation"));
|
||||
let cache = rep.and_then(|r| entry(d, r, "AppDataCache"));
|
||||
let data = cache.and_then(|c| entry(d, c, "ACAD_ENHANCEDBLOCKDATA"));
|
||||
let templates = def_record.map(|r| templates(d, r)).unwrap_or_default();
|
||||
let param = |node: i32| m.parameters.iter().find(|p| p.node == node).map(|p| &p.kind);
|
||||
let at = |node: i32| match param(node) {
|
||||
Some(
|
||||
ParameterKind::Visibility { at, .. }
|
||||
| ParameterKind::Lookup { at, .. }
|
||||
| ParameterKind::Point { at, .. }
|
||||
| ParameterKind::BasePoint { at },
|
||||
) => *at,
|
||||
_ => Vec3::ZERO,
|
||||
};
|
||||
let mut objects: Vec<ForeignObject> = Vec::new();
|
||||
let mut fresh: Vec<(String, Vec<RawTag>)> = Vec::new();
|
||||
let (cache, data) = match (cache, data) {
|
||||
(Some(c), Some(h)) => (c, h),
|
||||
_ => {
|
||||
// Create the representation: reference xdict ▸ AcDbBlockRepresentation ▸ AcDbRepData
|
||||
// (the dynamic block's record) and AppDataCache ▸ ACAD_ENHANCEDBLOCKDATA.
|
||||
let Some(rec) = def_record else { return };
|
||||
let xd = xdict.unwrap_or_else(|| d.new_handle());
|
||||
let (rep, rd, cache, data) = (d.new_handle(), d.new_handle(), d.new_handle(), d.new_handle());
|
||||
objects.push(dictionary(rep, xd, &[("AcDbRepData", rd), ("AppDataCache", cache)]));
|
||||
objects.push(ForeignObject {
|
||||
handle: rd,
|
||||
kind: "ACDB_BLOCKREPRESENTATION_DATA".into(),
|
||||
tags: vec![tag(5, rd.hex()), tag(330, rep.hex()), tag(100, "AcDbBlockRepresentationData"), tag(70, "1"), tag(340, rec.hex())],
|
||||
});
|
||||
objects.push(dictionary(cache, rep, &[("ACAD_ENHANCEDBLOCKDATA", data)]));
|
||||
objects.push(dictionary(data, cache, &[]));
|
||||
// Every record the other references have, with this reference's values: its
|
||||
// parameters' positions and defaults (lookups as Properties shows them); action
|
||||
// records as they are.
|
||||
let lookups: HashMap<String, String> = super::custom_properties(d, insert)
|
||||
.map(|(_, ps)| ps.into_iter().map(|p| (p.name.to_ascii_uppercase(), p.value)).collect())
|
||||
.unwrap_or_default();
|
||||
let mut keys: Vec<&String> = templates.keys().collect();
|
||||
keys.sort();
|
||||
for k in keys {
|
||||
let (Ok(node), Some(t)) = (k.parse::<i32>(), templates.get(k)) else { continue };
|
||||
let mut tags = t.clone();
|
||||
match param(node) {
|
||||
Some(ParameterKind::Visibility { .. } | ParameterKind::Point { .. } | ParameterKind::BasePoint { .. }) => {
|
||||
write_value(&mut tags, &Val::Point(at(node)));
|
||||
}
|
||||
Some(ParameterKind::Lookup { label, .. }) => {
|
||||
write_value(&mut tags, &Val::Point(at(node)));
|
||||
if let Some(v) = lookups.get(&label.to_ascii_uppercase()) {
|
||||
write_value(&mut tags, &Val::Text(v.clone()));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if let Some(v) = current(m, &BTreeMap::new(), node) {
|
||||
write_value(&mut tags, &v);
|
||||
}
|
||||
fresh.push((k.clone(), tags));
|
||||
}
|
||||
let f = std::sync::Arc::make_mut(&mut d.foreign);
|
||||
match f.objects.iter_mut().find(|o| Some(o.handle) == xdict) {
|
||||
Some(x) => {
|
||||
x.tags.push(tag(3, "AcDbBlockRepresentation"));
|
||||
x.tags.push(tag(360, rep.hex()));
|
||||
}
|
||||
None => {
|
||||
objects.push(dictionary(xd, insert, &[("AcDbBlockRepresentation", rep)]));
|
||||
f.entities.entry(insert).or_default().xdict = Some(xd);
|
||||
}
|
||||
}
|
||||
(cache, data)
|
||||
}
|
||||
};
|
||||
let existing: HashMap<String, Handle> = match objects.iter().find(|o| o.handle == data) {
|
||||
Some(_) => HashMap::new(),
|
||||
None => entries(d, data).into_iter().collect(),
|
||||
};
|
||||
let mut add: Vec<(String, Handle)> = Vec::new();
|
||||
for (key, mut tags) in fresh {
|
||||
if let Some(v) = key.parse::<i32>().ok().and_then(|n| set.get(&n)) {
|
||||
write_value(&mut tags, v);
|
||||
}
|
||||
let h = d.new_handle();
|
||||
objects.push(ForeignObject { handle: h, kind: "XRECORD".into(), tags: own(tags, h, data) });
|
||||
add.push((key, h));
|
||||
}
|
||||
for (node, v) in set {
|
||||
let key = node.to_string();
|
||||
if add.iter().any(|(k, _)| *k == key) {
|
||||
continue;
|
||||
}
|
||||
match existing.get(&key) {
|
||||
Some(h) => {
|
||||
let f = std::sync::Arc::make_mut(&mut d.foreign);
|
||||
if let Some(x) = f.objects.iter_mut().find(|o| o.handle == *h) {
|
||||
write_value(&mut x.tags, v);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let h = d.new_handle();
|
||||
let mut tags = templates.get(&key).cloned().unwrap_or_else(|| {
|
||||
let p = at(*node);
|
||||
vec![tag(100, "AcDbXrecord"), tag(280, "1"), tag(10, p.x.to_string()), tag(20, p.y.to_string()), tag(30, p.z.to_string())]
|
||||
});
|
||||
write_value(&mut tags, v);
|
||||
objects.push(ForeignObject { handle: h, kind: "XRECORD".into(), tags: own(tags, h, data) });
|
||||
add.push((key, h));
|
||||
}
|
||||
}
|
||||
}
|
||||
// The history: AutoCAD replays it over the dynamic block (when the block is redefined), so
|
||||
// it must say what was done to this reference.
|
||||
let history = entry(d, cache, "ACAD_ENHANCEDBLOCKHISTORY").filter(|h| d.foreign.objects.iter().any(|o| o.handle == *h));
|
||||
let mut new_history = None;
|
||||
if let (Some(v), Some(p)) = (set.get(&direct), m.parameters.iter().find(|p| p.node == direct)) {
|
||||
match history {
|
||||
Some(h) => {
|
||||
let f = std::sync::Arc::make_mut(&mut d.foreign);
|
||||
if let Some(x) = f.objects.iter_mut().find(|o| o.handle == h) {
|
||||
write_history(&mut x.tags, p, old, v);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let h = d.new_handle();
|
||||
let mut tags = vec![tag(5, h.hex()), tag(330, cache.hex()), tag(100, "AcDbXrecord"), tag(280, "1"), tag(1070, "3")];
|
||||
write_history(&mut tags, p, old, v);
|
||||
objects.push(ForeignObject { handle: h, kind: "XRECORD".into(), tags });
|
||||
new_history = Some(h);
|
||||
}
|
||||
}
|
||||
}
|
||||
let f = std::sync::Arc::make_mut(&mut d.foreign);
|
||||
f.objects.extend(objects);
|
||||
if let Some(x) = f.objects.iter_mut().find(|o| o.handle == data) {
|
||||
for (k, h) in add {
|
||||
x.tags.push(tag(3, k));
|
||||
x.tags.push(tag(360, h.hex()));
|
||||
}
|
||||
}
|
||||
if let (Some(h), Some(x)) = (new_history, f.objects.iter_mut().find(|o| o.handle == cache)) {
|
||||
x.tags.push(tag(3, "ACAD_ENHANCEDBLOCKHISTORY"));
|
||||
x.tags.push(tag(360, h.hex()));
|
||||
}
|
||||
}
|
||||
|
||||
/// Record the direct change of parameter `p` to `v` in a reference's history (groups `tags`):
|
||||
/// after its header (`280 1`, `1070 3`), an entry per change: `1071` the id, `300` what, then
|
||||
/// the value. A visibility state or lookup value goes by the property's name (`1` the text), as
|
||||
/// does a distance (`40`, in block units: typed in Properties, it replaces its grips' drags); a
|
||||
/// position as the drag of its grip, `GRIPLOC` with how far it is from where the dynamic block
|
||||
/// has it (`11/21/31`), when the change moves it (from `old`). An entry for the same id is
|
||||
/// replaced; a grip back in its place has none.
|
||||
fn write_history(tags: &mut Vec<RawTag>, p: &super::Parameter, old: Option<&Val>, v: &Val) {
|
||||
let grip = |id: i32, moved: Vec3| {
|
||||
let home = moved.x.abs() < 1e-9 && moved.y.abs() < 1e-9 && moved.z.abs() < 1e-9;
|
||||
let e = vec![
|
||||
tag(1071, id.to_string()),
|
||||
tag(300, "GRIPLOC"),
|
||||
tag(11, moved.x.to_string()),
|
||||
tag(21, moved.y.to_string()),
|
||||
tag(31, moved.z.to_string()),
|
||||
];
|
||||
(id, if home { Vec::new() } else { e })
|
||||
};
|
||||
// Each grip with where it was, where it is and where the dynamic block has it.
|
||||
let grips = |places: &[(Vec3, Vec3, Vec3)]| -> Vec<(i32, Vec<RawTag>)> {
|
||||
p.grips
|
||||
.iter()
|
||||
.zip(places)
|
||||
.filter(|(g, (was, is, _))| **g != 0 && (*was - *is).len() > 1e-9)
|
||||
.map(|(g, (_, is, home))| grip(*g, *is - *home))
|
||||
.collect()
|
||||
};
|
||||
let was = match old {
|
||||
Some(Val::Point(q)) => Some(*q),
|
||||
_ => None,
|
||||
};
|
||||
let changes: Vec<(i32, Vec<RawTag>)> = match (&p.kind, v) {
|
||||
(ParameterKind::Visibility { label, .. } | ParameterKind::Lookup { label, .. }, Val::Text(t)) => {
|
||||
vec![(p.node, vec![tag(1071, p.node.to_string()), tag(300, label.clone()), tag(1, t.clone())])]
|
||||
}
|
||||
(ParameterKind::Linear { label, .. }, Val::Points(b, e)) => {
|
||||
let mut c: Vec<(i32, Vec<RawTag>)> = p.grips.iter().filter(|g| **g != 0).map(|g| (*g, Vec::new())).collect();
|
||||
c.push((p.node, vec![tag(1071, p.node.to_string()), tag(300, label.clone()), tag(40, (*e - *b).len().to_string())]));
|
||||
c
|
||||
}
|
||||
(ParameterKind::Point { at, .. }, Val::Point(q)) => grips(&[(was.unwrap_or(*at), *q, *at)]),
|
||||
_ => return,
|
||||
};
|
||||
// The header, then the entries (each from its 1071).
|
||||
let start = tags.iter().position(|t| t.code == 1071).unwrap_or(tags.len());
|
||||
let mut entries: Vec<Vec<RawTag>> = Vec::new();
|
||||
for t in tags.drain(start..) {
|
||||
if t.code == 1071 || entries.is_empty() {
|
||||
entries.push(Vec::new());
|
||||
}
|
||||
if let Some(e) = entries.last_mut() {
|
||||
e.push(t);
|
||||
}
|
||||
}
|
||||
let id = |e: &[RawTag]| e.first().filter(|t| t.code == 1071).and_then(|t| t.value.trim().parse::<i32>().ok());
|
||||
entries.retain(|e| !changes.iter().any(|(c, _)| id(e) == Some(*c)));
|
||||
entries.extend(changes.into_iter().map(|(_, e)| e).filter(|e| !e.is_empty()));
|
||||
tags.extend(entries.into_iter().flatten());
|
||||
}
|
||||
|
||||
/// Write value `v` into a value record's groups: its text (group 1), or its points in order over
|
||||
/// the record's points (10/20/30), adding them when it has fewer.
|
||||
fn write_value(tags: &mut Vec<RawTag>, v: &Val) {
|
||||
let points: Vec<Vec3> = match v {
|
||||
Val::Text(t) => {
|
||||
match tags.iter_mut().find(|x| x.code == 1) {
|
||||
Some(x) => x.value.clone_from(t),
|
||||
None => tags.push(tag(1, t.clone())),
|
||||
}
|
||||
return;
|
||||
}
|
||||
Val::Point(p) => vec![*p],
|
||||
Val::Points(b, e) => vec![*b, *e],
|
||||
};
|
||||
for (i, p) in points.iter().enumerate() {
|
||||
// The i-th point's groups, else new ones before any text.
|
||||
let find = |tags: &[RawTag], code: i32, n: usize| tags.iter().enumerate().filter(|(_, t)| t.code == code).nth(n).map(|(j, _)| j);
|
||||
match (find(tags, 10, i), find(tags, 20, i)) {
|
||||
(Some(jx), Some(jy)) => {
|
||||
if let Some(t) = tags.get_mut(jx) {
|
||||
t.value = p.x.to_string();
|
||||
}
|
||||
if let Some(t) = tags.get_mut(jy) {
|
||||
t.value = p.y.to_string();
|
||||
}
|
||||
if let Some(t) = find(tags, 30, i).and_then(|jz| tags.get_mut(jz)) {
|
||||
t.value = p.z.to_string();
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let at = tags.iter().position(|t| t.code == 1).unwrap_or(tags.len());
|
||||
tags.splice(at..at, [tag(10, p.x.to_string()), tag(20, p.y.to_string()), tag(30, p.z.to_string())]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove anonymous block `name` when nothing refers to it any more (the block a changed
|
||||
/// reference used to show), with the data kept for its record.
|
||||
fn drop_if_unused(d: &mut Drawing, name: &str) {
|
||||
if !name.starts_with('*') {
|
||||
return;
|
||||
}
|
||||
let refers = |e: &Entity| matches!(&e.kind, EntityKind::Insert(i) if i.block.eq_ignore_ascii_case(name));
|
||||
let used = d.model.iter().any(|e| refers(e))
|
||||
|| d.layouts.iter().any(|l| l.entities.iter().any(|e| refers(e)))
|
||||
|| d.blocks.values().any(|b| b.entities.iter().any(|e| refers(e)));
|
||||
if used {
|
||||
return;
|
||||
}
|
||||
if let Some(b) = d.blocks.remove(name) {
|
||||
let f = std::sync::Arc::make_mut(&mut d.foreign);
|
||||
f.records.remove(&("BLOCK_RECORD".to_string(), name.to_ascii_uppercase()));
|
||||
f.records.remove(&("BLOCK".to_string(), name.to_ascii_uppercase()));
|
||||
for e in b.entities.iter() {
|
||||
f.entities.remove(&e.handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
//! The grips of a dynamic block reference, as AutoCAD shows them on a selected reference: an
|
||||
//! arrow at each end of a distance (linear parameter), a square at a position (point parameter)
|
||||
//! and a down-pointing triangle for a list (visibility states, lookup values). Dragging an arrow
|
||||
//! or square is [`super::drag_grip`]; a list grip offers its property's choices.
|
||||
//!
|
||||
//! With a visibility parameter, a state lists the grips shown in it; the others are hidden.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use cadcraft_geom::{Vec2, Vec3};
|
||||
use serde::Serialize;
|
||||
|
||||
use super::{ParameterKind, Value, custom_properties, dynamic_block, reference};
|
||||
use crate::{Drawing, EntityKind, Handle};
|
||||
|
||||
/// Most grips listed for one reference.
|
||||
const MAX_GRIPS: usize = 200;
|
||||
|
||||
/// Which of a parameter's points a grip is at.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum GripPart {
|
||||
/// A distance's base point.
|
||||
Base,
|
||||
/// A distance's end point.
|
||||
End,
|
||||
/// A one-point parameter's point (a position, a visibility or lookup grip).
|
||||
At,
|
||||
}
|
||||
|
||||
impl GripPart {
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
GripPart::Base => "base",
|
||||
GripPart::End => "end",
|
||||
GripPart::At => "at",
|
||||
}
|
||||
}
|
||||
pub fn from_name(s: &str) -> Option<GripPart> {
|
||||
match s.trim().to_ascii_lowercase().as_str() {
|
||||
"base" => Some(GripPart::Base),
|
||||
"end" => Some(GripPart::End),
|
||||
"at" => Some(GripPart::At),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How a grip looks and what it does.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum GripShape {
|
||||
/// An arrow pointing away from the distance: drag it to change the distance.
|
||||
Linear,
|
||||
/// A square: drag it to move the position.
|
||||
Point,
|
||||
/// A down-pointing triangle: click it for the property's choices.
|
||||
List,
|
||||
}
|
||||
|
||||
/// One grip of a dynamic block reference.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DynGrip {
|
||||
/// The parameter's graph id.
|
||||
pub node: i32,
|
||||
pub part: GripPart,
|
||||
pub shape: GripShape,
|
||||
/// Where it is, in drawing coordinates.
|
||||
#[serde(serialize_with = "point")]
|
||||
pub at: Vec2,
|
||||
/// The way an arrow points (a unit vector in drawing coordinates; zero for the others).
|
||||
#[serde(serialize_with = "point")]
|
||||
pub dir: Vec2,
|
||||
/// The property it changes, as Properties names it.
|
||||
pub property: String,
|
||||
/// A list grip's choices.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub choices: Vec<String>,
|
||||
}
|
||||
|
||||
fn point<S: serde::Serializer>(p: &Vec2, s: S) -> Result<S::Ok, S::Error> {
|
||||
[p.x, p.y].serialize(s)
|
||||
}
|
||||
|
||||
/// The grips of dynamic block reference `insert` (none when it is not one).
|
||||
pub fn grips(d: &Drawing, insert: Handle) -> Vec<DynGrip> {
|
||||
let Some(r) = reference(d, insert) else { return Vec::new() };
|
||||
let Some(m) = dynamic_block(d, &r.definition) else { return Vec::new() };
|
||||
let Some(EntityKind::Insert(ins)) = d.entity(insert).map(|e| &e.kind) else { return Vec::new() };
|
||||
let base = d.block(&r.definition).map(|b| b.base.xy()).unwrap_or_default();
|
||||
let xf = ins.transform(base);
|
||||
let world = |p: Vec3| xf.apply(p.xy());
|
||||
let toward = |p: Vec3, u: Vec3| {
|
||||
let v = xf.apply(p.xy() + u.xy()) - xf.apply(p.xy());
|
||||
if v.len() > 1e-12 { v * (1.0 / v.len()) } else { Vec2::ZERO }
|
||||
};
|
||||
// The grips the current visibility state shows (all of them without visibility states).
|
||||
let objects: HashMap<i32, Handle> = m.grip_objects.iter().copied().collect();
|
||||
let mut shown_in_state: Option<HashSet<Handle>> = None;
|
||||
let mut visibility_node = None;
|
||||
for p in &m.parameters {
|
||||
if let ParameterKind::Visibility { states, .. } = &p.kind {
|
||||
let current = match r.values.get(&p.node) {
|
||||
Some(Value::Text { value }) => states.iter().find(|s| s.name.eq_ignore_ascii_case(value)),
|
||||
_ => states.first(),
|
||||
};
|
||||
if let Some(s) = current {
|
||||
shown_in_state = Some(s.active.iter().copied().collect());
|
||||
}
|
||||
visibility_node = Some(p.node);
|
||||
break;
|
||||
}
|
||||
}
|
||||
let shown = |node: i32, id: i32| {
|
||||
id != 0 && (Some(node) == visibility_node || shown_in_state.as_ref().is_none_or(|a| objects.get(&id).is_some_and(|h| a.contains(h))))
|
||||
};
|
||||
let choices: HashMap<String, Vec<String>> =
|
||||
custom_properties(d, insert).map(|(_, ps)| ps.into_iter().map(|p| (p.name.to_ascii_uppercase(), p.choices)).collect()).unwrap_or_default();
|
||||
let mut out = Vec::new();
|
||||
for p in &m.parameters {
|
||||
let grip = |i: usize| p.grips.get(i).copied().unwrap_or(0);
|
||||
match &p.kind {
|
||||
ParameterKind::Linear { base, end, label, .. } => {
|
||||
let (b, e) = match r.values.get(&p.node) {
|
||||
Some(Value::Points { base, end }) => (*base, *end),
|
||||
_ => (*base, *end),
|
||||
};
|
||||
let u = e - b;
|
||||
let u = if u.len() > 1e-12 { u * (1.0 / u.len()) } else { Vec3::new(1.0, 0.0, 0.0) };
|
||||
for (i, part, at, way) in [(0, GripPart::Base, b, u * -1.0), (1, GripPart::End, e, u)] {
|
||||
if shown(p.node, grip(i)) {
|
||||
let (at, dir) = (world(at), toward(at, way));
|
||||
out.push(DynGrip { node: p.node, part, shape: GripShape::Linear, at, dir, property: label.clone(), choices: Vec::new() });
|
||||
}
|
||||
}
|
||||
}
|
||||
ParameterKind::Point { at, label, .. } => {
|
||||
let at = match r.values.get(&p.node) {
|
||||
Some(Value::Point { at }) => *at,
|
||||
_ => *at,
|
||||
};
|
||||
if shown(p.node, grip(0)) {
|
||||
let at = world(at);
|
||||
out.push(DynGrip {
|
||||
node: p.node,
|
||||
part: GripPart::At,
|
||||
shape: GripShape::Point,
|
||||
at,
|
||||
dir: Vec2::ZERO,
|
||||
property: label.clone(),
|
||||
choices: Vec::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
ParameterKind::Visibility { at, label, .. } | ParameterKind::Lookup { at, label, .. } => {
|
||||
let Some(list) = choices.get(&label.to_ascii_uppercase()).filter(|c| !c.is_empty()) else { continue };
|
||||
if shown(p.node, grip(0)) {
|
||||
let at = world(*at);
|
||||
out.push(DynGrip {
|
||||
node: p.node,
|
||||
part: GripPart::At,
|
||||
shape: GripShape::List,
|
||||
at,
|
||||
dir: Vec2::ZERO,
|
||||
property: label.clone(),
|
||||
choices: list.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if out.len() >= MAX_GRIPS {
|
||||
break;
|
||||
}
|
||||
}
|
||||
out.retain(|g| g.at.is_finite());
|
||||
out
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
//! Dynamic block references: which dynamic block a block reference shows, the values it was given
|
||||
//! and the custom properties those make.
|
||||
//!
|
||||
//! A reference whose values differ from the block's own refers to an anonymous block (`*U12`)
|
||||
//! holding the result, and its extension dictionary says what it is: `AcDbBlockRepresentation`
|
||||
//! has `AcDbRepData` (pointing at the dynamic block's BLOCK_RECORD) and an `AppDataCache` whose
|
||||
//! `ACAD_ENHANCEDBLOCKDATA` dictionary holds one XRECORD per parameter, keyed by its graph id, with
|
||||
//! the value in block coordinates. A reference to the dynamic block itself has the block's values.
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
|
||||
use cadcraft_geom::Vec3;
|
||||
use serde::Serialize;
|
||||
|
||||
use super::{ActionKind, DynamicBlock, ParameterKind, dynamic_block};
|
||||
use crate::foreign::ForeignObject;
|
||||
use crate::{Drawing, EntityKind, Handle};
|
||||
|
||||
/// Most values one reference is read with.
|
||||
const MAX_VALUES: usize = 10_000;
|
||||
|
||||
/// A parameter's value on one reference, in block coordinates.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")]
|
||||
pub enum Value {
|
||||
/// A visibility state's name or a lookup value.
|
||||
Text { value: String },
|
||||
/// A point parameter's position.
|
||||
Point { at: Vec3 },
|
||||
/// A two-point parameter's points.
|
||||
Points { base: Vec3, end: Vec3 },
|
||||
}
|
||||
|
||||
/// A dynamic block reference.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Reference {
|
||||
/// The dynamic block it shows (its name in the drawing).
|
||||
pub definition: String,
|
||||
/// The values it was given, by parameter graph id; parameters missing here have the block's
|
||||
/// own values.
|
||||
pub values: BTreeMap<i32, Value>,
|
||||
}
|
||||
|
||||
/// One custom property of a reference, as the Properties palette shows it.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CustomProperty {
|
||||
pub name: String,
|
||||
pub value: String,
|
||||
/// The values it may be set to, when it is a choice (visibility states, lookup values, a
|
||||
/// distance's list).
|
||||
pub choices: Vec<String>,
|
||||
/// CADCraft can change it ([`super::set_property`]): a visibility state, a distance, a
|
||||
/// position, or a lookup whose tables set only those.
|
||||
pub editable: bool,
|
||||
}
|
||||
|
||||
fn objects(d: &Drawing) -> HashMap<Handle, &ForeignObject> {
|
||||
d.foreign.objects.iter().map(|o| (o.handle, o)).collect()
|
||||
}
|
||||
|
||||
/// The object entry `key` of dictionary `dict` refers to.
|
||||
fn entry(objects: &HashMap<Handle, &ForeignObject>, dict: Handle, key: &str) -> Option<Handle> {
|
||||
let o = objects.get(&dict)?;
|
||||
let mut tags = o.tags.iter();
|
||||
while let Some(t) = tags.next() {
|
||||
if t.code == 3 && t.value.eq_ignore_ascii_case(key) {
|
||||
return tags.next().filter(|t| t.code == 350 || t.code == 360).and_then(|t| Handle::parse_hex(&t.value));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Every entry of dictionary `dict`: (key, object).
|
||||
fn entries(objects: &HashMap<Handle, &ForeignObject>, dict: Handle) -> Vec<(String, Handle)> {
|
||||
let Some(o) = objects.get(&dict) else { return Vec::new() };
|
||||
let mut out = Vec::new();
|
||||
let mut key: Option<&str> = None;
|
||||
for t in &o.tags {
|
||||
match t.code {
|
||||
3 => key = Some(t.value.as_str()),
|
||||
350 | 360 => {
|
||||
if let (Some(k), Some(h)) = (key.take(), Handle::parse_hex(&t.value)) {
|
||||
out.push((k.to_string(), h));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A value XRECORD: its text (group 1) when it has one, else its points (10/20/30).
|
||||
fn value(o: &ForeignObject) -> Option<Value> {
|
||||
if let Some(t) = o.tags.iter().find(|t| t.code == 1) {
|
||||
return Some(Value::Text { value: t.value.clone() });
|
||||
}
|
||||
let num = |code: i32, i: usize| {
|
||||
o.tags.iter().filter(|t| t.code == code).nth(i).and_then(|t| t.value.trim().parse::<f64>().ok()).filter(|v| v.is_finite())
|
||||
};
|
||||
let point = |i: usize| Some(Vec3::new(num(10, i)?, num(20, i)?, num(30, i).unwrap_or(0.0)));
|
||||
match (point(0), point(1)) {
|
||||
(Some(base), Some(end)) => Some(Value::Points { base, end }),
|
||||
(Some(at), None) => Some(Value::Point { at }),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The dynamic block reference `insert` is, or `None` when it isn't one.
|
||||
pub fn reference(d: &Drawing, insert: Handle) -> Option<Reference> {
|
||||
let EntityKind::Insert(ins) = &d.entity(insert)?.kind else { return None };
|
||||
let objects = objects(d);
|
||||
let rep = d.foreign.entities.get(&insert).and_then(|x| x.xdict).and_then(|x| entry(&objects, x, "AcDbBlockRepresentation"));
|
||||
let Some(rep) = rep else {
|
||||
// No representation: a reference to a dynamic block with its own values.
|
||||
return dynamic_block(d, &ins.block)
|
||||
.is_some()
|
||||
.then(|| Reference { definition: d.block(&ins.block).map_or_else(|| ins.block.clone(), |b| b.name.clone()), values: BTreeMap::new() });
|
||||
};
|
||||
let data = entry(&objects, rep, "AcDbRepData").and_then(|h| objects.get(&h).copied())?;
|
||||
let target = data.tags.iter().find(|t| t.code == 340).and_then(|t| Handle::parse_hex(&t.value))?;
|
||||
let (kind, name) = d.foreign.targets.get(&target)?;
|
||||
let definition = d.block(name).filter(|_| kind == "BLOCK_RECORD")?.name.clone();
|
||||
dynamic_block(d, &definition)?;
|
||||
let mut values = BTreeMap::new();
|
||||
let cache = entry(&objects, rep, "AppDataCache").and_then(|c| entry(&objects, c, "ACAD_ENHANCEDBLOCKDATA"));
|
||||
for (key, h) in cache.map(|c| entries(&objects, c)).unwrap_or_default().into_iter().take(MAX_VALUES) {
|
||||
if let (Ok(node), Some(v)) = (key.trim().parse::<i32>(), objects.get(&h).and_then(|o| value(o))) {
|
||||
values.insert(node, v);
|
||||
}
|
||||
}
|
||||
Some(Reference { definition, values })
|
||||
}
|
||||
|
||||
/// A number as the drawing shows lengths (LUPREC decimals).
|
||||
fn number(d: &Drawing, v: f64) -> String {
|
||||
let prec = usize::try_from(d.header.i64("LUPREC", 4).clamp(0, 8)).unwrap_or(4);
|
||||
format!("{v:.prec$}")
|
||||
}
|
||||
|
||||
/// Whether table cell `cell` reads as `value` (numbers compare as numbers).
|
||||
fn same(cell: &str, value: &str) -> bool {
|
||||
match (cell.trim().parse::<f64>(), value.trim().parse::<f64>()) {
|
||||
(Ok(a), Ok(b)) => (a - b).abs() <= 1e-6 * a.abs().max(b.abs()).max(1.0),
|
||||
_ => cell.trim().eq_ignore_ascii_case(value.trim()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The current values of a reference's parameters, for evaluating its lookup tables.
|
||||
struct Current<'a> {
|
||||
m: &'a DynamicBlock,
|
||||
values: &'a BTreeMap<i32, Value>,
|
||||
}
|
||||
|
||||
impl Current<'_> {
|
||||
/// Parameter `node`'s value of property `property`, as table cells write it.
|
||||
fn property(&self, node: i32, property: &str) -> Option<String> {
|
||||
let p = self.m.parameters.iter().find(|p| p.node == node)?;
|
||||
let v = self.values.get(&node);
|
||||
match (&p.kind, property.to_ascii_lowercase().as_str()) {
|
||||
(ParameterKind::Visibility { states, .. }, "visibilitystate") => match v {
|
||||
Some(Value::Text { value }) => Some(value.clone()),
|
||||
_ => states.first().map(|s| s.name.clone()),
|
||||
},
|
||||
(ParameterKind::Linear { base, end, .. }, "updateddistance" | "distance") => {
|
||||
let (b, e) = match v {
|
||||
Some(Value::Points { base, end }) => (*base, *end),
|
||||
_ => (*base, *end),
|
||||
};
|
||||
Some(format!("{}", (e - b).len()))
|
||||
}
|
||||
(ParameterKind::Lookup { .. }, "lookupstring") => match v {
|
||||
Some(Value::Text { value }) => Some(value.clone()),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Lookup parameter `node`'s value: the value every lookup table that sets it gives for the
|
||||
/// current inputs, or the tables' "no match" name ("Custom") when they don't agree or a table
|
||||
/// has no matching row. Also the values it may be set to.
|
||||
fn lookup(&self, node: i32) -> (String, Vec<String>) {
|
||||
let mut found: Option<Option<String>> = None;
|
||||
let mut unmatched = String::from("Custom");
|
||||
let mut choices: Vec<String> = Vec::new();
|
||||
for a in &self.m.actions {
|
||||
let ActionKind::Lookup { columns, rows } = &a.kind else { continue };
|
||||
let Some(out) = columns.iter().position(|c| c.lookup && c.node == node) else { continue };
|
||||
if rows.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Some(c) = columns.get(out) {
|
||||
unmatched = c.unmatched.clone();
|
||||
}
|
||||
for r in rows {
|
||||
if let Some(v) = r.get(out).filter(|v| !choices.contains(v)) {
|
||||
choices.push(v.clone());
|
||||
}
|
||||
}
|
||||
let hit = rows.iter().find(|r| {
|
||||
columns.iter().enumerate().filter(|(_, c)| !c.lookup).all(|(i, c)| match (r.get(i), self.property(c.node, &c.property)) {
|
||||
(Some(cell), Some(v)) => same(cell, &v),
|
||||
_ => false,
|
||||
})
|
||||
});
|
||||
let v = hit.and_then(|r| r.get(out).cloned());
|
||||
found = Some(match found {
|
||||
None => v,
|
||||
Some(prev) if prev == v => prev,
|
||||
Some(_) => None,
|
||||
});
|
||||
}
|
||||
(found.flatten().unwrap_or(unmatched), choices)
|
||||
}
|
||||
}
|
||||
|
||||
/// The custom properties block reference `insert` shows (the Properties palette's Custom
|
||||
/// section): its dynamic block's name and, in parameter order, each parameter shown in Properties
|
||||
/// with its value. Lengths and positions are in drawing units (block units × the reference's
|
||||
/// scale); positions are relative to the block's base point.
|
||||
pub fn custom_properties(d: &Drawing, insert: Handle) -> Option<(String, Vec<CustomProperty>)> {
|
||||
let r = reference(d, insert)?;
|
||||
let EntityKind::Insert(ins) = &d.entity(insert)?.kind else { return None };
|
||||
let m = dynamic_block(d, &r.definition)?;
|
||||
let base = d.block(&r.definition).map(|b| b.base).unwrap_or_default();
|
||||
let (sx, sy) = (ins.scale.x.abs(), ins.scale.y.abs());
|
||||
let cur = Current { m: &m, values: &r.values };
|
||||
let mut out = Vec::new();
|
||||
for p in m.parameters.iter().filter(|p| p.show_properties) {
|
||||
let v = r.values.get(&p.node);
|
||||
match &p.kind {
|
||||
ParameterKind::Linear { base: b, end: e, label, values, .. } => {
|
||||
let (b, e) = match v {
|
||||
Some(Value::Points { base, end }) => (*base, *end),
|
||||
_ => (*b, *e),
|
||||
};
|
||||
let choices = if values.is_list() { values.list.iter().map(|x| number(d, x * sx)).collect() } else { Vec::new() };
|
||||
out.push(CustomProperty { name: label.clone(), value: number(d, (e - b).len() * sx), choices, editable: true });
|
||||
}
|
||||
ParameterKind::Point { at, label, .. } => {
|
||||
let at = match v {
|
||||
Some(Value::Point { at }) => *at,
|
||||
_ => *at,
|
||||
};
|
||||
out.push(CustomProperty { name: format!("{label} X"), value: number(d, (at.x - base.x) * sx), choices: Vec::new(), editable: true });
|
||||
out.push(CustomProperty { name: format!("{label} Y"), value: number(d, (at.y - base.y) * sy), choices: Vec::new(), editable: true });
|
||||
}
|
||||
ParameterKind::Visibility { label, states, .. } => {
|
||||
let value = cur.property(p.node, "VisibilityState").unwrap_or_default();
|
||||
out.push(CustomProperty { name: label.clone(), value, choices: states.iter().map(|s| s.name.clone()).collect(), editable: true });
|
||||
}
|
||||
ParameterKind::Lookup { label, .. } => {
|
||||
let (value, choices) = cur.lookup(p.node);
|
||||
let editable = lookup_sets_what_changes(&m, p.node);
|
||||
out.push(CustomProperty { name: label.clone(), value, choices, editable });
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Some((r.definition, out))
|
||||
}
|
||||
|
||||
/// Whether every lookup table that sets lookup parameter `node` sets only what
|
||||
/// [`super::set_property`] can apply with it: visibility states and distances.
|
||||
fn lookup_sets_what_changes(m: &DynamicBlock, node: i32) -> bool {
|
||||
let mut any = false;
|
||||
for a in &m.actions {
|
||||
let ActionKind::Lookup { columns, rows } = &a.kind else { continue };
|
||||
if rows.is_empty() || !columns.iter().any(|c| c.lookup && c.node == node) {
|
||||
continue;
|
||||
}
|
||||
any = true;
|
||||
if !columns
|
||||
.iter()
|
||||
.all(|c| matches!(c.property.to_ascii_lowercase().as_str(), "lookupstring" | "visibilitystate" | "updateddistance" | "distance"))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
any
|
||||
}
|
||||
+111
-1
@@ -103,6 +103,11 @@ pub struct Common {
|
||||
/// 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 {
|
||||
@@ -119,6 +124,7 @@ impl Default for Common {
|
||||
extrusion: Vec3::Z,
|
||||
plot_style: "ByLayer".into(),
|
||||
anno_scales: Vec::new(),
|
||||
xdata: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -343,6 +349,98 @@ pub struct Attrib {
|
||||
/// 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
|
||||
@@ -825,6 +923,10 @@ pub struct TableCell {
|
||||
/// 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")]
|
||||
@@ -1155,7 +1257,10 @@ impl EntityKind {
|
||||
r.dir = m.apply_vec(r.dir.xy()).normalized().to3(0.0);
|
||||
}
|
||||
EntityKind::Text(t) => transform_text(t, m, s, rot, mirror),
|
||||
EntityKind::AttDef(a) => transform_text(&mut a.text, m, s, rot, mirror),
|
||||
EntityKind::AttDef(a) => {
|
||||
transform_text(&mut a.text, m, s, rot, mirror);
|
||||
a.transform_multiline(m);
|
||||
}
|
||||
EntityKind::MText(t) => {
|
||||
t3(&mut t.insert);
|
||||
t.height *= s;
|
||||
@@ -1177,6 +1282,7 @@ impl EntityKind {
|
||||
}
|
||||
for a in &mut i.attribs {
|
||||
transform_text(&mut a.text, m, s, rot, false);
|
||||
a.transform_multiline(m);
|
||||
}
|
||||
i.col_spacing *= s;
|
||||
i.row_spacing *= s;
|
||||
@@ -1206,6 +1312,10 @@ impl EntityKind {
|
||||
if let Some(t) = &mut ml.text {
|
||||
t3(&mut t.insert);
|
||||
t.height *= s;
|
||||
t.width *= s;
|
||||
if let Some(c) = &mut t.columns {
|
||||
c.scale(s);
|
||||
}
|
||||
}
|
||||
if let Some(b) = &mut ml.block {
|
||||
// Transformed exactly like a block reference.
|
||||
|
||||
+39
-22
@@ -88,10 +88,12 @@ pub fn entity_bounds(d: &Drawing, e: &Entity, depth: usize) -> Bounds2 {
|
||||
}
|
||||
|
||||
/// [`entity_bounds`] with the bounds of block contents (in block coordinates) already computed,
|
||||
/// by block name: every further reference to a block reuses them instead of expanding the block
|
||||
/// again, so nested or self-referencing blocks cost one pass over each definition instead of one
|
||||
/// per reference (which grows exponentially with the nesting depth).
|
||||
fn bounds_in(d: &Drawing, e: &Entity, depth: usize, blocks: &mut HashMap<String, Bounds2>) -> Bounds2 {
|
||||
/// by block name and depth: every further reference to a block at that depth reuses them instead of
|
||||
/// expanding the block again, so nested or self-referencing blocks cost at most one pass over each
|
||||
/// definition per depth instead of one per reference (which grows exponentially with the nesting
|
||||
/// depth). The depth is part of the key because [`MAX_BLOCK_DEPTH`] cuts a deep visit short: its
|
||||
/// bounds must not stand in for a shallower reference to the same block (#706).
|
||||
fn bounds_in<'a>(d: &'a Drawing, e: &Entity, depth: usize, blocks: &mut HashMap<(&'a str, usize), Bounds2>) -> Bounds2 {
|
||||
match &e.kind {
|
||||
EntityKind::Table(t) if t.is_broken() => t.bounds(),
|
||||
EntityKind::Text(t) if blank(&t.value) => Bounds2::EMPTY,
|
||||
@@ -106,28 +108,34 @@ fn bounds_in(d: &Drawing, e: &Entity, depth: usize, blocks: &mut HashMap<String,
|
||||
// A reference to a missing block (an unloaded xref, a purged definition) or to an empty
|
||||
// one draws nothing, so it contributes nothing: ZOOM Extents must not fly off to its
|
||||
// insertion point. Attributes below still count.
|
||||
let Some(blk) = d.block(&ins.block) else { return Bounds2::EMPTY };
|
||||
let m = ins.transform(blk.base.xy());
|
||||
let inner = match blocks.get(&blk.name) {
|
||||
Some(b) => *b,
|
||||
None => {
|
||||
let mut inner = Bounds2::EMPTY;
|
||||
for be in blk.entities.iter().filter(|be| in_block_visible(d, be)) {
|
||||
inner = inner.union(&bounds_in(d, be, depth + 1, blocks));
|
||||
let mut b = Bounds2::EMPTY;
|
||||
if let Some(blk) = d.block(&ins.block) {
|
||||
let m = ins.transform(blk.base.xy());
|
||||
let inner = match blocks.get(&(blk.name.as_str(), depth)) {
|
||||
Some(b) => *b,
|
||||
None => {
|
||||
let mut inner = Bounds2::EMPTY;
|
||||
for be in blk.entities.iter().filter(|be| in_block_visible(d, be)) {
|
||||
inner = inner.union(&bounds_in(d, be, depth + 1, blocks));
|
||||
}
|
||||
blocks.insert((blk.name.as_str(), depth), inner);
|
||||
inner
|
||||
}
|
||||
blocks.insert(blk.name.clone(), inner);
|
||||
inner
|
||||
};
|
||||
b = if inner.is_empty() { Bounds2::EMPTY } else { Bounds2::from_points(inner.corners().map(|c| m.apply(c))) };
|
||||
if ins.cols > 1 || ins.rows > 1 {
|
||||
let off =
|
||||
Vec2::new(ins.col_spacing * f64::from(ins.cols.saturating_sub(1)), ins.row_spacing * f64::from(ins.rows.saturating_sub(1)))
|
||||
.rotate(ins.rotation);
|
||||
b = b.union(&Bounds2::from_points(b.corners().map(|c| c + off)));
|
||||
}
|
||||
};
|
||||
let mut b = if inner.is_empty() { Bounds2::EMPTY } else { Bounds2::from_points(inner.corners().map(|c| m.apply(c))) };
|
||||
if ins.cols > 1 || ins.rows > 1 {
|
||||
let off = Vec2::new(ins.col_spacing * f64::from(ins.cols.saturating_sub(1)), ins.row_spacing * f64::from(ins.rows.saturating_sub(1)))
|
||||
.rotate(ins.rotation);
|
||||
b = b.union(&Bounds2::from_points(b.corners().map(|c| c + off)));
|
||||
}
|
||||
for a in &ins.attribs {
|
||||
if !a.invisible {
|
||||
b = b.union(&text_box(&a.text, a.text.value.chars().count()));
|
||||
b = b.union(&match a.multiline_text() {
|
||||
Some(m) => mtext_box(&m),
|
||||
None => text_box(&a.text, a.text.value.chars().count()),
|
||||
});
|
||||
}
|
||||
}
|
||||
b
|
||||
@@ -309,7 +317,16 @@ mod tests {
|
||||
#[test]
|
||||
fn right_justified_attdef_is_left_of_alignment_point() {
|
||||
let text = text(HAlign::Right, VAlign::Baseline);
|
||||
let a = crate::Attrib { tag: "HELLO".into(), text, invisible: false, constant: false, prompt: String::new(), props: Default::default() };
|
||||
let a = crate::Attrib {
|
||||
tag: "HELLO".into(),
|
||||
text,
|
||||
invisible: false,
|
||||
constant: false,
|
||||
prompt: String::new(),
|
||||
props: Default::default(),
|
||||
multiline: None,
|
||||
modes: Default::default(),
|
||||
};
|
||||
let b = bounds(EntityKind::AttDef(a));
|
||||
assert!(b.min.x <= -4.4 && b.max.x < 1.0);
|
||||
}
|
||||
|
||||
@@ -34,7 +34,8 @@ pub struct ForeignExtra {
|
||||
pub xdict: Option<Handle>,
|
||||
/// Objects of [`Foreign::objects`] that react to it (persistent reactors).
|
||||
pub reactors: Vec<Handle>,
|
||||
/// Other applications' extended data: groups from `1001` on.
|
||||
/// Other applications' extended data of a table record or block: groups from `1001` on. An
|
||||
/// entity's is part of the entity ([`crate::Common::xdata`]), so it follows the entity's copies.
|
||||
pub xdata: Vec<RawTag>,
|
||||
/// The colour book colour (group 430) and the true colour (group 420) it was read with: it is
|
||||
/// written back while the colour is unchanged.
|
||||
@@ -65,6 +66,11 @@ 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 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`
|
||||
/// names its entity with a 320 handle (see [`Foreign::data_store_entities`]).
|
||||
pub data_store: Vec<RawTag>,
|
||||
}
|
||||
|
||||
impl Foreign {
|
||||
@@ -75,5 +81,11 @@ impl Foreign {
|
||||
&& self.records.is_empty()
|
||||
&& self.classes.is_empty()
|
||||
&& self.targets.is_empty()
|
||||
&& self.data_store.is_empty()
|
||||
}
|
||||
|
||||
/// The entities the data store has records for (their 320 handles).
|
||||
pub fn data_store_entities(&self) -> std::collections::BTreeSet<Handle> {
|
||||
self.data_store.iter().filter(|t| t.code == 320).filter_map(|t| Handle::parse_hex(t.value.trim())).collect()
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ mod header;
|
||||
pub mod library;
|
||||
mod plotstyle;
|
||||
mod plotter;
|
||||
mod refedit;
|
||||
mod store;
|
||||
mod tables;
|
||||
|
||||
@@ -30,10 +31,12 @@ pub use gdt::*;
|
||||
pub use header::{HVal, Header};
|
||||
pub use plotstyle::*;
|
||||
pub use plotter::*;
|
||||
pub use refedit::{RefEdit, WorkItem};
|
||||
pub use store::EntityStore;
|
||||
pub use tables::*;
|
||||
|
||||
/// File data CADCraft doesn't model, kept for saving (#528).
|
||||
pub mod dynblock;
|
||||
pub mod foreign;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
@@ -59,6 +62,26 @@ pub enum Space {
|
||||
Paper(String),
|
||||
}
|
||||
|
||||
impl Space {
|
||||
/// Whether this is a layout's paper space.
|
||||
pub fn is_paper(&self) -> bool {
|
||||
matches!(self, Space::Paper(_))
|
||||
}
|
||||
|
||||
/// The layout whose paper space this is; `None` for model space.
|
||||
pub fn layout(&self) -> Option<&str> {
|
||||
match self {
|
||||
Space::Paper(n) => Some(n),
|
||||
Space::Model => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The tab name (CTAB): `Model`, or the layout's name.
|
||||
pub fn tab_name(&self) -> &str {
|
||||
self.layout().unwrap_or("Model")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Drawing {
|
||||
pub header: Header,
|
||||
@@ -95,6 +118,15 @@ pub struct Drawing {
|
||||
/// Objects hidden for this session by ISOLATEOBJECTS / HIDEOBJECTS. Never written to a file:
|
||||
/// a drawing opens with everything shown again.
|
||||
pub hidden_objects: BTreeSet<Handle>,
|
||||
/// What reading the file left out (a DWG's object records the reader couldn't decode), told
|
||||
/// to the user when the drawing opens. Never written to a file.
|
||||
pub read_notes: Vec<String>,
|
||||
/// The in-place block reference edit in progress (REFEDIT). Never written to a file; files are
|
||||
/// written from [`Drawing::committed`].
|
||||
pub refedit: Option<RefEdit>,
|
||||
/// Records of the file this drawing was read from that the reader could not place (a damaged
|
||||
/// file), counted by the section they came from. Never written to a file.
|
||||
pub skipped_records: BTreeMap<String, usize>,
|
||||
}
|
||||
|
||||
impl Default for Drawing {
|
||||
@@ -148,6 +180,9 @@ impl Drawing {
|
||||
scales: imperial_scales(),
|
||||
handseed: 0x100,
|
||||
hidden_objects: BTreeSet::new(),
|
||||
read_notes: Vec::new(),
|
||||
refedit: None,
|
||||
skipped_records: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
//! In-place editing of a block reference (REFEDIT / REFSET / REFCLOSE): the state kept with the
|
||||
//! drawing while it lasts.
|
||||
//!
|
||||
//! The reference being edited is hidden and the block's objects stand in the drawing as ordinary
|
||||
//! objects (the *working set*, transformed to where the reference shows them), the rest of the
|
||||
//! block as faded copies (*ghosts*) that cannot be edited. REFCLOSE writes the working set back
|
||||
//! into the block definition. The state lives in the [`Drawing`] itself, so undo and redo move
|
||||
//! the working copies and the state together and can never leave one without the other.
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use cadcraft_geom::Mat3;
|
||||
|
||||
use crate::{Drawing, Entity, Handle, Space};
|
||||
|
||||
/// One object of the working set.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct WorkItem {
|
||||
/// The object in the drawing (a copy of the block object, or a drawing object added to the set).
|
||||
pub copy: Handle,
|
||||
/// The block object it was copied from; `None` for an object added from the drawing.
|
||||
pub source: Option<Handle>,
|
||||
}
|
||||
|
||||
/// An active in-place edit of a block reference. Never written to a file.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct RefEdit {
|
||||
/// The block being edited.
|
||||
pub block: String,
|
||||
/// The reference that was picked (hidden while editing).
|
||||
pub insert: Handle,
|
||||
/// Whether the reference was visible before it was hidden.
|
||||
pub insert_was_visible: bool,
|
||||
/// The space the reference is in.
|
||||
pub space: Space,
|
||||
/// Block coordinates to drawing coordinates, through the picked reference.
|
||||
pub to_world: Mat3,
|
||||
/// The working set.
|
||||
pub items: Vec<WorkItem>,
|
||||
/// The rest of the block as faded copies: (copy, block object).
|
||||
pub ghosts: Vec<(Handle, Handle)>,
|
||||
/// Block objects taken out of the working set with their edits (block coordinates, the block
|
||||
/// object's handle).
|
||||
pub released: Vec<Entity>,
|
||||
}
|
||||
|
||||
impl RefEdit {
|
||||
/// Whether `h` is a working set object.
|
||||
pub fn is_working(&self, h: Handle) -> bool {
|
||||
self.items.iter().any(|i| i.copy == h)
|
||||
}
|
||||
/// Whether `h` is something the edit manages (working set, ghost, the hidden reference).
|
||||
pub fn owns(&self, h: Handle) -> bool {
|
||||
h == self.insert || self.is_working(h) || self.ghosts.iter().any(|(g, _)| *g == h)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drawing {
|
||||
/// The drawing as REFCLOSE Discard would leave it: the working copies and ghosts gone, the
|
||||
/// reference shown, the block untouched. Borrowed when no edit is active. Files are written
|
||||
/// from this, so a saved or autosaved drawing never holds half an edit.
|
||||
pub fn committed(&self) -> Cow<'_, Drawing> {
|
||||
let Some(r) = &self.refedit else { return Cow::Borrowed(self) };
|
||||
let mut d = self.clone();
|
||||
d.end_refedit_discard(r);
|
||||
Cow::Owned(d)
|
||||
}
|
||||
|
||||
/// Remove the edit's working copies and ghosts, show the reference again and clear the state.
|
||||
pub fn end_refedit_discard(&mut self, r: &RefEdit) {
|
||||
for i in &r.items {
|
||||
if i.source.is_some() {
|
||||
self.remove_entity(i.copy);
|
||||
}
|
||||
}
|
||||
for (g, _) in &r.ghosts {
|
||||
self.remove_entity(*g);
|
||||
}
|
||||
let vis = r.insert_was_visible;
|
||||
let _ = self.modify_entity(r.insert, |e| e.common.visible = vis);
|
||||
self.refedit = None;
|
||||
}
|
||||
}
|
||||
@@ -747,6 +747,9 @@ pub struct PageSetup {
|
||||
/// Draw the layout on screen (and in its PNG/SVG exports) with its plot style table
|
||||
/// applied ("Display plot styles"; DXF plot layout flag 2).
|
||||
pub show_plot_styles: bool,
|
||||
/// Plot objects with their transparency ("Plot transparency"; PLOTTRANSPARENCYOVERRIDE can
|
||||
/// override it). The DXF plot layout flags have no bit for it, so it is not saved in DXF.
|
||||
pub plot_transparency: bool,
|
||||
}
|
||||
|
||||
impl Default for PageSetup {
|
||||
@@ -765,6 +768,7 @@ impl Default for PageSetup {
|
||||
center: false,
|
||||
lineweights: true,
|
||||
show_plot_styles: false,
|
||||
plot_transparency: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,6 +79,59 @@ fn inserts_of_missing_or_empty_blocks_do_not_stretch_extents() {
|
||||
assert_eq!(d.extents(&Space::Model).max, Vec2::new(12.0, 12.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visible_attributes_of_a_missing_block_still_count() {
|
||||
// The renderer draws the attributes of a reference whose definition is missing, so they are
|
||||
// extents and selection targets, as with an empty definition. The insertion point still isn't.
|
||||
let attrib = |invisible: bool| Attrib {
|
||||
tag: "TAG".into(),
|
||||
text: Text {
|
||||
insert: Vec3::new(1000.0, 1000.0, 0.0),
|
||||
align_pt: None,
|
||||
height: 10.0,
|
||||
value: "hello".into(),
|
||||
rotation: 0.0,
|
||||
width_factor: 1.0,
|
||||
oblique: 0.0,
|
||||
style: "Standard".into(),
|
||||
halign: HAlign::Left,
|
||||
valign: VAlign::Baseline,
|
||||
},
|
||||
invisible,
|
||||
constant: false,
|
||||
prompt: String::new(),
|
||||
props: Default::default(),
|
||||
multiline: None,
|
||||
modes: Default::default(),
|
||||
};
|
||||
let ins = |block: &str, attribs: Vec<Attrib>| {
|
||||
Entity::new(
|
||||
Handle(1),
|
||||
EntityKind::Insert(Insert {
|
||||
block: block.into(),
|
||||
insert: Vec3::ZERO,
|
||||
scale: Vec3::new(1.0, 1.0, 1.0),
|
||||
rotation: 0.0,
|
||||
attribs,
|
||||
cols: 1,
|
||||
rows: 1,
|
||||
col_spacing: 0.0,
|
||||
row_spacing: 0.0,
|
||||
}),
|
||||
)
|
||||
};
|
||||
let mut d = Drawing::new_imperial();
|
||||
d.blocks.insert("Empty".into(), std::sync::Arc::new(Block::new("Empty")));
|
||||
let missing = entity_bounds(&d, &ins("Missing", vec![attrib(false)]), 0);
|
||||
let empty = entity_bounds(&d, &ins("Empty", vec![attrib(false)]), 0);
|
||||
assert!(!missing.is_empty());
|
||||
assert_eq!((missing.min, missing.max), (empty.min, empty.max));
|
||||
assert_eq!(missing.min, Vec2::new(1000.0, 1000.0));
|
||||
// Without a visible attribute there is nothing to measure.
|
||||
assert!(entity_bounds(&d, &ins("Missing", vec![]), 0).is_empty());
|
||||
assert!(entity_bounds(&d, &ins("Missing", vec![attrib(true)]), 0).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transform_arc_mirror_keeps_shape() {
|
||||
let mut k = EntityKind::Arc(Arc { center: Vec3::ZERO, radius: 1.0, start: 0.0, end: std::f64::consts::FRAC_PI_2 });
|
||||
@@ -269,6 +322,38 @@ fn extents_pair(d: &Drawing) -> (Vec2, Vec2) {
|
||||
(b.min, b.max)
|
||||
}
|
||||
|
||||
/// #706: block A reaches B once through a chain of 14 blocks (B at depth 15, its line's block E at
|
||||
/// 16, past the limit) and once directly (B at depth 1). `children` picks which of the two A holds.
|
||||
fn deep_and_shallow(children: &[&str]) -> Drawing {
|
||||
let mut d = Drawing::new_imperial();
|
||||
let mut add = |name: &str, kids: Vec<EntityKind>| {
|
||||
let mut blk = Block::new(name);
|
||||
for (i, k) in kids.into_iter().enumerate() {
|
||||
blk.entities.push(Entity::new(Handle(i as u64 + 1), k));
|
||||
}
|
||||
d.blocks.insert(name.into(), std::sync::Arc::new(blk));
|
||||
};
|
||||
add("E", vec![line((1000.0, 1000.0), (2000.0, 1000.0))]);
|
||||
add("B", vec![plain_insert("E")]);
|
||||
for i in 1..=14 {
|
||||
let next = if i == 14 { "B".to_string() } else { format!("C{}", i + 1) };
|
||||
add(&format!("C{i}"), vec![plain_insert(&next)]);
|
||||
}
|
||||
add("A", children.iter().map(|c| plain_insert(c)).collect());
|
||||
d.add(&Space::Model, Common::default(), plain_insert("A")).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deep_block_visit_does_not_hide_a_shallow_reference() {
|
||||
let line = (Vec2::new(1000.0, 1000.0), Vec2::new(2000.0, 1000.0));
|
||||
// Control: past the limit, the deep path alone contributes nothing.
|
||||
assert!(deep_and_shallow(&["C1"]).extents(&Space::Model).is_empty());
|
||||
assert_eq!(extents_pair(&deep_and_shallow(&["B"])), line);
|
||||
assert_eq!(extents_pair(&deep_and_shallow(&["B", "C1"])), line);
|
||||
assert_eq!(extents_pair(&deep_and_shallow(&["C1", "B"])), line);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_entity_with_invisible_flag_is_ignored_in_insert_extents() {
|
||||
let mut far = far_line("0");
|
||||
@@ -354,6 +439,8 @@ fn attdef_in_block_is_ignored_in_insert_extents() {
|
||||
constant: false,
|
||||
prompt: String::new(),
|
||||
props: Default::default(),
|
||||
multiline: None,
|
||||
modes: Default::default(),
|
||||
}),
|
||||
);
|
||||
let d = drawing_with_block(attdef);
|
||||
@@ -466,3 +553,14 @@ fn invisible_dimblock_entity_is_ignored_in_dimension_extents() {
|
||||
dim(&mut d, far_line("0"));
|
||||
assert_eq!(d.extents(&Space::Model).max, Vec2::new(6000.0, 6000.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn space_names_its_tab() {
|
||||
assert!(!Space::Model.is_paper());
|
||||
assert_eq!(Space::Model.layout(), None);
|
||||
assert_eq!(Space::Model.tab_name(), "Model");
|
||||
let p = Space::Paper("Sheet A".into());
|
||||
assert!(p.is_paper());
|
||||
assert_eq!(p.layout(), Some("Sheet A"));
|
||||
assert_eq!(p.tab_name(), "Sheet A");
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
//! `Sub::parse` accepts every spelling its documentation lists (#710).
|
||||
|
||||
use cadcraft_doc::Sub;
|
||||
|
||||
#[test]
|
||||
fn short_segment_name_matches_the_documented_forms() {
|
||||
for spelling in ["seg2", "segment2", "s2"] {
|
||||
assert_eq!(Sub::parse(spelling), Some(Sub::Segment(2)), "spelling: {spelling}");
|
||||
}
|
||||
for spelling in ["s", "start"] {
|
||||
assert_eq!(Sub::parse(spelling), Some(Sub::Start), "spelling: {spelling}");
|
||||
}
|
||||
assert_eq!(Sub::parse("unknown"), None);
|
||||
}
|
||||
@@ -10,4 +10,4 @@ workspace = true
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
# MPL-2.0: used as an unmodified dependency only (see plan/adr/0001 and NOTICE).
|
||||
acadrust = "0.6.3"
|
||||
acadrust = "0.6.4"
|
||||
+170
-5
@@ -61,12 +61,30 @@ mod native {
|
||||
/// guard: the input size before the DWG reader runs, the DXF size while it is written, so a
|
||||
/// runaway conversion stops at the limit instead of filling memory first.
|
||||
pub fn dwg_to_dxf_with(bytes: &[u8], limits: Limits) -> Result<Vec<u8>, String> {
|
||||
dwg_to_dxf_noted(bytes, limits).map(|(dxf, _)| dxf)
|
||||
}
|
||||
|
||||
/// [`dwg_to_dxf_with`], also returning notes on what the read left out. The reader runs in
|
||||
/// failsafe mode: an object record it can't decode is skipped instead of failing the whole
|
||||
/// file, and the notes say how many were skipped and of which types, so a partial read never
|
||||
/// passes for a complete one.
|
||||
pub fn dwg_to_dxf_noted(bytes: &[u8], limits: Limits) -> Result<(Vec<u8>, Vec<String>), String> {
|
||||
if bytes.len() > limits.max_dwg_bytes {
|
||||
return Err(format!("DWG: the file is {}, larger than the {} CADCraft opens", mb(bytes.len() as u64), mb(limits.max_dwg_bytes as u64)));
|
||||
}
|
||||
let mut doc = std::panic::catch_unwind(|| acadrust::DwgReader::from_stream(Cursor::new(bytes)).read())
|
||||
.map_err(|_| "the DWG reader failed on this file".to_string())?
|
||||
.map_err(|e| format!("DWG: {e}"))?;
|
||||
let outcome = std::panic::catch_unwind(|| {
|
||||
acadrust::DwgReader::from_stream_with_options(Cursor::new(bytes), acadrust::DwgReadOptions::failsafe()).read_with_stats()
|
||||
})
|
||||
.map_err(|_| "the DWG reader failed on this file".to_string())?
|
||||
.map_err(|e| format!("DWG: {e}"))?;
|
||||
// Failsafe skips records, not whole files: when the objects stream itself couldn't be
|
||||
// read (a damaged header or section map, or not a DWG at all), nothing was read.
|
||||
if !outcome.stats.record_stream_read {
|
||||
let why = outcome.stats.diagnostics.first().map(|d| d.message.trim_start_matches("Failsafe: ").to_string());
|
||||
return Err(format!("DWG: {}", why.unwrap_or_else(|| "the file's objects couldn't be read".to_string())));
|
||||
}
|
||||
let notes = read_notes(&outcome.stats);
|
||||
let mut doc = outcome.document;
|
||||
table_merges(&mut doc);
|
||||
table_style_rows(&mut doc, false);
|
||||
let mut out = CappedWriter { buf: Vec::new(), limit: limits.max_dxf_bytes, exceeded: false };
|
||||
@@ -81,7 +99,111 @@ mod native {
|
||||
));
|
||||
}
|
||||
written.map_err(|e| format!("DWG→DXF: {e}"))?;
|
||||
Ok(add_header_vars(out.buf, &doc.header))
|
||||
Ok((add_header_vars(out.buf, &doc.header), notes))
|
||||
}
|
||||
|
||||
/// Most record types named in the skipped-records note.
|
||||
const NOTE_TYPES: usize = 6;
|
||||
|
||||
/// The note on object records a failsafe read skipped: how many, and how many of each type
|
||||
/// (most first), or nothing when the read was complete.
|
||||
pub(crate) fn read_notes(stats: &acadrust::ReadStats) -> Vec<String> {
|
||||
let skipped = stats.skipped_source_records;
|
||||
if skipped == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let label = |t: Option<&str>| match t.map(str::parse::<i16>) {
|
||||
Some(Ok(code)) => type_name(code).map_or_else(|| format!("type {code}"), str::to_string),
|
||||
Some(Err(_)) => t.unwrap_or_default().to_string(),
|
||||
None => "unknown".to_string(),
|
||||
};
|
||||
let mut by_type: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
|
||||
for d in stats.diagnostics.iter().filter(|d| d.record_handle.is_some()) {
|
||||
*by_type.entry(label(d.record_type.as_deref())).or_default() += 1;
|
||||
}
|
||||
let mut types: Vec<(String, usize)> = by_type.into_iter().collect();
|
||||
types.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
|
||||
let mut list: Vec<String> = types.iter().take(NOTE_TYPES).map(|(t, n)| format!("{t} × {n}")).collect();
|
||||
if types.len() > NOTE_TYPES {
|
||||
list.push(format!("{} more types", types.len() - NOTE_TYPES));
|
||||
}
|
||||
let of = if stats.source_records > 0 { format!(" of {}", stats.source_records) } else { String::new() };
|
||||
let records = if skipped == 1 && of.is_empty() { "object record" } else { "object records" };
|
||||
let (were, them) = if skipped == 1 { ("was", "it") } else { ("were", "them") };
|
||||
let which = if list.is_empty() { String::new() } else { format!(" ({})", list.join(", ")) };
|
||||
vec![format!(
|
||||
"DWG: {skipped}{of} {records} couldn't be read and {were} left out{which}; \
|
||||
saving over the original file would lose {them}"
|
||||
)]
|
||||
}
|
||||
|
||||
/// The DXF name of a DWG object type code with a fixed number (classes, 500 and up, are
|
||||
/// numbered per file).
|
||||
fn type_name(code: i16) -> Option<&'static str> {
|
||||
Some(match code {
|
||||
1 => "TEXT",
|
||||
2 => "ATTRIB",
|
||||
3 => "ATTDEF",
|
||||
4 => "BLOCK",
|
||||
5 => "ENDBLK",
|
||||
6 => "SEQEND",
|
||||
7 => "INSERT",
|
||||
8 => "MINSERT",
|
||||
10..=14 => "VERTEX",
|
||||
15 | 16 | 29 | 30 => "POLYLINE",
|
||||
17 => "ARC",
|
||||
18 => "CIRCLE",
|
||||
19 => "LINE",
|
||||
20..=26 => "DIMENSION",
|
||||
27 => "POINT",
|
||||
28 => "3DFACE",
|
||||
31 => "SOLID",
|
||||
32 => "TRACE",
|
||||
33 => "SHAPE",
|
||||
34 => "VIEWPORT",
|
||||
35 => "ELLIPSE",
|
||||
36 => "SPLINE",
|
||||
37 => "REGION",
|
||||
38 => "3DSOLID",
|
||||
39 => "BODY",
|
||||
40 => "RAY",
|
||||
41 => "XLINE",
|
||||
42 => "DICTIONARY",
|
||||
43 => "OLEFRAME",
|
||||
44 => "MTEXT",
|
||||
45 => "LEADER",
|
||||
46 => "TOLERANCE",
|
||||
47 => "MLINE",
|
||||
48 => "BLOCK_CONTROL",
|
||||
49 => "BLOCK_RECORD",
|
||||
50 => "LAYER_CONTROL",
|
||||
51 => "LAYER",
|
||||
52 => "STYLE_CONTROL",
|
||||
53 => "STYLE",
|
||||
56 => "LTYPE_CONTROL",
|
||||
57 => "LTYPE",
|
||||
60 => "VIEW_CONTROL",
|
||||
61 => "VIEW",
|
||||
62 => "UCS_CONTROL",
|
||||
63 => "UCS",
|
||||
64 => "VPORT_CONTROL",
|
||||
65 => "VPORT",
|
||||
66 => "APPID_CONTROL",
|
||||
67 => "APPID",
|
||||
68 => "DIMSTYLE_CONTROL",
|
||||
69 => "DIMSTYLE",
|
||||
70 => "VX_CONTROL",
|
||||
71 => "VX_TABLE_RECORD",
|
||||
72 => "GROUP",
|
||||
73 => "MLINESTYLE",
|
||||
74 => "OLE2FRAME",
|
||||
77 => "LWPOLYLINE",
|
||||
78 => "HATCH",
|
||||
79 => "XRECORD",
|
||||
80 => "ACDBPLACEHOLDER",
|
||||
82 => "LAYOUT",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Merged table cells onto the cells. acadrust 0.6.3 reads a 2010+ (AC1024+) DWG table's
|
||||
@@ -143,6 +265,44 @@ mod native {
|
||||
}
|
||||
}
|
||||
|
||||
/// Leave out what acadrust can't write as this DWG version: other programs' custom entities
|
||||
/// and objects without raw DWG data of that version. acadrust 0.6.4 refuses the whole file
|
||||
/// for one of them (0.6.3 left them out); CADCraft already tells the user which entity types
|
||||
/// a DWG doesn't keep (`not_saved`), so the rest of the drawing is written. Returns how many.
|
||||
fn drop_unwritable(doc: &mut acadrust::CadDocument) -> usize {
|
||||
let version = doc.version;
|
||||
let unwritable: Vec<acadrust::Handle> = doc
|
||||
.entities()
|
||||
.filter_map(|e| match e {
|
||||
acadrust::entities::EntityType::Unknown(u)
|
||||
if u.raw_dwg_data.is_none() || u.dwg_source_version.is_some_and(|v| v != version) || version == acadrust::DxfVersion::AC1009 =>
|
||||
{
|
||||
Some(u.common.handle)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
for h in &unwritable {
|
||||
doc.remove_entity(*h);
|
||||
}
|
||||
let objects: Vec<acadrust::Handle> = doc
|
||||
.objects
|
||||
.iter()
|
||||
.filter_map(|(h, o)| match o {
|
||||
acadrust::objects::ObjectType::Unknown { raw_dwg_data, raw_dwg_version, .. }
|
||||
if raw_dwg_data.is_none() || raw_dwg_version.is_some_and(|v| v != version) || version == acadrust::DxfVersion::AC1009 =>
|
||||
{
|
||||
Some(*h)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
for h in &objects {
|
||||
doc.objects.remove(h);
|
||||
}
|
||||
unwritable.len() + objects.len()
|
||||
}
|
||||
|
||||
/// Header variables the DWG reader fills in but acadrust's DXF writer (0.6.3) leaves out of
|
||||
/// the HEADER section: (name, group code, value).
|
||||
fn unwritten_header_vars(h: &acadrust::document::HeaderVariables) -> [(&'static str, i32, String); 7] {
|
||||
@@ -239,6 +399,7 @@ mod native {
|
||||
None => {}
|
||||
}
|
||||
table_style_rows(&mut doc, true);
|
||||
drop_unwritable(&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}"))
|
||||
@@ -246,7 +407,7 @@ mod native {
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use native::{dwg_to_dxf, dwg_to_dxf_with, dxf_to_dwg, dxf_to_dwg_as};
|
||||
pub use native::{dwg_to_dxf, dwg_to_dxf_noted, dwg_to_dxf_with, dxf_to_dwg, dxf_to_dwg_as};
|
||||
|
||||
/// The error for a DWG version CADCraft can't write.
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
@@ -260,6 +421,10 @@ pub fn dwg_to_dxf(_bytes: &[u8]) -> Result<Vec<u8>, String> {
|
||||
Err("DWG files can't be opened in the web build yet; save as DXF".into())
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn dwg_to_dxf_noted(_bytes: &[u8], _limits: Limits) -> Result<(Vec<u8>, Vec<String>), String> {
|
||||
Err("DWG files can't be opened in the web build yet; save as DXF".into())
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn dwg_to_dxf_with(_bytes: &[u8], _limits: Limits) -> Result<Vec<u8>, String> {
|
||||
Err("DWG files can't be opened in the web build yet; save as DXF".into())
|
||||
}
|
||||
|
||||
@@ -109,6 +109,9 @@ pub enum Job {
|
||||
struct Tracked {
|
||||
/// The drawing as last autosaved (a weak pointer: compared, never kept alive).
|
||||
written: Option<Weak<Drawing>>,
|
||||
/// The drawing outside the Block Editor as last autosaved (the same as `written` when the
|
||||
/// editor is closed): saving the editor's changes into the block changes only this.
|
||||
host: Option<Weak<Drawing>>,
|
||||
/// Its autosave file, once written (or adopted).
|
||||
file: Option<String>,
|
||||
/// When a change not yet autosaved was first seen (seconds).
|
||||
@@ -122,8 +125,9 @@ pub struct Autosaver {
|
||||
docs: HashMap<u64, Tracked>,
|
||||
/// Drawings whose autosave is being written right now.
|
||||
busy: HashSet<u64>,
|
||||
/// Writes in progress; each tells whether its file was written.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
running: Vec<(u64, std::thread::JoinHandle<()>)>,
|
||||
running: Vec<(u64, std::thread::JoinHandle<bool>)>,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
start: std::time::Instant,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -149,8 +153,9 @@ impl Autosaver {
|
||||
/// Recovery, whose current state it holds): later autosaves overwrite it, and it is deleted
|
||||
/// once the drawing is saved or closed.
|
||||
pub fn adopt(&mut self, s: &Session, uid: u64, path: &str) {
|
||||
let written = s.docs.iter().find(|d| d.uid == uid).map(|d| Arc::downgrade(&d.doc));
|
||||
self.docs.insert(uid, Tracked { written, file: Some(path.to_string()), since: None });
|
||||
let st = s.docs.iter().find(|d| d.uid == uid);
|
||||
let (written, host) = (st.map(|d| Arc::downgrade(&d.doc)), st.map(|d| Arc::downgrade(d.drawing())));
|
||||
self.docs.insert(uid, Tracked { written, host, file: Some(path.to_string()), since: None });
|
||||
}
|
||||
|
||||
/// The autosave files this app has written or adopted.
|
||||
@@ -193,7 +198,8 @@ impl Autosaver {
|
||||
*t = Tracked::default();
|
||||
continue;
|
||||
}
|
||||
let changed = t.written.as_ref().is_none_or(|w| !std::ptr::eq(w.as_ptr(), Arc::as_ptr(&st.doc)));
|
||||
let same = |w: &Option<Weak<Drawing>>, d: &Arc<Drawing>| w.as_ref().is_some_and(|w| std::ptr::eq(w.as_ptr(), Arc::as_ptr(d)));
|
||||
let changed = !same(&t.written, &st.doc) || !same(&t.host, st.drawing());
|
||||
if !changed {
|
||||
t.since = None;
|
||||
continue;
|
||||
@@ -208,8 +214,10 @@ impl Autosaver {
|
||||
.unwrap_or_else(|| std::path::Path::new(dir).join(file_name(&st.title, &self.instance, st.uid)).to_string_lossy().to_string());
|
||||
t.file = Some(path.clone());
|
||||
t.written = Some(Arc::downgrade(&st.doc));
|
||||
t.host = Some(Arc::downgrade(st.drawing()));
|
||||
t.since = None;
|
||||
jobs.push(Job::Write { uid: st.uid, path, doc: st.doc.clone() });
|
||||
// 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
|
||||
}
|
||||
@@ -341,14 +349,7 @@ mod native {
|
||||
/// Returns how long until the next autosave is due, so the host can wake up for it.
|
||||
pub fn tick(&mut self, s: &Session) -> Option<Duration> {
|
||||
let now = self.start.elapsed().as_secs_f64();
|
||||
let busy = &mut self.busy;
|
||||
self.running.retain(|(uid, h)| {
|
||||
let running = !h.is_finished();
|
||||
if !running {
|
||||
busy.remove(uid);
|
||||
}
|
||||
running
|
||||
});
|
||||
self.reap();
|
||||
let dir = savefilepath();
|
||||
let minutes = savetime();
|
||||
let jobs = self.plan(s, now, minutes, &dir);
|
||||
@@ -371,6 +372,26 @@ mod native {
|
||||
writes
|
||||
}
|
||||
|
||||
/// Take in the writes that have finished ([`Autosaver::tick`] does this first). A drawing
|
||||
/// whose write failed is no longer counted as autosaved: the next plan restarts its clock,
|
||||
/// so it is tried again SAVETIME later even if it doesn't change.
|
||||
pub fn reap(&mut self) {
|
||||
let (done, running) = std::mem::take(&mut self.running).into_iter().partition::<Vec<_>, _>(|(_, h)| h.is_finished());
|
||||
self.running = running;
|
||||
for (uid, h) in done {
|
||||
self.busy.remove(&uid);
|
||||
if !h.join().unwrap_or(false) {
|
||||
self.unwritten(uid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn unwritten(&mut self, uid: u64) {
|
||||
if let Some(t) = self.docs.get_mut(&uid) {
|
||||
t.written = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Carry out jobs from [`Autosaver::plan`]: writes on a background thread, deletes now.
|
||||
pub fn perform(&mut self, jobs: Vec<Job>) {
|
||||
for job in jobs {
|
||||
@@ -382,15 +403,24 @@ mod native {
|
||||
continue;
|
||||
};
|
||||
let spawned = std::thread::Builder::new().name("autosave".into()).spawn(move || match write_file(write, &doc, &path) {
|
||||
Ok(()) => log::info!("autosaved {path}"),
|
||||
Err(e) => log::warn!("autosave {path}: {e}"),
|
||||
Ok(()) => {
|
||||
log::info!("autosaved {path}");
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("autosave {path}: {e}");
|
||||
false
|
||||
}
|
||||
});
|
||||
match spawned {
|
||||
Ok(h) => {
|
||||
self.busy.insert(uid);
|
||||
self.running.push((uid, h));
|
||||
}
|
||||
Err(e) => log::warn!("autosave: {e}"),
|
||||
Err(e) => {
|
||||
log::warn!("autosave: {e}");
|
||||
self.unwritten(uid);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
//! The Block Editor's state (BEDIT). While it is open, the drawing's tab shows the block's objects
|
||||
//! as its model space, moved so that the block's base point is at the origin, and every command
|
||||
//! edits them as it would any drawing. The drawing itself waits, untouched, with its undo history,
|
||||
//! selection and views: BSAVE writes the objects back into the block definition (so every
|
||||
//! reference shows them), BCLOSE puts the drawing back. The commands are in
|
||||
//! `cmd::blocks::bedit`.
|
||||
//!
|
||||
//! Layer, style and block table changes made in the editor belong to the drawing too, as in
|
||||
//! AutoCAD: saving keeps the editor's tables and only puts back what is the drawing's own (its
|
||||
//! model and paper space objects, groups, constraints, tiled viewports and hidden objects).
|
||||
//!
|
||||
//! The editor can also open on the drawing itself ([`CURRENT_DRAWING`], AutoCAD's
|
||||
//! `<Current Drawing>`): a dynamic block's own drawing, whose model space holds the block's
|
||||
//! objects and authoring elements. Its objects are edited where they are; BSAVE keeps the
|
||||
//! changes as the drawing's, BCLOSE without saving puts the drawing back as it was.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_doc::{Drawing, EntityKind, EntityStore};
|
||||
use cadcraft_geom::{Mat3, Vec2};
|
||||
|
||||
use crate::{DocState, EngineError, Result, Snapshot};
|
||||
|
||||
/// The undo step a save into the block records in the drawing.
|
||||
const UNDO_LABEL: &str = "Block Editor";
|
||||
|
||||
/// The name the Block Editor gives the drawing itself (BEDIT `<Current Drawing>`).
|
||||
pub const CURRENT_DRAWING: &str = "<Current Drawing>";
|
||||
|
||||
/// Whether block `name` (or `*Model_Space`, a drawing's own) carries dynamic block data from
|
||||
/// another program: parameters and actions in the `ACAD_ENHANCEDBLOCK` dictionary of its
|
||||
/// BLOCK_RECORD table entry.
|
||||
pub fn has_dynamic_data(d: &Drawing, name: &str) -> bool {
|
||||
let key = ("BLOCK_RECORD".to_string(), name.to_ascii_uppercase());
|
||||
let Some(xdict) = d.foreign.records.get(&key).and_then(|x| x.xdict) else { return false };
|
||||
d.foreign.objects.iter().any(|o| o.handle == xdict && o.tags.iter().any(|t| t.code == 3 && t.value.eq_ignore_ascii_case("ACAD_ENHANCEDBLOCK")))
|
||||
}
|
||||
|
||||
/// Whether drawing `d` has authoring elements: it is a dynamic block's own drawing, its model
|
||||
/// space carrying the block's parameters and actions (AutoCAD offers to open it in the Block
|
||||
/// Editor).
|
||||
pub fn has_authoring_elements(d: &Drawing) -> bool {
|
||||
has_dynamic_data(d, "*Model_Space")
|
||||
}
|
||||
|
||||
/// The Block Editor open in a drawing.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct BlockEdit {
|
||||
/// The block being edited, as the drawing names it.
|
||||
pub name: String,
|
||||
/// The drawing outside the editor: its drawing, undo history, selection and views, put back
|
||||
/// by BCLOSE. Its own `block_edit` is always `None`.
|
||||
pub host: Box<DocState>,
|
||||
/// The editor's drawing as last saved into the block; later changes are unsaved.
|
||||
pub saved: Arc<Drawing>,
|
||||
/// The block's objects as the drawing has them (block coordinates): objects left unchanged in
|
||||
/// the editor are saved back exactly as they were.
|
||||
original: EntityStore,
|
||||
/// The block's base point, at the origin in the editor.
|
||||
base: Vec2,
|
||||
/// The editor is open on the drawing itself ([`CURRENT_DRAWING`]).
|
||||
pub current: bool,
|
||||
}
|
||||
|
||||
/// Why a block can't be opened in the Block Editor, if it can't.
|
||||
pub fn refusal(d: &Drawing, name: &str) -> Option<String> {
|
||||
let Some(b) = d.block(name) else { return Some(format!("Block \"{name}\" not found.")) };
|
||||
if b.xref_path.is_some() {
|
||||
return Some(format!("\"{}\" is an external reference; open its drawing (XOPEN) to edit it.", b.name));
|
||||
}
|
||||
if b.anonymous || b.name.starts_with('*') {
|
||||
return Some(format!("\"{}\" is an anonymous block made by another object; it can't be edited in the Block Editor.", b.name));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// `store` moved by `m`, objects in the same order with the same handles.
|
||||
fn moved(store: &EntityStore, m: &Mat3) -> EntityStore {
|
||||
let mut out = EntityStore::new();
|
||||
for e in store.iter() {
|
||||
let mut e = (**e).clone();
|
||||
e.kind.transform(m);
|
||||
out.push(e);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether any object of `store` refers to block `name`, directly or through nested blocks of `d`.
|
||||
fn refers_to(d: &Drawing, store: &EntityStore, name: &str) -> bool {
|
||||
let mut todo: Vec<String> = store.iter().filter_map(|e| if let EntityKind::Insert(i) = &e.kind { Some(i.block.clone()) } else { None }).collect();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
while let Some(b) = todo.pop() {
|
||||
if b.eq_ignore_ascii_case(name) {
|
||||
return true;
|
||||
}
|
||||
if seen.insert(b.to_ascii_uppercase())
|
||||
&& let Some(blk) = d.block(&b)
|
||||
{
|
||||
todo.extend(blk.entities.iter().filter_map(|e| if let EntityKind::Insert(i) = &e.kind { Some(i.block.clone()) } else { None }));
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
impl BlockEdit {
|
||||
/// The editor's objects in block coordinates, ready to be the block's: unchanged objects are
|
||||
/// the block's own, the others are moved back from the editor's origin to the base point.
|
||||
fn block_objects(&self, editor: &Drawing) -> EntityStore {
|
||||
if self.base == Vec2::ZERO {
|
||||
return editor.model.clone();
|
||||
}
|
||||
let (to_editor, back) = (Mat3::translate(-self.base), Mat3::translate(self.base));
|
||||
let mut out = EntityStore::new();
|
||||
for e in editor.model.iter() {
|
||||
let unchanged = self.original.get(e.handle).filter(|o| {
|
||||
let mut shown = (***o).clone();
|
||||
shown.kind.transform(&to_editor);
|
||||
shown == **e
|
||||
});
|
||||
match unchanged {
|
||||
Some(o) => out.push((**o).clone()),
|
||||
None => {
|
||||
let mut e = (**e).clone();
|
||||
e.kind.transform(&back);
|
||||
out.push(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The drawing with the editor's objects saved into block `name` (the edited block, or a new
|
||||
/// name for BSAVEAS), from `editor`, the editor's drawing.
|
||||
pub fn compose(&self, editor: &Drawing, name: &str) -> Result<Drawing> {
|
||||
// The drawing itself: the editor's drawing is the drawing.
|
||||
if self.current {
|
||||
if !name.eq_ignore_ascii_case(CURRENT_DRAWING) {
|
||||
return Err(EngineError::Other("The current drawing can't be saved as a block from the Block Editor; use WBLOCK or BLOCK.".into()));
|
||||
}
|
||||
return Ok(editor.clone());
|
||||
}
|
||||
if refers_to(editor, &editor.model, name) {
|
||||
return Err(EngineError::Other(format!("Block \"{name}\" can't contain a reference to itself.")));
|
||||
}
|
||||
let host = &self.host.doc;
|
||||
let mut d = editor.clone();
|
||||
d.model = host.model.clone();
|
||||
d.layouts = host.layouts.clone();
|
||||
d.groups = host.groups.clone();
|
||||
d.constraints = host.constraints.clone();
|
||||
d.vports = host.vports.clone();
|
||||
d.hidden_objects = host.hidden_objects.clone();
|
||||
let entities = self.block_objects(editor);
|
||||
// The edited block's properties (the editor's definition, else the drawing's): the objects
|
||||
// are placed for its base point, also when saved under another name (BSAVEAS).
|
||||
let template = editor.block(&self.name).or_else(|| host.block(&self.name));
|
||||
let mut b = match template {
|
||||
Some(b) => (**b).clone(),
|
||||
None => cadcraft_doc::Block::new(name),
|
||||
};
|
||||
// Block names are case-insensitive: an existing block keeps its name's spelling.
|
||||
let key = d.blocks.keys().find(|k| k.eq_ignore_ascii_case(name)).cloned().unwrap_or_else(|| name.to_string());
|
||||
if !b.name.eq_ignore_ascii_case(name) {
|
||||
b.name = key.clone();
|
||||
}
|
||||
b.base.x = self.base.x;
|
||||
b.base.y = self.base.y;
|
||||
b.entities = entities;
|
||||
d.blocks.insert(key, Arc::new(b));
|
||||
Ok(d)
|
||||
}
|
||||
}
|
||||
|
||||
impl DocState {
|
||||
/// The drawing as a file saves it: outside the Block Editor's changes while it is open.
|
||||
pub fn drawing(&self) -> &Arc<Drawing> {
|
||||
match &self.block_edit {
|
||||
Some(be) => &be.host.doc,
|
||||
None => &self.doc,
|
||||
}
|
||||
}
|
||||
/// [`DocState::drawing`], to change it (a save's file version).
|
||||
pub fn drawing_arc_mut(&mut self) -> &mut Arc<Drawing> {
|
||||
match &mut self.block_edit {
|
||||
Some(be) => &mut be.host.doc,
|
||||
None => &mut self.doc,
|
||||
}
|
||||
}
|
||||
/// The block the Block Editor is editing, when it is open.
|
||||
pub fn block_editor(&self) -> Option<&str> {
|
||||
self.block_edit.as_ref().map(|b| b.name.as_str())
|
||||
}
|
||||
/// The Block Editor has changes not yet saved into the block.
|
||||
pub fn block_unsaved(&self) -> bool {
|
||||
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> {
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the Block Editor on block `name` (BEDIT).
|
||||
/// With `create`, a name no block has yet starts a new, empty block, which BSAVE creates.
|
||||
pub fn open_block_editor(&mut self, name: &str, create: bool) -> Result<()> {
|
||||
if let Some(open) = self.block_editor() {
|
||||
return Err(EngineError::Other(format!("The Block Editor is already open on \"{open}\"; close it first (BCLOSE).")));
|
||||
}
|
||||
if name.trim().eq_ignore_ascii_case(CURRENT_DRAWING) {
|
||||
let host = Box::new(self.clone());
|
||||
let editor = self.doc.clone();
|
||||
self.enter_block_editor(BlockEdit {
|
||||
name: CURRENT_DRAWING.into(),
|
||||
host,
|
||||
saved: editor,
|
||||
original: EntityStore::new(),
|
||||
base: Vec2::ZERO,
|
||||
current: true,
|
||||
});
|
||||
return Ok(());
|
||||
}
|
||||
let block = match self.doc.block(name) {
|
||||
None if create => Arc::new(cadcraft_doc::Block::new(name)),
|
||||
_ => {
|
||||
if let Some(why) = refusal(&self.doc, name) {
|
||||
return Err(EngineError::Other(why));
|
||||
}
|
||||
self.doc.block(name).cloned().ok_or_else(|| EngineError::Other(format!("Block \"{name}\" not found.")))?
|
||||
}
|
||||
};
|
||||
let base = block.base.xy();
|
||||
let mut d = (*self.doc).clone();
|
||||
d.model = if base == Vec2::ZERO { block.entities.clone() } else { moved(&block.entities, &Mat3::translate(-base)) };
|
||||
// The drawing's own groups, constraints and hidden objects stay with it.
|
||||
d.groups.clear();
|
||||
d.constraints.clear();
|
||||
d.hidden_objects.clear();
|
||||
let host = Box::new(self.clone());
|
||||
let editor = Arc::new(d);
|
||||
self.doc = editor.clone();
|
||||
self.enter_block_editor(BlockEdit { name: block.name.clone(), host, saved: editor, original: block.entities.clone(), base, current: false });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Start editing in the Block Editor `be` (the drawing is already the editor's): a fresh
|
||||
/// undo history, nothing selected, model space.
|
||||
fn enter_block_editor(&mut self, be: BlockEdit) {
|
||||
self.undo.clear();
|
||||
self.redo.clear();
|
||||
self.undo_ctl = Default::default();
|
||||
self.selection.clear();
|
||||
self.previous_selection.clear();
|
||||
self.space = cadcraft_doc::Space::Model;
|
||||
self.mspace = None;
|
||||
self.tiles = Default::default();
|
||||
self.views.clear();
|
||||
self.view_history.clear();
|
||||
self.block_edit = Some(be);
|
||||
self.revision += 1;
|
||||
}
|
||||
|
||||
/// Save the Block Editor's objects into the block (BSAVE), or into a new block `as_name`
|
||||
/// (BSAVEAS), which the editor then edits. One undo step in the drawing.
|
||||
pub fn save_block_editor(&mut self, as_name: Option<&str>) -> Result<()> {
|
||||
let Some(be) = &self.block_edit else { return Err(EngineError::Other("The Block Editor is not open.".into())) };
|
||||
let name = as_name.unwrap_or(&be.name).to_string();
|
||||
let d = be.compose(&self.doc, &name)?;
|
||||
let original = d.block(&name).map(|b| b.entities.clone()).unwrap_or_default();
|
||||
let name = d.block(&name).map(|b| b.name.clone()).unwrap_or(name);
|
||||
let editor = self.doc.clone();
|
||||
let Some(be) = &mut self.block_edit else { return Ok(()) };
|
||||
let before = be.host.doc.clone();
|
||||
let selection = be.host.selection.clone();
|
||||
be.host.push_undo(Snapshot { label: UNDO_LABEL.into(), doc: before, selection });
|
||||
be.host.doc = Arc::new(d);
|
||||
be.host.revision += 1;
|
||||
be.original = original;
|
||||
be.saved = editor;
|
||||
be.name = name;
|
||||
self.revision += 1;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Close the Block Editor (BCLOSE), saving its changes into the block first when `save`.
|
||||
pub fn close_block_editor(&mut self, save: bool) -> Result<()> {
|
||||
if self.block_edit.is_none() {
|
||||
return Err(EngineError::Other("The Block Editor is not open.".into()));
|
||||
}
|
||||
if save && self.block_unsaved() {
|
||||
self.save_block_editor(None)?;
|
||||
}
|
||||
let Some(be) = self.block_edit.take() else { return Ok(()) };
|
||||
let host = *be.host;
|
||||
self.doc = host.doc;
|
||||
self.undo = host.undo;
|
||||
self.redo = host.redo;
|
||||
self.undo_ctl = host.undo_ctl;
|
||||
// The drawing comes back with nothing selected; Previous is what was selected before.
|
||||
self.selection.clear();
|
||||
self.previous_selection = if host.selection.is_empty() { host.previous_selection } else { host.selection };
|
||||
self.space = host.space;
|
||||
self.mspace = host.mspace;
|
||||
self.tiles = host.tiles;
|
||||
self.views = host.views;
|
||||
self.view_history = host.view_history;
|
||||
self.revision += 1;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
//! Blocks: BLOCK, INSERT, ATTDEF, ATTEDIT (programmatic), WBLOCK, BASE, PURGE, block listing.
|
||||
//! Blocks: BLOCK, INSERT, ATTDEF, ATTEDIT (programmatic), WBLOCK, BASE, PURGE, block listing, the
|
||||
//! Block Editor.
|
||||
|
||||
pub(crate) mod bedit;
|
||||
mod purge;
|
||||
|
||||
use std::sync::Arc;
|
||||
@@ -14,7 +16,9 @@ use super::*;
|
||||
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
mod attedit;
|
||||
mod dyngrip;
|
||||
mod extract;
|
||||
pub(crate) mod refedit;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
@@ -42,6 +46,24 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
attedit::dash_attedit_spec(),
|
||||
CommandSpec::new("wblock", "Write Block", run_wblock).alias(&["w"]).params("{path, name? | handles?, base?}"),
|
||||
CommandSpec::new("base", "Base", run_base).menu(&["Draw", "Block", "Base"]).params("{at: [x,y]}"),
|
||||
CommandSpec::new("blocks.dynamic", "Dynamic Block Data", run_dynamic)
|
||||
.params("{name (a block, or \"*Model_Space\" for a drawing that is a dynamic block's own)} → {parameters, actions, other} | null (not a dynamic block)")
|
||||
.enabled(has_doc)
|
||||
.noundo(),
|
||||
CommandSpec::new("blocks.reference", "Dynamic Block Reference", run_reference)
|
||||
.params("{handle (a block reference)} → {definition, values (by parameter graph id), properties: [{name, value, choices}]} | null (not a dynamic block reference)")
|
||||
.enabled(has_doc)
|
||||
.noundo(),
|
||||
CommandSpec::new("blocks.reference.set", "Set Dynamic Property", run_reference_set)
|
||||
.params("{handle (a dynamic block reference), name (a custom property as Properties names it: a visibility state, a lookup, a distance or \"Position1 X\"), value (lengths in drawing units)} → {properties}")
|
||||
.enabled(has_doc),
|
||||
CommandSpec::new("blocks.reference.grips", "Dynamic Block Grips", dyngrip::run_grips)
|
||||
.params("{handle} → [{node, part: base|end|at, shape: linear|point|list, at: [x,y], dir: [x,y], property, choices?}] (empty: not a dynamic block reference)")
|
||||
.enabled(has_doc)
|
||||
.noundo(),
|
||||
CommandSpec::new("blocks.reference.grip", "Drag Dynamic Grip", dyngrip::run_grip)
|
||||
.params("{handle, node (the parameter's graph id), part: base|end|at, to: [x,y] (drawing coordinates)} → {properties}: a distance's arrow changes the distance (snapped to its values), a position's square moves it")
|
||||
.enabled(has_doc),
|
||||
CommandSpec::new("purge", "Purge", purge::run)
|
||||
.alias(&["pu", "-purge"])
|
||||
.params("{type?: all|blocks|dimstyles|groups|layers|linetypes|mleaderstyles|tablestyles|textstyles|zerolength|emptytext (default all: every named type), names?: \"A*,B\" (default *)} → purged")
|
||||
@@ -55,6 +77,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
]
|
||||
.into_iter()
|
||||
.chain(xref::specs())
|
||||
.chain(refedit::specs())
|
||||
.chain(bedit::specs())
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -97,6 +121,9 @@ pub(crate) fn make_block(s: &mut Session, name: &str, base: Vec2, hs: &[Handle],
|
||||
if references_block(d, &ents, name) {
|
||||
return Err(bad("block", format!("Block \"{name}\" references itself.")));
|
||||
}
|
||||
if d.refedit.as_ref().is_some_and(|r| r.block.eq_ignore_ascii_case(name)) {
|
||||
return Err(bad("block", format!("Block \"{name}\" is being edited in place; finish with REFCLOSE first.")));
|
||||
}
|
||||
// Block names are case-insensitive: redefining keeps the existing definition's name.
|
||||
let name = d.blocks.keys().find(|k| k.eq_ignore_ascii_case(name)).cloned().unwrap_or_else(|| name.to_string());
|
||||
let name = name.as_str();
|
||||
@@ -180,6 +207,8 @@ pub(crate) fn insert_with(
|
||||
constant: false,
|
||||
prompt: String::new(),
|
||||
props: Default::default(),
|
||||
multiline: ad.multiline.as_ref().map(|ml| ml.transformed(&m)),
|
||||
modes: ad.modes.for_reference(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -252,6 +281,8 @@ fn attdef_kind(s: &Session, tag: &str, prompt: &str, default: &str, at: Vec2, he
|
||||
constant: false,
|
||||
prompt: prompt.into(),
|
||||
props: Default::default(),
|
||||
multiline: None,
|
||||
modes: Default::default(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -321,9 +352,61 @@ fn run_base(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn run_list(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let d = s.doc()?;
|
||||
Ok(
|
||||
json!({ "blocks": d.blocks.values().filter(|b| !b.anonymous).map(|b| json!({"name": b.name, "entities": b.entities.len(), "base": [b.base.x, b.base.y], "description": b.description})).collect::<Vec<_>>() }),
|
||||
)
|
||||
let block = |b: &Block| {
|
||||
let dynamic = cadcraft_doc::dynblock::dynamic_block(d, &b.name).is_some();
|
||||
json!({"name": b.name, "entities": b.entities.len(), "base": [b.base.x, b.base.y], "description": b.description, "dynamic": dynamic})
|
||||
};
|
||||
Ok(json!({ "blocks": d.blocks.values().filter(|b| !b.anonymous).map(|b| block(b)).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
/// A dynamic block's parameters and actions as read from the file (null for a block that has
|
||||
/// none).
|
||||
fn run_dynamic(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = str_param(p, "name").ok_or_else(|| bad("blocks.dynamic", "`name` is required"))?;
|
||||
let d = s.doc()?;
|
||||
if d.block(name).is_none() && !name.trim().eq_ignore_ascii_case("*Model_Space") {
|
||||
return Err(bad("blocks.dynamic", format!("no block `{name}`")));
|
||||
}
|
||||
serde_json::to_value(cadcraft_doc::dynblock::dynamic_block(d, name)).map_err(|e| bad("blocks.dynamic", e.to_string()))
|
||||
}
|
||||
|
||||
/// The dynamic block a block reference shows, the values it was given and its custom properties
|
||||
/// (null for any other reference).
|
||||
fn run_reference(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let h = super::curves::handle_param(p, "handle").ok_or_else(|| bad("blocks.reference", "`handle` is required"))?;
|
||||
let d = s.doc()?;
|
||||
match d.entity(h).map(|e| &e.kind) {
|
||||
Some(EntityKind::Insert(_)) => {}
|
||||
Some(_) => return Err(bad("blocks.reference", "`handle` is not a block reference")),
|
||||
None => return Err(bad("blocks.reference", format!("no object {}", h.hex()))),
|
||||
}
|
||||
let (Some(r), Some((_, properties))) = (cadcraft_doc::dynblock::reference(d, h), cadcraft_doc::dynblock::custom_properties(d, h)) else {
|
||||
return Ok(Value::Null);
|
||||
};
|
||||
Ok(json!({ "definition": r.definition, "values": r.values, "properties": properties }))
|
||||
}
|
||||
|
||||
/// Change a custom property of a dynamic block reference (one undo step).
|
||||
fn run_reference_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "blocks.reference.set";
|
||||
let h = super::curves::handle_param(p, "handle").ok_or_else(|| bad(cmd, "`handle` is required"))?;
|
||||
let name = str_param(p, "name").ok_or_else(|| bad(cmd, "`name` is required"))?;
|
||||
let value = match p.get("value") {
|
||||
Some(Value::String(v)) => v.clone(),
|
||||
Some(Value::Number(n)) => n.to_string(),
|
||||
_ => return Err(bad(cmd, "`value` is required")),
|
||||
};
|
||||
if !matches!(s.doc()?.entity(h).map(|e| &e.kind), Some(EntityKind::Insert(_))) {
|
||||
return Err(bad(cmd, "`handle` is not a block reference"));
|
||||
}
|
||||
if super::curves::is_locked(s, h) {
|
||||
return Err(bad(cmd, "the reference is on a locked layer"));
|
||||
}
|
||||
let mut d = s.doc()?.clone();
|
||||
cadcraft_doc::dynblock::set_property(&mut d, h, name, &value).map_err(|e| bad(cmd, e))?;
|
||||
let properties = cadcraft_doc::dynblock::custom_properties(&d, h).map(|(_, p)| p).unwrap_or_default();
|
||||
*s.doc_mut()? = d;
|
||||
Ok(json!({ "properties": properties }))
|
||||
}
|
||||
|
||||
// ---------------- interactive ----------------
|
||||
|
||||
@@ -97,7 +97,16 @@ fn synced(blk: &Block, defs: &[&Attrib], ins: &Insert) -> Vec<Attrib> {
|
||||
.find(|a| a.tag.eq_ignore_ascii_case(&ad.tag))
|
||||
.map(|a| a.text.value.clone())
|
||||
.unwrap_or_else(|| ad.text.value.clone());
|
||||
Attrib { tag: ad.tag.clone(), text: t, invisible: ad.invisible, constant: false, prompt: String::new(), props: Default::default() }
|
||||
Attrib {
|
||||
tag: ad.tag.clone(),
|
||||
text: t,
|
||||
invisible: ad.invisible,
|
||||
constant: false,
|
||||
prompt: String::new(),
|
||||
props: Default::default(),
|
||||
multiline: ad.multiline.as_ref().map(|ml| ml.transformed(&m)),
|
||||
modes: ad.modes.for_reference(),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
//! BEDIT, BSAVE, BSAVEAS and BCLOSE: the Block Editor. BEDIT opens a block definition in the
|
||||
//! drawing's own tab, its base point at the origin; the block's objects are edited with the
|
||||
//! ordinary commands; BSAVE saves them into the definition, so every reference to the block in the
|
||||
//! drawing shows the change; BCLOSE goes back to the drawing, saving or discarding what isn't saved.
|
||||
//! The state is [`crate::blockedit`].
|
||||
|
||||
use cadcraft_doc::{Drawing, EntityKind, Handle};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::cmd::curves::{KW, handle_value};
|
||||
use crate::cmd::{CommandSpec, bad, bool_or, str_param};
|
||||
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
pub(super) fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("bedit", "Block Editor", run_bedit)
|
||||
.menu(&["Tools", "Block Editor"])
|
||||
.alias(&["be", "-bedit"])
|
||||
.params("{name? (\"<Current Drawing>\": the drawing itself, a dynamic block's own drawing) | handle? (a block reference: edits its block); default the one selected block reference, create?: bool (a name no block has yet starts a new block; BSAVE creates it)} → {block, new}")
|
||||
.enabled(not_editing)
|
||||
.noundo()
|
||||
.interactive(|_| Ok(Box::new(BeditM))),
|
||||
CommandSpec::new("bsave", "Save Block", run_bsave).params("{} → {block}").enabled(editing).noundo(),
|
||||
CommandSpec::new("bsaveas", "Save Block As", run_bsaveas)
|
||||
.params("{name, replace?: bool (redefine an existing block of that name)} → {block}")
|
||||
.enabled(editing_a_block)
|
||||
.noundo()
|
||||
.interactive(|_| Ok(Box::new(BsaveasM::default()))),
|
||||
CommandSpec::new("bclose", "Close Block Editor", run_bclose)
|
||||
.params("{save?: bool (default true; false discards the changes not saved into the block)} → {block, saved}")
|
||||
.enabled(editing)
|
||||
.noundo()
|
||||
.interactive(|_| Ok(Box::new(BcloseM))),
|
||||
]
|
||||
}
|
||||
|
||||
fn editing(s: &Session) -> std::result::Result<(), String> {
|
||||
match s.state() {
|
||||
Ok(st) if st.block_edit.is_some() => Ok(()),
|
||||
Ok(_) => Err("The Block Editor is not open.".into()),
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The Block Editor is open on a block (not on the drawing itself, which BSAVEAS can't save as one).
|
||||
fn editing_a_block(s: &Session) -> std::result::Result<(), String> {
|
||||
editing(s)?;
|
||||
match s.state() {
|
||||
Ok(st) if st.block_edit.as_ref().is_some_and(|b| b.current) => {
|
||||
Err("The Block Editor is open on the current drawing; there is no block to save under another name.".into())
|
||||
}
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn not_editing(s: &Session) -> std::result::Result<(), String> {
|
||||
match s.state() {
|
||||
Ok(st) => match st.block_editor() {
|
||||
Some(name) => Err(format!("The Block Editor is already open on \"{name}\"; close it first (BCLOSE).")),
|
||||
None => Ok(()),
|
||||
},
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Commands that leave model space, which the Block Editor is: not available while it is open.
|
||||
const NOT_IN_EDITOR: &[&str] = &[
|
||||
"layout",
|
||||
"layout.new",
|
||||
"layout.delete",
|
||||
"layout.rename",
|
||||
"layout.copy",
|
||||
"layout.set",
|
||||
"mview",
|
||||
"mview.polygonal",
|
||||
"mspace",
|
||||
"pspace",
|
||||
"chspace",
|
||||
];
|
||||
|
||||
/// Why command `id` can't run now, when the Block Editor is open and it is one of [`NOT_IN_EDITOR`].
|
||||
pub(crate) fn refused_in_editor(s: &Session, id: &str) -> Option<String> {
|
||||
let name = s.state().ok()?.block_editor()?;
|
||||
NOT_IN_EDITOR.contains(&id).then(|| format!("Not available in the Block Editor (editing \"{name}\"); close it first (BCLOSE)."))
|
||||
}
|
||||
|
||||
/// The block a block reference refers to.
|
||||
fn block_of(d: &Drawing, h: Handle) -> Option<String> {
|
||||
match d.entity(h).map(|e| &e.kind) {
|
||||
Some(EntityKind::Insert(i)) => Some(dynamic_definition(d, &i.block).unwrap_or_else(|| i.block.clone())),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The dynamic block whose anonymous block `name` is (a dynamic block reference with values of
|
||||
/// its own shows one, `*U12`): its BLOCK_RECORD's `AcDbBlockRepBTag` xdata names the dynamic
|
||||
/// block's record. `None` for other blocks.
|
||||
fn dynamic_definition(d: &Drawing, name: &str) -> Option<String> {
|
||||
if !name.starts_with('*') {
|
||||
return None;
|
||||
}
|
||||
let x = &d.foreign.records.get(&("BLOCK_RECORD".to_string(), name.to_ascii_uppercase()))?.xdata;
|
||||
let at = x.iter().position(|t| t.code == 1001 && t.value.eq_ignore_ascii_case("AcDbBlockRepBTag"))?;
|
||||
let record = x.iter().skip(at + 1).take_while(|t| t.code != 1001).find(|t| t.code == 1005).and_then(|t| Handle::parse_hex(&t.value))?;
|
||||
let (kind, block) = d.foreign.targets.get(&record)?;
|
||||
(kind == "BLOCK_RECORD").then(|| d.block(block).map(|b| b.name.clone())).flatten()
|
||||
}
|
||||
|
||||
/// Open the Block Editor on block `name` and frame its objects.
|
||||
fn open(s: &mut Session, name: &str, create: bool) -> Result<Value> {
|
||||
let new = create && !name.eq_ignore_ascii_case(crate::blockedit::CURRENT_DRAWING) && s.doc()?.block(name).is_none();
|
||||
s.state_mut()?.open_block_editor(name, create)?;
|
||||
let name = s.state()?.block_editor().unwrap_or(name).to_string();
|
||||
// The block's objects with room around them (and the editor's bar above them).
|
||||
s.zoom_extents()?;
|
||||
let c = s.state()?.view().center;
|
||||
s.zoom_about(0.75, c)?;
|
||||
s.echo(if new {
|
||||
format!("Block Editor: new block \"{name}\". BSAVE creates it in the drawing; BCLOSE closes the editor.")
|
||||
} else {
|
||||
format!("Block Editor: editing \"{name}\". BSAVE saves it into the drawing; BCLOSE closes the editor.")
|
||||
});
|
||||
let dynamic =
|
||||
|be: &crate::blockedit::BlockEdit| crate::blockedit::has_dynamic_data(&be.host.doc, if be.current { "*Model_Space" } else { &be.name });
|
||||
if s.state().is_ok_and(|st| st.block_edit.as_ref().is_some_and(dynamic)) {
|
||||
s.echo("This is a dynamic block: its parameters and actions are kept but can't be edited in CADCraft yet.");
|
||||
}
|
||||
Ok(json!({ "block": name, "new": new }))
|
||||
}
|
||||
|
||||
/// The one block reference selected, if exactly one is.
|
||||
fn selected_reference(s: &Session) -> Option<String> {
|
||||
let d = s.doc().ok()?;
|
||||
let refs: Vec<String> = s.selection().iter().filter_map(|h| block_of(d, *h)).collect();
|
||||
match refs.as_slice() {
|
||||
[one] => Some(one.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn run_bedit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = match (str_param(p, "name"), p.get("handle")) {
|
||||
(Some(n), _) => n.to_string(),
|
||||
(None, Some(h)) => {
|
||||
let h = handle_value(h).ok_or_else(|| bad("bedit", "`handle` must be a handle"))?;
|
||||
block_of(s.doc()?, h).ok_or_else(|| bad("bedit", "`handle` is not a block reference"))?
|
||||
}
|
||||
(None, None) => selected_reference(s).ok_or_else(|| bad("bedit", "`name` or `handle` is required (or select one block reference)"))?,
|
||||
};
|
||||
let create = bool_or(p, "create", false);
|
||||
let name = name.trim();
|
||||
let current = name.eq_ignore_ascii_case(crate::blockedit::CURRENT_DRAWING);
|
||||
if create && !current && s.doc()?.block(name).is_none() && !super::valid_block_name(name) {
|
||||
return Err(bad("bedit", format!("\"{name}\" is not a valid block name")));
|
||||
}
|
||||
open(s, name, create)
|
||||
}
|
||||
|
||||
fn run_bsave(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
s.state_mut()?.save_block_editor(None)?;
|
||||
let name = s.state()?.block_editor().unwrap_or_default().to_string();
|
||||
s.echo(format!("Block \"{name}\" saved."));
|
||||
Ok(json!({ "block": name }))
|
||||
}
|
||||
|
||||
/// Check a BSAVEAS name: valid, and not another existing block unless `replace`.
|
||||
fn check_save_as(s: &Session, name: &str, replace: bool) -> Result<()> {
|
||||
if !super::valid_block_name(name) {
|
||||
return Err(bad("bsaveas", format!("\"{name}\" is not a valid block name")));
|
||||
}
|
||||
let d = s.doc()?;
|
||||
let current = s.state()?.block_editor().is_some_and(|b| b.eq_ignore_ascii_case(name));
|
||||
if !current && !replace && d.block(name).is_some() {
|
||||
return Err(bad("bsaveas", format!("Block \"{name}\" already exists; pass `replace: true` to redefine it")));
|
||||
}
|
||||
if let Some(why) = d.block(name).filter(|_| !current).and_then(|_| crate::blockedit::refusal(d, name)) {
|
||||
return Err(bad("bsaveas", why));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_bsaveas(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = str_param(p, "name").ok_or_else(|| bad("bsaveas", "`name` is required"))?.trim().to_string();
|
||||
check_save_as(s, &name, bool_or(p, "replace", false))?;
|
||||
s.state_mut()?.save_block_editor(Some(&name))?;
|
||||
let name = s.state()?.block_editor().unwrap_or_default().to_string();
|
||||
s.echo(format!("Block \"{name}\" saved; the Block Editor now edits it."));
|
||||
Ok(json!({ "block": name }))
|
||||
}
|
||||
|
||||
fn close(s: &mut Session, save: bool) -> Result<Value> {
|
||||
let st = s.state()?;
|
||||
let name = st.block_editor().unwrap_or_default().to_string();
|
||||
let saved = save && st.block_unsaved();
|
||||
s.state_mut()?.close_block_editor(save)?;
|
||||
if saved {
|
||||
s.echo(format!("Block \"{name}\" saved."));
|
||||
}
|
||||
Ok(json!({ "block": name, "saved": saved }))
|
||||
}
|
||||
|
||||
fn run_bclose(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
close(s, bool_or(p, "save", true))
|
||||
}
|
||||
|
||||
// ---------------- interactive ----------------
|
||||
|
||||
/// BEDIT: the selected block reference's block, else a block name typed or a reference picked.
|
||||
struct BeditM;
|
||||
|
||||
impl Interactive for BeditM {
|
||||
fn name(&self) -> &'static str {
|
||||
"BEDIT"
|
||||
}
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
match selected_reference(s) {
|
||||
Some(name) => open(s, &name, false).map(|_| Step::Done),
|
||||
None => Ok(Step::Continue),
|
||||
}
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
Prompt::new("Enter block name or select a block reference [?]", Accept { point: true, number: false, text: true, select: true, enter: true })
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
match i {
|
||||
Input::Text(t) if t.trim() == "?" => {
|
||||
let names: Vec<String> = {
|
||||
let d = s.doc()?;
|
||||
d.blocks.values().filter(|b| crate::blockedit::refusal(d, &b.name).is_none()).map(|b| b.name.clone()).collect()
|
||||
};
|
||||
s.echo(if names.is_empty() { "No blocks to edit.".to_string() } else { names.join(", ") });
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Input::Text(t) | Input::Keyword(t) => open(s, t.trim(), false).map(|_| Step::Done),
|
||||
Input::Pick(hs) => {
|
||||
let name = {
|
||||
let d = s.doc()?;
|
||||
hs.iter().find_map(|h| block_of(d, *h))
|
||||
};
|
||||
match name {
|
||||
Some(n) => open(s, &n, false).map(|_| Step::Done),
|
||||
None => Err(EngineError::Other("Select a block reference.".into())),
|
||||
}
|
||||
}
|
||||
Input::Enter => Ok(Step::Done),
|
||||
_ => Ok(Step::Continue),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// BCLOSE: asks whether to save changes not saved yet.
|
||||
struct BcloseM;
|
||||
|
||||
impl Interactive for BcloseM {
|
||||
fn name(&self) -> &'static str {
|
||||
"BCLOSE"
|
||||
}
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
if s.state()?.block_unsaved() { Ok(Step::Continue) } else { close(s, false).map(|_| Step::Done) }
|
||||
}
|
||||
fn prompt(&self, s: &Session) -> Prompt {
|
||||
let name = s.state().ok().and_then(|st| st.block_editor().map(str::to_string)).unwrap_or_default();
|
||||
Prompt::new(format!("Save changes to block \"{name}\"?"), KW).kw(&["Yes", "No"]).default("Y")
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
let answer = match i {
|
||||
Input::Enter => "Y".to_string(),
|
||||
Input::Keyword(k) | Input::Text(k) => k,
|
||||
_ => return Ok(Step::Continue),
|
||||
};
|
||||
match answer.trim().chars().next().map(|c| c.to_ascii_uppercase()) {
|
||||
Some('Y') => close(s, true).map(|_| Step::Done),
|
||||
Some('N') => close(s, false).map(|_| Step::Done),
|
||||
_ => Err(EngineError::Other("Enter Yes or No.".into())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// BSAVEAS: a new block name; an existing block is redefined only when confirmed.
|
||||
#[derive(Default)]
|
||||
struct BsaveasM {
|
||||
/// The name given, waiting for "redefine?" because a block of that name exists.
|
||||
name: Option<String>,
|
||||
}
|
||||
|
||||
impl BsaveasM {
|
||||
fn save(s: &mut Session, name: &str, replace: bool) -> Result<Step> {
|
||||
run_bsaveas(s, &json!({ "name": name, "replace": replace })).map(|_| Step::Done)
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for BsaveasM {
|
||||
fn name(&self) -> &'static str {
|
||||
"BSAVEAS"
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
match &self.name {
|
||||
Some(n) => Prompt::new(format!("Block \"{n}\" already exists. Redefine it?"), KW).kw(&["Yes", "No"]).default("N"),
|
||||
None => Prompt::new("Enter block name", Accept::TEXT),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
if let Some(n) = self.name.clone() {
|
||||
let yes = matches!(&i, Input::Keyword(k) | Input::Text(k) if k.trim().to_ascii_uppercase().starts_with('Y'));
|
||||
return if yes { Self::save(s, &n, true) } else { Ok(Step::Done) };
|
||||
}
|
||||
let name = match i {
|
||||
Input::Text(t) => t.trim().to_string(),
|
||||
Input::Enter => return Ok(Step::Done),
|
||||
_ => return Ok(Step::Continue),
|
||||
};
|
||||
let current = s.state()?.block_editor().is_some_and(|b| b.eq_ignore_ascii_case(&name));
|
||||
if !current && s.doc()?.block(&name).is_some() {
|
||||
check_save_as(s, &name, true)?;
|
||||
self.name = Some(name);
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
Self::save(s, &name, false)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
//! Grips of dynamic block references: `blocks.reference.grips` lists them, `blocks.reference.grip`
|
||||
//! drags one (one undo step), and [`Session::start_dyn_grip`] starts the drag the UI's click on a
|
||||
//! grip begins, with the reference re-evaluated under the cursor as its preview. Typing a number
|
||||
//! at a distance's grip sets the distance.
|
||||
|
||||
use cadcraft_doc::dynblock::{self, GripPart, GripShape};
|
||||
use cadcraft_doc::{Drawing, EntityKind, Handle};
|
||||
use cadcraft_geom::Vec2;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::cmd::{bad, point_param, str_param};
|
||||
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Running, Session, Step};
|
||||
|
||||
/// Most objects one preview draws.
|
||||
const MAX_PREVIEW: usize = 2000;
|
||||
|
||||
/// The grips of a dynamic block reference (an empty list for any other object).
|
||||
pub(super) fn run_grips(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let h = super::super::curves::handle_param(p, "handle").ok_or_else(|| bad("blocks.reference.grips", "`handle` is required"))?;
|
||||
serde_json::to_value(dynblock::grips(s.doc()?, h)).map_err(|e| bad("blocks.reference.grips", e.to_string()))
|
||||
}
|
||||
|
||||
/// Drag a grip of a dynamic block reference to a point (one undo step).
|
||||
pub(super) fn run_grip(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "blocks.reference.grip";
|
||||
let h = super::super::curves::handle_param(p, "handle").ok_or_else(|| bad(cmd, "`handle` is required"))?;
|
||||
let node = p
|
||||
.get("node")
|
||||
.and_then(Value::as_i64)
|
||||
.and_then(|n| i32::try_from(n).ok())
|
||||
.ok_or_else(|| bad(cmd, "`node` (a parameter's graph id) is required"))?;
|
||||
let part = str_param(p, "part").and_then(GripPart::from_name).ok_or_else(|| bad(cmd, "`part` must be base, end or at"))?;
|
||||
let to = point_param(p, "to").ok_or_else(|| bad(cmd, "`to` [x, y] is required"))?;
|
||||
drag(s, h, node, part, to).map_err(|e| bad(cmd, e))?;
|
||||
let properties = dynblock::custom_properties(s.doc()?, h).map(|(_, p)| p).unwrap_or_default();
|
||||
Ok(json!({ "properties": properties }))
|
||||
}
|
||||
|
||||
/// Drag grip (`node`, `part`) of reference `h` to `to` in the session's drawing.
|
||||
fn drag(s: &mut Session, h: Handle, node: i32, part: GripPart, to: Vec2) -> std::result::Result<(), String> {
|
||||
if !matches!(s.doc().map_err(|e| e.to_string())?.entity(h).map(|e| &e.kind), Some(EntityKind::Insert(_))) {
|
||||
return Err("`handle` is not a block reference".into());
|
||||
}
|
||||
if super::super::curves::is_locked(s, h) {
|
||||
return Err("the reference is on a locked layer".into());
|
||||
}
|
||||
let mut d = s.doc().map_err(|e| e.to_string())?.clone();
|
||||
dynblock::drag_grip(&mut d, h, node, part, to)?;
|
||||
*s.doc_mut().map_err(|e| e.to_string())? = d;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The reference `h` as drawn in drawing `d`: its block's shown objects placed by it, and its
|
||||
/// attributes (what a preview draws, in the preview colour: solid fills are left out so they
|
||||
/// don't cover the outline and text).
|
||||
fn drawn(d: &Drawing, h: Handle) -> Vec<EntityKind> {
|
||||
let Some(EntityKind::Insert(ins)) = d.entity(h).map(|e| &e.kind) else { return Vec::new() };
|
||||
let Some(b) = d.block(&ins.block) else { return Vec::new() };
|
||||
let m = ins.transform(b.base.xy());
|
||||
let mut out: Vec<EntityKind> = b
|
||||
.entities
|
||||
.iter()
|
||||
.filter(|e| e.common.visible && !matches!(&e.kind, EntityKind::AttDef(_) | EntityKind::Wipeout(_)))
|
||||
.filter(|e| !matches!(&e.kind, EntityKind::Hatch(h) if h.solid))
|
||||
.take(MAX_PREVIEW)
|
||||
.map(|e| {
|
||||
let mut k = e.kind.clone();
|
||||
k.transform(&m);
|
||||
k
|
||||
})
|
||||
.collect();
|
||||
out.extend(ins.attribs.iter().filter(|a| !a.invisible).map(|a| EntityKind::Text(a.text.clone())));
|
||||
out
|
||||
}
|
||||
|
||||
/// A dynamic grip being dragged.
|
||||
struct DynGripEdit {
|
||||
handle: Handle,
|
||||
node: i32,
|
||||
part: GripPart,
|
||||
/// Where the grip is (the rubber band's start).
|
||||
from: Vec2,
|
||||
/// The property it changes, and whether it is a distance (a typed number sets it).
|
||||
property: String,
|
||||
distance: bool,
|
||||
}
|
||||
|
||||
impl Interactive for DynGripEdit {
|
||||
fn name(&self) -> &'static str {
|
||||
"GRIP"
|
||||
}
|
||||
|
||||
fn begin(&mut self, _s: &mut Session) -> Result<Step> {
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
let (msg, accept) = if self.distance {
|
||||
("** STRETCH ** Specify stretch point or distance", Accept::POINT_OR_NUMBER)
|
||||
} else {
|
||||
("** STRETCH ** Specify stretch point", Accept::POINT)
|
||||
};
|
||||
Prompt::new(msg, accept).kw(&["eXit"]).base(self.from)
|
||||
}
|
||||
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
let to = match i {
|
||||
Input::Point(p) => p,
|
||||
Input::Deferred(d) => d.at,
|
||||
Input::Text(t) if self.distance => {
|
||||
let mut d = s.doc()?.clone();
|
||||
dynblock::set_property(&mut d, self.handle, &self.property, t.trim()).map_err(EngineError::Other)?;
|
||||
*s.doc_mut()? = d;
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
Input::Keyword(k) if k == "eXit" => return Ok(Step::Done),
|
||||
Input::Enter | Input::Cancel => return Ok(Step::Done),
|
||||
_ => return Err(EngineError::Other("Requires a point.".into())),
|
||||
};
|
||||
drag(s, self.handle, self.node, self.part, to).map_err(EngineError::Other)?;
|
||||
Ok(Step::Done)
|
||||
}
|
||||
|
||||
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
let Ok(d) = s.doc() else { return Vec::new() };
|
||||
let mut d = d.clone();
|
||||
let mut out = match dynblock::drag_grip(&mut d, self.handle, self.node, self.part, c) {
|
||||
Ok(()) => drawn(&d, self.handle),
|
||||
Err(_) => Vec::new(),
|
||||
};
|
||||
out.push(crate::cmd::helpers::line(self.from, c));
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Start dragging grip `part` of parameter `node` of dynamic block reference `handle` (the
|
||||
/// UI's click on a dynamic grip). A list grip (visibility, lookup) has no drag: the UI offers
|
||||
/// its choices and sets them with `blocks.reference.set`.
|
||||
pub fn start_dyn_grip(&mut self, handle: Handle, node: i32, part: GripPart) -> Result<()> {
|
||||
if self.running.is_some() {
|
||||
self.cancel();
|
||||
}
|
||||
let g = dynblock::grips(self.doc()?, handle)
|
||||
.into_iter()
|
||||
.find(|g| g.node == node && g.part == part)
|
||||
.ok_or_else(|| EngineError::Other("no such grip".into()))?;
|
||||
if g.shape == GripShape::List {
|
||||
return Err(EngineError::Other("a list grip is chosen from, not dragged".into()));
|
||||
}
|
||||
let edit = DynGripEdit { handle, node, part, from: g.at, property: g.property, distance: g.shape == GripShape::Linear };
|
||||
let machine = crate::guard::Guarded::wrap(Box::new(edit));
|
||||
let st = self.state()?;
|
||||
let (before, selection_before) = (st.doc.clone(), st.selection.clone());
|
||||
self.running = Some(Running { id: "grip".into(), machine, before, selection_before });
|
||||
self.pending_window = None;
|
||||
self.point_mods.clear();
|
||||
self.echo("** STRETCH **");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,469 @@
|
||||
//! REFEDIT, REFSET and REFCLOSE: edit a block reference in place.
|
||||
//!
|
||||
//! REFEDIT hides the picked reference and puts the block's objects (all, or the ones named) into
|
||||
//! the drawing where the reference shows them: the *working set*, ordinary objects that every
|
||||
//! command edits. The rest of the block stands there as faded copies, and everything else in the
|
||||
//! drawing is locked ([`crate::cmd::curves::is_locked`], enforced by [`after_command`]). Objects
|
||||
//! drawn while editing join the working set. REFSET adds objects to or removes them from it.
|
||||
//! REFCLOSE writes the working set back into the block definition, so every reference to the
|
||||
//! block shows the change, or discards the edit.
|
||||
//!
|
||||
//! The state is [`Drawing::refedit`]: it travels with undo, is never written to a file (files
|
||||
//! are written from [`Drawing::committed`]) and ends with the drawing it belongs to.
|
||||
//!
|
||||
//! Not yet: external references (their source drawing would have to be written back), nested
|
||||
//! references picked at a depth, annotative scale contexts and attribute synchronisation.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_doc::{Drawing, Entity, EntityStore, Handle, RefEdit, Transparency, WorkItem};
|
||||
use cadcraft_geom::Mat3;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::super::curves::handle_value;
|
||||
use super::super::machines::{SelOutcome, SelectPhase, SelectRun};
|
||||
use super::references_block;
|
||||
use crate::cmd::{CommandSpec, bad, bool_or, has_doc, str_param, targets};
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
/// Transparency of the faded copies of the block's other objects.
|
||||
const GHOST_FADE: u8 = 70;
|
||||
|
||||
pub(super) fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("refedit", "Edit Reference In-Place", run_refedit)
|
||||
.menu(&["Tools", "Xref and Block In-place Editing", "Edit Reference In-Place"])
|
||||
.params("{handle? | handles? | name? (a block reference, default the selection), objects?: [block object handles] (default all)} → {block, insert, working: [handles], faded}")
|
||||
.enabled(has_doc)
|
||||
.interactive(|_| Ok(Box::new(SelectRun::single("refedit", "REFEDIT", "Select reference")))),
|
||||
CommandSpec::new("refset", "Working Set", run_refset)
|
||||
.menu(&["Tools", "Xref and Block In-place Editing", "Add to Working Set"])
|
||||
.params("{add?: [handles], remove?: [handles]} (or {handles, mode?: \"add\"|\"remove\"}); with neither: lists the working set → {working: [handles], added, removed}")
|
||||
.enabled(editing)
|
||||
.interactive(|_| Ok(Box::new(RefsetM::default()))),
|
||||
CommandSpec::new("refclose", "Save Reference Edits", run_refclose)
|
||||
.menu(&["Tools", "Xref and Block In-place Editing", "Save Reference Edits"])
|
||||
.params("{option?: \"save\"|\"discard\" | save?: bool (default true)} → {block, saved, objects}")
|
||||
.enabled(editing)
|
||||
.interactive(|_| Ok(Box::new(RefcloseM))),
|
||||
]
|
||||
}
|
||||
|
||||
fn editing(s: &Session) -> std::result::Result<(), String> {
|
||||
has_doc(s)?;
|
||||
if s.doc().is_ok_and(|d| d.refedit.is_some()) { Ok(()) } else { Err("no block reference is being edited (REFEDIT)".into()) }
|
||||
}
|
||||
|
||||
fn active(s: &Session, cmd: &str) -> Result<RefEdit> {
|
||||
s.doc()?.refedit.clone().ok_or_else(|| bad(cmd, "no block reference is being edited (REFEDIT)"))
|
||||
}
|
||||
|
||||
/// A block object as the drawing shows it through the edited reference.
|
||||
fn to_host(to_world: &Mat3, e: &Entity, h: Handle, ghost: bool) -> Entity {
|
||||
let mut c = e.clone();
|
||||
c.handle = h;
|
||||
c.kind.transform(to_world);
|
||||
if ghost {
|
||||
c.common.transparency = Transparency::Percent(GHOST_FADE);
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
/// A drawing object as it belongs in the block, with the block object's handle `h`.
|
||||
fn to_block(inv: &Mat3, e: &Entity, h: Handle) -> Entity {
|
||||
let mut c = e.clone();
|
||||
c.handle = h;
|
||||
c.kind.transform(inv);
|
||||
c
|
||||
}
|
||||
|
||||
fn put(d: &mut Drawing, r: &RefEdit, e: Entity) -> Result<()> {
|
||||
d.ensure_layer(&e.common.layer);
|
||||
d.space_mut(&r.space).ok_or_else(|| bad("refedit", "the space of the reference is gone"))?.push(e);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn hexes(hs: impl IntoIterator<Item = Handle>) -> Vec<String> {
|
||||
hs.into_iter().map(|h| h.hex()).collect()
|
||||
}
|
||||
|
||||
// ---------------- REFEDIT ----------------
|
||||
|
||||
fn run_refedit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if s.doc()?.refedit.is_some() {
|
||||
return Err(bad("refedit", "a reference is already being edited; finish with REFCLOSE"));
|
||||
}
|
||||
let d = s.doc()?;
|
||||
let picked = match str_param(p, "name") {
|
||||
Some(n) => d
|
||||
.model
|
||||
.iter()
|
||||
.chain(d.layouts.iter().flat_map(|l| l.entities.iter()))
|
||||
.find(|e| matches!(&e.kind, cadcraft_doc::EntityKind::Insert(i) if i.block.eq_ignore_ascii_case(n)))
|
||||
.map(|e| e.handle),
|
||||
None => targets(s, p)?.into_iter().find(|h| d.entity(*h).is_some_and(|e| matches!(e.kind, cadcraft_doc::EntityKind::Insert(_)))),
|
||||
};
|
||||
let ih = picked.ok_or_else(|| bad("refedit", "select a block reference"))?;
|
||||
let (ins, space) = match d.entity(ih).map(|e| &e.kind) {
|
||||
Some(cadcraft_doc::EntityKind::Insert(i)) => (i.clone(), d.space_of(ih).ok_or_else(|| bad("refedit", "the reference is in no space"))?),
|
||||
_ => return Err(bad("refedit", "select a block reference")),
|
||||
};
|
||||
let blk = d.block(&ins.block).cloned().ok_or_else(|| bad("refedit", format!("block \"{}\" not found", ins.block)))?;
|
||||
if blk.xref_path.is_some() {
|
||||
return Err(bad(
|
||||
"refedit",
|
||||
format!(
|
||||
"\"{}\" is an external reference: editing it in place would write its source drawing, which is not available yet. Open it with XOPEN, edit and save it, then reload.",
|
||||
blk.name
|
||||
),
|
||||
));
|
||||
}
|
||||
if blk.name.starts_with('*') || blk.array.is_some() {
|
||||
return Err(bad("refedit", format!("\"{}\" is generated geometry and can't be edited in place", blk.name)));
|
||||
}
|
||||
let to_world = ins.transform(blk.base.xy());
|
||||
let (sx, sy) = (ins.scale.x.abs(), ins.scale.y.abs());
|
||||
let finite = [to_world.a, to_world.b, to_world.c, to_world.d, to_world.e, to_world.f].iter().all(|v| v.is_finite());
|
||||
if !finite || to_world.det().abs() < 1e-12 || (sx - sy).abs() > 1e-9 * sx.max(sy) {
|
||||
return Err(bad("refedit", "the reference must be scaled equally in X and Y (and not flat) to be edited in place"));
|
||||
}
|
||||
let chosen: Option<HashSet<Handle>> = match p.get("objects") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::String(a)) if a.eq_ignore_ascii_case("all") => None,
|
||||
Some(Value::Array(a)) => {
|
||||
let mut set = HashSet::new();
|
||||
for v in a {
|
||||
let h = handle_value(v).ok_or_else(|| bad("refedit", format!("`objects`: {v} is not an object handle")))?;
|
||||
if blk.entities.get(h).is_none() {
|
||||
return Err(bad("refedit", format!("object {} is not in block \"{}\"", h.hex(), blk.name)));
|
||||
}
|
||||
set.insert(h);
|
||||
}
|
||||
if set.is_empty() {
|
||||
return Err(bad("refedit", "`objects` is empty"));
|
||||
}
|
||||
Some(set)
|
||||
}
|
||||
Some(v) => return Err(bad("refedit", format!("`objects` must be \"all\" or an array of handles, got {v}"))),
|
||||
};
|
||||
let was_visible = d.entity(ih).is_some_and(|e| e.common.visible);
|
||||
let mut r = RefEdit {
|
||||
block: blk.name.clone(),
|
||||
insert: ih,
|
||||
insert_was_visible: was_visible,
|
||||
space,
|
||||
to_world,
|
||||
items: Vec::new(),
|
||||
ghosts: Vec::new(),
|
||||
released: Vec::new(),
|
||||
};
|
||||
let doc = s.doc_mut()?;
|
||||
for e in blk.entities.iter() {
|
||||
let working = chosen.as_ref().is_none_or(|c| c.contains(&e.handle));
|
||||
let h = doc.new_handle();
|
||||
put(doc, &r, to_host(&to_world, e, h, !working))?;
|
||||
if working {
|
||||
r.items.push(WorkItem { copy: h, source: Some(e.handle) });
|
||||
} else {
|
||||
r.ghosts.push((h, e.handle));
|
||||
}
|
||||
}
|
||||
doc.modify_entity(ih, |e| e.common.visible = false).map_err(|e| bad("refedit", e.to_string()))?;
|
||||
let out = json!({ "block": r.block, "insert": ih.hex(), "working": hexes(r.items.iter().map(|i| i.copy)), "faded": r.ghosts.len() });
|
||||
let sel: Vec<Handle> = r.items.iter().map(|i| i.copy).collect();
|
||||
doc.refedit = Some(r);
|
||||
s.set_selection(sel);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
// ---------------- REFSET ----------------
|
||||
|
||||
fn handle_list(p: &Value, key: &str) -> Result<Vec<Handle>> {
|
||||
match p.get(key) {
|
||||
None | Some(Value::Null) => Ok(Vec::new()),
|
||||
Some(Value::Array(a)) => {
|
||||
a.iter().map(|v| handle_value(v).ok_or_else(|| bad("refset", format!("`{key}`: {v} is not an object handle")))).collect()
|
||||
}
|
||||
Some(v) => Err(bad("refset", format!("`{key}` must be an array of object handles, got {v}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn run_refset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (mut add, mut remove) = (handle_list(p, "add")?, handle_list(p, "remove")?);
|
||||
let hs = handle_list(p, "handles")?;
|
||||
if str_param(p, "mode").is_some_and(|m| m.to_ascii_lowercase().starts_with('r')) {
|
||||
remove.extend(hs)
|
||||
} else {
|
||||
add.extend(hs)
|
||||
}
|
||||
apply_refset(s, &add, &remove)
|
||||
}
|
||||
|
||||
fn apply_refset(s: &mut Session, add: &[Handle], remove: &[Handle]) -> Result<Value> {
|
||||
let mut r = active(s, "refset")?;
|
||||
let inv = r.to_world.inverse().ok_or_else(|| bad("refset", "the reference can't be inverted"))?;
|
||||
let d = s.doc()?;
|
||||
for h in add.iter().chain(remove) {
|
||||
if d.entity(*h).is_none() {
|
||||
return Err(bad("refset", format!("no object {}", h.hex())));
|
||||
}
|
||||
}
|
||||
if let Some(h) = remove.iter().find(|h| !r.is_working(**h)) {
|
||||
return Err(bad("refset", format!("object {} is not in the working set", h.hex())));
|
||||
}
|
||||
let blk = d.block(&r.block).cloned().ok_or_else(|| bad("refset", format!("block \"{}\" is gone; REFCLOSE Discard", r.block)))?;
|
||||
let (mut added, mut removed) = (0usize, 0usize);
|
||||
let doc = s.doc_mut()?;
|
||||
for h in add {
|
||||
if *h == r.insert || r.is_working(*h) {
|
||||
continue;
|
||||
}
|
||||
if let Some(pos) = r.ghosts.iter().position(|(g, _)| g == h) {
|
||||
// A faded block object joins the working set (with the edits it had before).
|
||||
let (g, src) = r.ghosts.remove(pos);
|
||||
doc.remove_entity(g);
|
||||
let cur = match r.released.iter().position(|e| e.handle == src) {
|
||||
Some(i) => Some(r.released.remove(i)),
|
||||
None => blk.entities.get(src).map(|e| (**e).clone()),
|
||||
};
|
||||
if let Some(cur) = cur {
|
||||
let c = doc.new_handle();
|
||||
put(doc, &r, to_host(&r.to_world, &cur, c, false))?;
|
||||
r.items.push(WorkItem { copy: c, source: Some(src) });
|
||||
added += 1;
|
||||
}
|
||||
} else {
|
||||
r.items.push(WorkItem { copy: *h, source: None });
|
||||
added += 1;
|
||||
}
|
||||
}
|
||||
for h in remove {
|
||||
let Some(pos) = r.items.iter().position(|i| i.copy == *h) else { continue };
|
||||
let item = r.items.remove(pos);
|
||||
if let Some(src) = item.source
|
||||
&& let Some(e) = doc.entity(*h).map(|e| (**e).clone())
|
||||
{
|
||||
// Back to the block, edits kept, shown faded.
|
||||
let kept = to_block(&inv, &e, src);
|
||||
doc.remove_entity(*h);
|
||||
let g = doc.new_handle();
|
||||
put(doc, &r, to_host(&r.to_world, &kept, g, true))?;
|
||||
r.ghosts.push((g, src));
|
||||
r.released.push(kept);
|
||||
}
|
||||
removed += 1;
|
||||
}
|
||||
let working = hexes(r.items.iter().map(|i| i.copy));
|
||||
doc.refedit = Some(r);
|
||||
Ok(json!({ "working": working, "added": added, "removed": removed }))
|
||||
}
|
||||
|
||||
// ---------------- REFCLOSE ----------------
|
||||
|
||||
fn run_refclose(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let save = match str_param(p, "option") {
|
||||
Some(o) if o.to_ascii_lowercase().starts_with('d') => false,
|
||||
Some(o) if o.to_ascii_lowercase().starts_with('s') => true,
|
||||
Some(o) => return Err(bad("refclose", format!("unknown option \"{o}\" (save or discard)"))),
|
||||
None => bool_or(p, "save", true),
|
||||
};
|
||||
close(s, save)
|
||||
}
|
||||
|
||||
fn close(s: &mut Session, save: bool) -> Result<Value> {
|
||||
let r = active(s, "refclose")?;
|
||||
// Work on a copy: the drawing changes only when everything went through.
|
||||
let mut nd: Drawing = s.doc()?.clone();
|
||||
let mut objects = 0;
|
||||
if save {
|
||||
objects = commit_block(&mut nd, &r)?;
|
||||
}
|
||||
nd.end_refedit_discard(&r);
|
||||
*s.doc_mut()? = nd;
|
||||
s.set_selection(Vec::new());
|
||||
Ok(json!({ "block": r.block, "saved": save, "objects": objects }))
|
||||
}
|
||||
|
||||
/// Replace the block's objects by the working set (and move the drawing objects added to it
|
||||
/// into the block). Returns the number of objects the block has now.
|
||||
fn commit_block(nd: &mut Drawing, r: &RefEdit) -> Result<usize> {
|
||||
let blk = nd.block(&r.block).cloned().ok_or_else(|| bad("refclose", format!("block \"{}\" is gone; use REFCLOSE Discard", r.block)))?;
|
||||
let inv = r.to_world.inverse().ok_or_else(|| bad("refclose", "the reference can't be inverted; use REFCLOSE Discard"))?;
|
||||
let mut ents: Vec<Entity> = Vec::new();
|
||||
for e in blk.entities.iter() {
|
||||
match r.items.iter().find(|i| i.source == Some(e.handle)) {
|
||||
// A working object the user erased leaves the block.
|
||||
Some(i) => {
|
||||
if let Some(c) = nd.entity(i.copy) {
|
||||
ents.push(to_block(&inv, c, e.handle));
|
||||
}
|
||||
}
|
||||
None => match r.released.iter().find(|x| x.handle == e.handle) {
|
||||
Some(x) => ents.push(x.clone()),
|
||||
None => ents.push((**e).clone()),
|
||||
},
|
||||
}
|
||||
}
|
||||
let mut moved = Vec::new();
|
||||
for i in r.items.iter().filter(|i| i.source.is_none()) {
|
||||
if let Some(c) = nd.entity(i.copy).map(|c| (**c).clone()) {
|
||||
let h = nd.new_handle();
|
||||
ents.push(to_block(&inv, &c, h));
|
||||
moved.push(i.copy);
|
||||
}
|
||||
}
|
||||
if references_block(nd, &ents, &r.block) {
|
||||
return Err(bad("refclose", format!("Block \"{}\" would reference itself; remove that reference or use REFCLOSE Discard.", r.block)));
|
||||
}
|
||||
let n = ents.len();
|
||||
let mut store = EntityStore::new();
|
||||
for e in ents {
|
||||
store.push(e);
|
||||
}
|
||||
let key = nd.blocks.keys().find(|k| k.eq_ignore_ascii_case(&r.block)).cloned().unwrap_or_else(|| r.block.clone());
|
||||
let mut nb = (*blk).clone();
|
||||
nb.entities = store;
|
||||
nd.blocks.insert(key, Arc::new(nb));
|
||||
for h in moved {
|
||||
nd.remove_entity(h);
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
// ---------------- while editing ----------------
|
||||
|
||||
/// After a command ran: objects it created join the working set, and a command that changed
|
||||
/// anything outside the working set is undone (returns the reason).
|
||||
pub(crate) fn after_command(s: &mut Session, before: &Arc<Drawing>, id: &str) -> Option<String> {
|
||||
if matches!(id, "refedit" | "refset" | "refclose") || !crate::cmd::find_command(id).is_some_and(|c| c.undoable) {
|
||||
return None;
|
||||
}
|
||||
let rb = before.refedit.as_ref()?;
|
||||
let st = s.state_mut().ok()?;
|
||||
if Arc::ptr_eq(before, &st.doc) {
|
||||
return None;
|
||||
}
|
||||
let ra = st.doc.refedit.as_ref().filter(|ra| ra.insert == rb.insert)?;
|
||||
let owned: HashSet<Handle> = rb.items.iter().map(|i| i.copy).chain(rb.ghosts.iter().map(|g| g.0)).chain([rb.insert]).collect();
|
||||
let stores = |d: &Drawing| std::iter::once(&d.model).chain(d.layouts.iter().map(|l| &l.entities)).cloned().collect::<Vec<_>>();
|
||||
let touched = stores(before)
|
||||
.iter()
|
||||
.any(|store| store.iter().any(|e| !owned.contains(&e.handle) && !st.doc.entity(e.handle).is_some_and(|a| Arc::ptr_eq(a, e))));
|
||||
if touched {
|
||||
st.doc = before.clone();
|
||||
return Some("Only the working set can be edited during REFEDIT (REFSET adds objects, REFCLOSE ends the edit).".into());
|
||||
}
|
||||
let seed = before.handseed;
|
||||
let mut new: Vec<Handle> = Vec::new();
|
||||
for store in stores(&st.doc) {
|
||||
new.extend(store.iter().map(|e| e.handle).filter(|h| h.0 >= seed && !ra.owns(*h)));
|
||||
}
|
||||
if !new.is_empty()
|
||||
&& let Some(r) = Arc::make_mut(&mut st.doc).refedit.as_mut()
|
||||
{
|
||||
r.items.extend(new.into_iter().map(|copy| WorkItem { copy, source: None }));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Whether `h` may not be edited because a reference is being edited and it is not in the working set.
|
||||
pub(crate) fn locked(d: &Drawing, h: Handle) -> bool {
|
||||
d.refedit.as_ref().is_some_and(|r| !r.is_working(h))
|
||||
}
|
||||
|
||||
// ---------------- interactive ----------------
|
||||
|
||||
#[derive(Default)]
|
||||
enum RStage {
|
||||
#[default]
|
||||
Mode,
|
||||
Pick(bool),
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct RefsetM {
|
||||
stage: RStage,
|
||||
sel: SelectPhase,
|
||||
}
|
||||
|
||||
impl Interactive for RefsetM {
|
||||
fn name(&self) -> &'static str {
|
||||
"REFSET"
|
||||
}
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
active(s, "refset").map(|_| Step::Continue)
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
match self.stage {
|
||||
RStage::Mode => {
|
||||
let mut p = Prompt::new("Enter an option [Add/Remove] <Add>", Accept::TEXT);
|
||||
p.keywords = vec!["Add".into(), "Remove".into()];
|
||||
p.default = Some("Add".into());
|
||||
p
|
||||
}
|
||||
RStage::Pick(remove) => {
|
||||
let mut p = self.sel.prompt();
|
||||
p.message = format!("Select objects to {}", if remove { "remove" } else { "add" });
|
||||
p
|
||||
}
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
match self.stage {
|
||||
RStage::Mode => {
|
||||
let remove = match i {
|
||||
Input::Keyword(k) | Input::Text(k) => k.to_ascii_lowercase().starts_with('r'),
|
||||
Input::Enter => false,
|
||||
_ => return Ok(Step::Cancel),
|
||||
};
|
||||
self.sel = SelectPhase::begin(s);
|
||||
self.stage = RStage::Pick(remove);
|
||||
if self.sel.done {
|
||||
let hs = self.sel.picked.clone();
|
||||
return self.finish(s, remove, &hs);
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
RStage::Pick(remove) => match self.sel.feed(s, &i)? {
|
||||
SelOutcome::More => Ok(Step::Continue),
|
||||
SelOutcome::Empty => Ok(Step::Done),
|
||||
SelOutcome::Done(hs) => self.finish(s, remove, &hs),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RefsetM {
|
||||
fn finish(&mut self, s: &mut Session, remove: bool, hs: &[Handle]) -> Result<Step> {
|
||||
let r = if remove { apply_refset(s, &[], hs) } else { apply_refset(s, hs, &[]) }?;
|
||||
s.echo(format!("{} object(s) in the working set.", r.get("working").and_then(Value::as_array).map_or(0, Vec::len)));
|
||||
s.set_selection(Vec::new());
|
||||
Ok(Step::Done)
|
||||
}
|
||||
}
|
||||
|
||||
struct RefcloseM;
|
||||
|
||||
impl Interactive for RefcloseM {
|
||||
fn name(&self) -> &'static str {
|
||||
"REFCLOSE"
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
let mut p = Prompt::new("Enter option [Save/Discard reference changes] <Save>", Accept::TEXT);
|
||||
p.keywords = vec!["Save".into(), "Discard".into()];
|
||||
p.default = Some("Save".into());
|
||||
p
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
let save = match i {
|
||||
Input::Keyword(k) | Input::Text(k) => !k.to_ascii_lowercase().starts_with('d'),
|
||||
Input::Enter => true,
|
||||
_ => return Ok(Step::Cancel),
|
||||
};
|
||||
let r = close(s, save)?;
|
||||
s.echo(if save { "Reference changes saved." } else { "Reference changes discarded." });
|
||||
let _ = r;
|
||||
Ok(Step::Done)
|
||||
}
|
||||
}
|
||||
@@ -36,9 +36,9 @@ pub fn handle_param(p: &serde_json::Value, key: &str) -> Option<Handle> {
|
||||
p.get(key).and_then(handle_value)
|
||||
}
|
||||
|
||||
/// Is the entity on a locked layer?
|
||||
/// Is the entity on a locked layer, or outside the working set while a block reference is edited in place (REFEDIT)?
|
||||
pub fn is_locked(s: &Session, h: Handle) -> bool {
|
||||
s.doc().ok().is_some_and(|d| d.entity(h).and_then(|e| d.layer(&e.common.layer)).is_some_and(|l| l.locked))
|
||||
s.doc().ok().is_some_and(|d| d.entity(h).and_then(|e| d.layer(&e.common.layer)).is_some_and(|l| l.locked) || super::blocks::refedit::locked(d, h))
|
||||
}
|
||||
|
||||
// ---------------- arc constructions ----------------
|
||||
|
||||
@@ -271,7 +271,7 @@ fn run_spline(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
Spline::fit_with(&f, bool_or(p, "closed", false), super::spline_opts::json_options(s, p)?)
|
||||
} else if let Some(c) = points_param(p, "control") {
|
||||
if c.len() < 2 {
|
||||
if c.len() < 2 || c.len() > super::curves::MAX_GEN {
|
||||
return Err(bad("spline", "need 2+ control points"));
|
||||
}
|
||||
// Degree 1..=10 as at the Degree prompt, `spline.cv` and DXF input (tessellation grows
|
||||
@@ -378,7 +378,7 @@ fn run_mtext(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
attach,
|
||||
rotation: f64_or(p, "rotation", 0.0).to_radians(),
|
||||
style,
|
||||
contents: super::mtext_opts::json_paragraphs(p, text.replace('\n', "\\P"))?,
|
||||
contents: super::mtext_opts::json_paragraphs(p, text.replace('\n', "\\P"), height)?,
|
||||
line_spacing: f64_or(p, "lineSpacing", 1.0),
|
||||
line_spacing_exact: bool_or(p, "lineSpacingExact", false),
|
||||
columns: super::mtext_opts::json_columns(p, height, width)?,
|
||||
@@ -428,7 +428,21 @@ impl Interactive for LineM {
|
||||
&& let Some(first) = self.first
|
||||
{
|
||||
match i {
|
||||
Input::Point(p) => return self.resolve_first(s, first, snap::LineEnd::Point(p)),
|
||||
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)),
|
||||
_ => {}
|
||||
}
|
||||
@@ -780,6 +794,9 @@ impl Interactive for CircleM {
|
||||
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 => {
|
||||
@@ -895,6 +912,9 @@ impl Interactive for PolygonM {
|
||||
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))?;
|
||||
@@ -917,11 +937,17 @@ impl Interactive for PolygonM {
|
||||
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);
|
||||
@@ -1412,11 +1438,22 @@ struct TextM {
|
||||
justify: (HAlign, VAlign),
|
||||
asking_justify: bool,
|
||||
line: usize,
|
||||
/// Run by a LISP routine (`command`): one line of text ends the command, as AutoLISP's
|
||||
/// `command` runs TEXT; typed and in scripts each line asks for the next.
|
||||
single_line: bool,
|
||||
}
|
||||
|
||||
impl TextM {
|
||||
fn new(_s: &Session) -> Self {
|
||||
TextM { at: None, height: None, rotation: None, justify: (HAlign::Left, VAlign::Baseline), asking_justify: false, line: 0 }
|
||||
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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1477,6 +1514,9 @@ impl Interactive for TextM {
|
||||
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),
|
||||
_ => {}
|
||||
@@ -1562,6 +1602,10 @@ impl Interactive for MTextM {
|
||||
}
|
||||
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) => {
|
||||
|
||||
@@ -43,12 +43,17 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("preview", "Plot Preview", run_preview)
|
||||
.menu(&["File", "Plot Preview"])
|
||||
.alias(&["pre"])
|
||||
.params("{layout?: current|\"Model\", paper?, landscape?, fit?, scale?, lineweights?, width?: pixels (default 1200)} → {data: base64 PNG, width, height, layout, paper}")
|
||||
.params("{layout?: current|\"Model\", paper?, landscape?, fit?, scale?, lineweights?, plotTransparency?, width?: pixels (default 1200)} → {data: base64 PNG, width, height, layout, paper}")
|
||||
.noundo(),
|
||||
CommandSpec::new("publish", "Batch Publish...", run_publish)
|
||||
.menu(&["File", "Batch Publish..."])
|
||||
.params("{path?: \"NAME.pdf\" (writes NAME-<sheet>.pdf per sheet; else returns base64 `data` per sheet), layouts?: [name | \"Model\"] (default: Model if it has objects, then every layout in tab order)} → {sheets: [{layout, bytes, path? | data}]}")
|
||||
.noundo(),
|
||||
CommandSpec::new("pdfbook", "Sheet Books to PDF...", run_pdfbook)
|
||||
.menu(&["File", "Sheet Books to PDF..."])
|
||||
.alias(&["printpdf", "pdfb"])
|
||||
.params("{path?: \"DIR/NAME^NAME2.pdf\" (default: the drawing's file; a `^` splits the name into one book name per row of frames; else returns base64 `data` per book), names?: [book names], frameHeight?: mm (default 297), unit?: mm per drawing unit (default 1)} → {books: [{name, pages, bytes, path? | data}], skipped}: closed polylines of the frame height in model space are sheet frames; each row (same lower edge, bottom to top) is one PDF, its frames left to right one page each on the paper (ISO A-series, either orientation) they match")
|
||||
.noundo(),
|
||||
CommandSpec::new("attach", "Attach", |_, _| {
|
||||
Err(EngineError::Other("ATTACH is not available yet: raster images and PDF/DWF/DGN underlays can't be shown yet".into()))
|
||||
})
|
||||
@@ -287,9 +292,9 @@ fn plot_scale(b: &Bounds2, sheet: &Sheet, fit: bool, scale: Option<f64>) -> f64
|
||||
fn run_preview(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let space = plot_space("preview", s, p)?;
|
||||
let d = s.doc()?;
|
||||
let mut page = match &space {
|
||||
Space::Paper(n) => d.layout(n).map(|l| l.page.clone()).unwrap_or_default(),
|
||||
Space::Model => {
|
||||
let mut page = match space.layout() {
|
||||
Some(n) => d.layout(n).map(|l| l.page.clone()).unwrap_or_default(),
|
||||
None => {
|
||||
// Model space plots on A4 (metric) or ANSI A, like the PDF plotter.
|
||||
let mut page = PageSetup::default();
|
||||
if paper::paper_unit_mm(d) == 1.0
|
||||
@@ -312,10 +317,8 @@ fn run_preview(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
page.landscape = l;
|
||||
}
|
||||
let sheet = Sheet::from_page(&page, paper::paper_unit_mm(d));
|
||||
let lineweights = p.get("lineweights").and_then(Value::as_bool).unwrap_or(match space {
|
||||
Space::Paper(_) => page.lineweights,
|
||||
Space::Model => true,
|
||||
});
|
||||
let lineweights = p.get("lineweights").and_then(Value::as_bool).unwrap_or(if space.is_paper() { page.lineweights } else { true });
|
||||
let transparency = super::plotters::plot_transparency(s, p).unwrap_or(page.plot_transparency);
|
||||
let fit = p.get("fit").and_then(Value::as_bool).unwrap_or(matches!(space, Space::Model));
|
||||
let scale = p.get("scale").and_then(Value::as_f64).filter(|v| v.is_finite() && *v > 0.0);
|
||||
let width = p.get("width").and_then(Value::as_u64).unwrap_or(PREVIEW_PX).clamp(16, MAX_PREVIEW_PX);
|
||||
@@ -331,6 +334,7 @@ fn run_preview(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
text: true,
|
||||
fill: true,
|
||||
lineweights,
|
||||
transparency,
|
||||
..Default::default()
|
||||
};
|
||||
let list = cadcraft_render::build_plot(d, &space, &ropts);
|
||||
@@ -355,10 +359,7 @@ fn run_preview(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
antialias: true,
|
||||
};
|
||||
let png = cadcraft_render::raster::render_png(&list, &view, &ro).ok_or_else(|| bad("preview", "rendering the preview failed"))?;
|
||||
let layout = match &space {
|
||||
Space::Model => "Model".to_string(),
|
||||
Space::Paper(n) => n.clone(),
|
||||
};
|
||||
let layout = space.tab_name();
|
||||
Ok(json!({ "data": base64_encode(&png), "width": w, "height": h, "layout": layout, "paper": page.paper, "landscape": page.landscape }))
|
||||
}
|
||||
|
||||
@@ -421,6 +422,122 @@ fn run_publish(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "sheets": out, "message": message }))
|
||||
}
|
||||
|
||||
// ---------- PDFBOOK ----------
|
||||
|
||||
/// Frame sizes match a paper to this many millimetres.
|
||||
const FRAME_TOL_MM: f64 = 0.5;
|
||||
/// Most frames one PDFBOOK looks at.
|
||||
const MAX_FRAMES: usize = 100_000;
|
||||
|
||||
/// A sheet frame found in model space.
|
||||
struct Frame {
|
||||
window: Bounds2,
|
||||
paper: &'static str,
|
||||
landscape: bool,
|
||||
}
|
||||
|
||||
/// The ISO/ANSI paper a frame of `w` x `h` mm fits (either orientation), and whether it is landscape.
|
||||
fn paper_for(w: f64, h: f64) -> Option<(&'static str, bool)> {
|
||||
cadcraft_render::PAPER_SIZES.iter().find_map(|p| {
|
||||
let (pw, ph) = (p.width_mm, p.height_mm);
|
||||
if (pw - w).abs() <= FRAME_TOL_MM && (ph - h).abs() <= FRAME_TOL_MM {
|
||||
Some((p.name, false))
|
||||
} else if (ph - w).abs() <= FRAME_TOL_MM && (pw - h).abs() <= FRAME_TOL_MM {
|
||||
Some((p.name, true))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Sheet frames grouped into books: rows of equal lower edge, bottom to top, each left to right.
|
||||
fn frame_books(d: &Drawing, frame_height: f64, unit: f64) -> (Vec<Vec<Frame>>, usize) {
|
||||
let mut found: Vec<(i64, f64, Frame)> = Vec::new();
|
||||
let mut skipped = 0;
|
||||
for e in d.model.iter().take(MAX_FRAMES) {
|
||||
let EntityKind::LwPolyline(pl) = &e.kind else { continue };
|
||||
if !pl.closed {
|
||||
continue;
|
||||
}
|
||||
let b = cadcraft_doc::entity_bounds(d, e, 0);
|
||||
if b.is_empty() || !b.width().is_finite() || !b.height().is_finite() {
|
||||
continue;
|
||||
}
|
||||
let (w, h) = (b.width() * unit, b.height() * unit);
|
||||
if (h - frame_height).abs() > 0.0005 {
|
||||
continue;
|
||||
}
|
||||
match paper_for(w, h) {
|
||||
Some((paper, landscape)) => {
|
||||
let row = (b.min.y * 1000.0).round();
|
||||
if row.is_finite() && row.abs() < 9e15 {
|
||||
found.push((row as i64, b.min.x, Frame { window: b, paper, landscape }));
|
||||
}
|
||||
}
|
||||
None => skipped += 1,
|
||||
}
|
||||
}
|
||||
found.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.total_cmp(&b.1)));
|
||||
let mut books: Vec<Vec<Frame>> = Vec::new();
|
||||
let mut last = None;
|
||||
for (row, _, f) in found {
|
||||
if last != Some(row) {
|
||||
books.push(Vec::new());
|
||||
last = Some(row);
|
||||
}
|
||||
if let Some(b) = books.last_mut() {
|
||||
b.push(f);
|
||||
}
|
||||
}
|
||||
(books, skipped)
|
||||
}
|
||||
|
||||
fn run_pdfbook(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if io().and_then(|h| h.plot).is_none() {
|
||||
return Err(bad("pdfbook", "plotting is not available in this build"));
|
||||
}
|
||||
let num = |k: &str, default: f64| p.get(k).and_then(Value::as_f64).filter(|v| v.is_finite() && *v > 0.0).unwrap_or(default);
|
||||
let (frame_height, unit) = (num("frameHeight", 297.0), num("unit", 1.0));
|
||||
let (books, skipped) = frame_books(s.doc()?, frame_height, unit);
|
||||
if books.is_empty() {
|
||||
return Err(bad("pdfbook", format!("no closed polylines {frame_height} mm high (sheet frames) in model space")));
|
||||
}
|
||||
let target = str_param(p, "path").map(str::to_string).or_else(|| s.state().ok().and_then(|st| st.path.clone()));
|
||||
let pb = target.as_deref().map(std::path::Path::new);
|
||||
let dir = pb.and_then(std::path::Path::parent).map(std::path::Path::to_path_buf).unwrap_or_default();
|
||||
let stem = pb
|
||||
.and_then(std::path::Path::file_stem)
|
||||
.map(|x| x.to_string_lossy().to_string())
|
||||
.filter(|x| !x.is_empty())
|
||||
.unwrap_or_else(|| "Drawing".into());
|
||||
let names: Vec<String> = match p.get("names").and_then(Value::as_array) {
|
||||
Some(a) => a.iter().filter_map(|n| n.as_str().map(str::to_string)).collect(),
|
||||
None => stem.split('^').map(str::to_string).collect(),
|
||||
};
|
||||
let write = pb.is_some();
|
||||
let mut out = Vec::new();
|
||||
for (i, frames) in books.iter().enumerate() {
|
||||
// A row without its own name is numbered after the drawing.
|
||||
let name = names.get(i).map(|n| file_part(n)).filter(|n| !n.is_empty()).unwrap_or_else(|| format!("{}-{}", file_part(&stem), i + 1));
|
||||
let pages: Vec<Value> = frames
|
||||
.iter()
|
||||
.map(|f| json!({ "window": [[f.window.min.x, f.window.min.y], [f.window.max.x, f.window.max.y]], "paper": f.paper, "landscape": f.landscape }))
|
||||
.collect();
|
||||
let mut q = json!({ "layout": "Model", "pages": pages, "margins": [0, 0, 0, 0] });
|
||||
if write {
|
||||
q["path"] = json!(dir.join(format!("{name}.pdf")).to_string_lossy());
|
||||
}
|
||||
let mut r = s.execute("plot", &q).map_err(|e| bad("pdfbook", format!("{name}: {e}")))?;
|
||||
if let Some(o) = r.as_object_mut() {
|
||||
o.insert("name".into(), json!(name));
|
||||
o.insert("pages".into(), json!(frames.len()));
|
||||
}
|
||||
out.push(r);
|
||||
}
|
||||
let message = format!("Plotted {} book(s), {} sheet(s)", out.len(), books.iter().map(Vec::len).sum::<usize>());
|
||||
Ok(json!({ "books": out, "skipped": skipped, "message": message }))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use cadcraft_doc::{Block, Circle, Common, Handle, Insert, Layer, Line};
|
||||
@@ -587,6 +704,39 @@ mod tests {
|
||||
assert_ne!(png(&mut s, json!({ "layout": "Layout1", "width": 200, "fit": false, "scale": 0.5 })), default, "scale 0.5 is not 1:1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdfbook_collects_frames_into_one_pdf_per_row() {
|
||||
our_hooks();
|
||||
if io().and_then(|h| h.plot).is_none() {
|
||||
return;
|
||||
}
|
||||
let mut s = Session::new();
|
||||
// Row 1 (y = 0): A4, A3 landscape (drawn right to left); row 2 (y = 400): one A4; a stray rectangle.
|
||||
for c in ["pline 210,0 420,0 420,297 210,297 c", "pline 0,0 210,0 210,297 0,297 c", "pline 420,0 840,0 840,297 420,297 c"] {
|
||||
s.cmdline(c).unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
}
|
||||
s.cmdline("pline 0,400 210,400 210,697 0,697 c").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
s.cmdline("pline 0,900 50,900 50,950 0,950 c").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
let r = s.execute("pdfbook", &json!({ "path": temp("a^b.pdf").to_string_lossy() }));
|
||||
let r = r.unwrap();
|
||||
let books = r["books"].as_array().unwrap();
|
||||
assert_eq!(books.len(), 2);
|
||||
assert_eq!(books[0]["pages"], 3);
|
||||
assert_eq!(books[1]["pages"], 1);
|
||||
let first = temp("a.pdf");
|
||||
let text = String::from_utf8(std::fs::read(&first).unwrap()).unwrap();
|
||||
let (a4, a3) = (text.find("ISO A4").unwrap(), text.find("ISO A3").unwrap());
|
||||
assert!(a4 < a3, "frames go left to right: {text}");
|
||||
assert!(text.contains("\"landscape\":true"));
|
||||
let _ = std::fs::remove_file(first);
|
||||
let _ = std::fs::remove_file(temp("b.pdf"));
|
||||
// No frames: an error, not a panic.
|
||||
assert!(Session::new().execute("pdfbook", &json!({})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publish_plots_every_sheet() {
|
||||
our_hooks();
|
||||
|
||||
+122
-17
@@ -37,6 +37,24 @@ pub fn io() -> Option<&'static IoHooks> {
|
||||
IO.get()
|
||||
}
|
||||
|
||||
/// An entity as DXF groups and back, for AutoLISP's `entget`, `entmake` and `entmod`
|
||||
/// (installed by the app with the DXF reader and writer, a lower layer).
|
||||
pub struct EntityCodec {
|
||||
/// The entity's type name and the groups of its record, without its `0` group.
|
||||
pub groups: fn(&Drawing, &cadcraft_doc::Entity) -> (String, Vec<cadcraft_dxf::Tag>),
|
||||
/// An entity from a type name and groups.
|
||||
pub entity: fn(&str, &[cadcraft_dxf::Tag]) -> Option<(cadcraft_doc::Common, cadcraft_doc::EntityKind)>,
|
||||
}
|
||||
|
||||
static CODEC: OnceLock<EntityCodec> = OnceLock::new();
|
||||
|
||||
pub fn set_entity_codec(c: EntityCodec) {
|
||||
let _ = CODEC.set(c);
|
||||
}
|
||||
pub fn entity_codec() -> Option<&'static EntityCodec> {
|
||||
CODEC.get()
|
||||
}
|
||||
|
||||
/// COMPARE (drawing comparison).
|
||||
pub mod compare;
|
||||
|
||||
@@ -117,11 +135,13 @@ fn write_bytes(s: &Session, cmd: &str, path: &str, opts: &SaveAs) -> Result<Vec<
|
||||
if opts.binary && !is_dxf(path) {
|
||||
return Err(bad(cmd, "binary applies to DXF files only"));
|
||||
}
|
||||
// A drawing file is the drawing outside the Block Editor; an image is what is shown.
|
||||
let d: &cadcraft_doc::Drawing = if is_drawing_file(path) { s.state()?.drawing() } else { s.doc()? };
|
||||
let r = if *opts == SaveAs::default() {
|
||||
(hooks.write)(s.doc()?, path)
|
||||
(hooks.write)(d, path)
|
||||
} else {
|
||||
let w = write_as().ok_or_else(|| bad(cmd, "saving at a chosen version or as binary DXF is not available in this build"))?;
|
||||
w(s.doc()?, path, opts)
|
||||
w(d, path, opts)
|
||||
};
|
||||
r.map_err(|e| bad(cmd, e))
|
||||
}
|
||||
@@ -138,7 +158,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("open", "Open...", run_open)
|
||||
.menu(&["File", "Open..."])
|
||||
.key("Cmd+O")
|
||||
.params("{path} | {data: base64, name}")
|
||||
.params("{path} | {data: base64, name} → {index, entities?, warnings: what reading left out}")
|
||||
.enabled(always)
|
||||
.noundo(),
|
||||
CommandSpec::new("close", "Close", run_close).menu(&["File", "Close"]).key("Cmd+W").params("{index?}").noundo(),
|
||||
@@ -228,18 +248,80 @@ fn file_name(path: &str) -> String {
|
||||
std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string())
|
||||
}
|
||||
|
||||
pub(crate) fn open_bytes(s: &mut Session, bytes: &[u8], name: &str, path: Option<String>) -> Result<usize> {
|
||||
/// A file read and decoded but not yet added to a [`Session`]. Reading is the slow part of OPEN and
|
||||
/// needs no session, so a host can do it on a worker thread and hand the result to
|
||||
/// [`finish_open`] on the thread that owns the session.
|
||||
pub struct ReadFile {
|
||||
drawing: Drawing,
|
||||
binary_dxf: bool,
|
||||
name: String,
|
||||
path: Option<String>,
|
||||
}
|
||||
|
||||
/// Read and decode `path` (the slow half of `OPEN {path}`). Needs no session; runs anywhere.
|
||||
pub fn read_file(path: &str) -> std::result::Result<ReadFile, String> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let r = std::panic::catch_unwind(|| {
|
||||
let bytes = std::fs::read(path).map_err(|e| bad("open", format!("{path}: {e}")))?;
|
||||
read_bytes(&bytes, path, Some(path.to_string()))
|
||||
});
|
||||
match r {
|
||||
Ok(r) => r.map_err(|e| e.to_string()),
|
||||
Err(_) => Err(bad("open", format!("{path}: the file could not be read")).to_string()),
|
||||
}
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let _ = path;
|
||||
Err(bad("open", "paths are not available on the web; pass `data`").to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a [`read_file`] result to the session as the active drawing: the fast half of OPEN.
|
||||
pub fn finish_open(s: &mut Session, f: ReadFile) -> Result<Value> {
|
||||
let ReadFile { drawing, binary_dxf, name, path } = f;
|
||||
let i = add_read(s, drawing, binary_dxf, &name, path);
|
||||
super::blocks::xref::after_open(s, i);
|
||||
Ok(opened(s, i, true))
|
||||
}
|
||||
|
||||
fn read_bytes(bytes: &[u8], name: &str, path: Option<String>) -> Result<ReadFile> {
|
||||
let hooks = io().ok_or_else(|| bad("open", "file formats are not available in this build"))?;
|
||||
let d = (hooks.read)(bytes, name).map_err(|e| bad("open", e))?;
|
||||
let drawing = (hooks.read)(bytes, name).map_err(|e| bad("open", e))?;
|
||||
// A file that isn't a drawing (an imported SVG) gives the drawing no file name: QSAVE then
|
||||
// asks where to save instead of exporting over the original.
|
||||
let path = path.filter(|p| is_drawing_file(p));
|
||||
let i = s.open_drawing(d, &file_name(name), path);
|
||||
Ok(ReadFile { drawing, binary_dxf: cadcraft_dxf::is_binary(bytes), name: name.to_string(), path })
|
||||
}
|
||||
|
||||
fn add_read(s: &mut Session, drawing: Drawing, binary_dxf: bool, name: &str, path: Option<String>) -> usize {
|
||||
let notes = drawing.read_notes.clone();
|
||||
let i = s.open_drawing(drawing, &file_name(name), path);
|
||||
if let Some(st) = s.docs.get_mut(i) {
|
||||
// QSAVE keeps a binary DXF binary.
|
||||
st.binary_dxf = cadcraft_dxf::is_binary(bytes);
|
||||
st.binary_dxf = binary_dxf;
|
||||
}
|
||||
for note in notes {
|
||||
s.echo(note);
|
||||
}
|
||||
i
|
||||
}
|
||||
|
||||
pub(crate) fn open_bytes(s: &mut Session, bytes: &[u8], name: &str, path: Option<String>) -> Result<usize> {
|
||||
let ReadFile { drawing, binary_dxf, name, path } = read_bytes(bytes, name, path)?;
|
||||
Ok(add_read(s, drawing, binary_dxf, &name, path))
|
||||
}
|
||||
|
||||
/// OPEN's reply: the drawing's index and what reading it left out.
|
||||
fn opened(s: &Session, i: usize, entities: bool) -> Value {
|
||||
let d = s.docs.get(i).map(|st| &st.doc);
|
||||
let warnings = d.map(|d| d.read_notes.clone()).unwrap_or_default();
|
||||
if entities {
|
||||
json!({ "index": i, "entities": d.map(|d| d.entity_count()), "warnings": warnings })
|
||||
} else {
|
||||
json!({ "index": i, "warnings": warnings })
|
||||
}
|
||||
Ok(i)
|
||||
}
|
||||
|
||||
fn run_open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -251,7 +333,7 @@ fn run_open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let bytes = std::fs::read(path).map_err(|e| bad("open", format!("{path}: {e}")))?;
|
||||
let i = open_bytes(s, &bytes, path, Some(path.to_string()))?;
|
||||
super::blocks::xref::after_open(s, i);
|
||||
return Ok(json!({ "index": i, "entities": s.docs.get(i).map(|d| d.doc.entity_count()) }));
|
||||
return Ok(opened(s, i, true));
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
return Err(bad("open", "paths are not available on the web; pass `data`"));
|
||||
@@ -261,7 +343,7 @@ fn run_open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = str_param(p, "name").unwrap_or("Drawing.dxf");
|
||||
let i = open_bytes(s, &bytes, name, None)?;
|
||||
super::blocks::xref::after_open(s, i);
|
||||
Ok(json!({ "index": i }))
|
||||
Ok(opened(s, i, false))
|
||||
}
|
||||
|
||||
fn run_close(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -296,16 +378,19 @@ pub(crate) fn save_to(s: &mut Session, path: &str) -> Result<usize> {
|
||||
/// Save to `path` with `opts`. A drawing file then keeps the version and binary choice: the
|
||||
/// version goes into the drawing's `$ACADVER`, so later saves (QSAVE) write it again.
|
||||
fn save_as(s: &mut Session, cmd: &str, path: &str, opts: &SaveAs) -> Result<usize> {
|
||||
if s.doc()?.refedit.is_some() {
|
||||
return Err(bad(cmd, "A block reference is being edited in place: finish with REFCLOSE (Save or Discard) before saving."));
|
||||
}
|
||||
let bytes = write_bytes(s, cmd, path, opts)?;
|
||||
let n = commit_save(s, path, &bytes, store)?;
|
||||
if is_drawing_file(path) {
|
||||
let st = s.state_mut()?;
|
||||
st.binary_dxf = opts.binary;
|
||||
if let Some(v) = opts.version
|
||||
&& st.doc.header.get("ACADVER").and_then(cadcraft_doc::HVal::as_str) != Some(v.code())
|
||||
&& st.drawing().header.get("ACADVER").and_then(cadcraft_doc::HVal::as_str) != Some(v.code())
|
||||
{
|
||||
std::sync::Arc::make_mut(&mut st.doc).header.set_str("ACADVER", v.code());
|
||||
st.saved = st.doc.clone();
|
||||
std::sync::Arc::make_mut(st.drawing_arc_mut()).header.set_str("ACADVER", v.code());
|
||||
st.saved = st.drawing().clone();
|
||||
}
|
||||
}
|
||||
Ok(n)
|
||||
@@ -341,7 +426,7 @@ fn commit_save(s: &mut Session, path: &str, bytes: &[u8], store: impl FnOnce(&st
|
||||
return Ok(bytes.len());
|
||||
}
|
||||
let st = s.state_mut()?;
|
||||
st.saved = st.doc.clone();
|
||||
st.saved = st.drawing().clone();
|
||||
st.path = Some(path.to_string());
|
||||
st.title = file_name(path);
|
||||
// A BEgin group still open when the drawing is saved ends there.
|
||||
@@ -400,7 +485,8 @@ fn run_switch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn run_bytes(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let fmt = str_param(p, "format").unwrap_or("dxf");
|
||||
let opts = save_options("document.bytes", p)?;
|
||||
let lost = not_saved(s.doc()?);
|
||||
let version = opts.version.unwrap_or_else(|| drawing_version(s, &format!("drawing.{fmt}")));
|
||||
let lost = not_saved_as(s.doc()?, version);
|
||||
let bytes = write_bytes(s, "document.bytes", &format!("drawing.{fmt}"), &opts)?;
|
||||
let mut r = json!({ "data": base64_encode(&bytes), "bytes": bytes.len() });
|
||||
if drops_unknown(&format!("drawing.{fmt}")) {
|
||||
@@ -411,12 +497,28 @@ fn run_bytes(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
/// Entities a DXF or DWG file doesn't keep: those of object types CADCraft doesn't model
|
||||
/// (3DSOLID, MULTILEADER… read from other programs' files and kept in the drawing, but
|
||||
/// not written), counted by DXF type across model space, layouts and block definitions.
|
||||
/// not written), counted by DXF type across model space, layouts and block definitions. Saved
|
||||
/// at the drawing's own version; see [`not_saved_as`].
|
||||
pub fn not_saved(d: &Drawing) -> std::collections::BTreeMap<String, usize> {
|
||||
let v = d.header.get("ACADVER").and_then(cadcraft_doc::HVal::as_str).and_then(Version::for_acadver).unwrap_or_default();
|
||||
not_saved_as(d, v)
|
||||
}
|
||||
|
||||
/// Solid modeler entities whose ACIS geometry a file of AutoCAD 2013 and later keeps in its data
|
||||
/// store section, which is written back with the drawing (`Foreign::data_store`).
|
||||
const MODELER_TYPES: [&str; 3] = ["3DSOLID", "REGION", "BODY"];
|
||||
|
||||
/// [`not_saved`] for a file saved as `version`. From 2013 on, a 3D solid, region or body the data
|
||||
/// store has a record for keeps its geometry and is not counted.
|
||||
pub fn not_saved_as(d: &Drawing, version: Version) -> std::collections::BTreeMap<String, usize> {
|
||||
let mut out: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
|
||||
let stored = if version >= Version::R2013 { d.foreign.data_store_entities() } else { Default::default() };
|
||||
let stores = std::iter::once(&d.model).chain(d.layouts.iter().map(|l| &l.entities)).chain(d.blocks.values().map(|b| &b.entities));
|
||||
for e in stores.flat_map(|s| s.iter()) {
|
||||
if let cadcraft_doc::EntityKind::Unknown(u) = &e.kind {
|
||||
if MODELER_TYPES.iter().any(|t| u.dxf_type.eq_ignore_ascii_case(t)) && stored.contains(&e.handle) {
|
||||
continue;
|
||||
}
|
||||
let n = out.entry(u.dxf_type.clone()).or_insert(0);
|
||||
*n = n.saturating_add(1);
|
||||
}
|
||||
@@ -457,7 +559,10 @@ fn report_not_saved(s: &mut Session, r: &mut Value, lost: &std::collections::BTr
|
||||
|
||||
/// Save, then report the entities the file couldn't keep (nothing is dropped silently).
|
||||
fn save_reporting(s: &mut Session, p: &Value, run: fn(&mut Session, &Value) -> Result<Value>) -> Result<Value> {
|
||||
let lost = s.doc().map(not_saved).unwrap_or_default();
|
||||
// What the file will hold depends on the version it is saved as (the data store from 2013).
|
||||
let path = str_param(p, "path").map(str::to_string).or_else(|| s.state().ok().and_then(|st| st.path.clone())).unwrap_or_default();
|
||||
let version = save_options("save", p).ok().and_then(|o| o.version).unwrap_or_else(|| drawing_version(s, &path));
|
||||
let lost = s.doc().map(|d| not_saved_as(d, version)).unwrap_or_default();
|
||||
let mut r = run(s, p)?;
|
||||
if r.get("path").and_then(Value::as_str).is_some_and(drops_unknown) {
|
||||
report_not_saved(s, &mut r, &lost);
|
||||
|
||||
@@ -15,15 +15,22 @@ use crate::{Accept, EngineError, Input, Interactive, Picker, Prompt, Result, Ses
|
||||
|
||||
mod assoc;
|
||||
mod edit;
|
||||
mod pick;
|
||||
pub(crate) use assoc::update as refill_associative;
|
||||
pub use pick::{HatchPicks, PickMode};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("hatch", "Hatch...", run_hatch).menu(&["Draw", "Hatch..."]).alias(&["h", "bh", "-hatch"]).params("{points?: [[x,y]] (internal points) | handles?: [hex] (closed objects), pattern?: \"ANSI31\", scale?, angle? (degrees), associative? (HPASSOC), separate? (HPSEPARATE: a hatch per point/object), origin?: [x,y] (HPORIGIN) | originMode?: \"current\"|\"bottomLeft\"|\"bottomRight\"|\"topLeft\"|\"topRight\"|\"center\" (HPORIGINMODE), style?: \"normal\"|\"outer\"|\"ignore\" (HPISLANDDETECTION)} — gaps up to HPGAPTOL close").interactive(|s| Ok(Box::new(HatchM::new(s, false)))),
|
||||
CommandSpec::new("gradient", "Gradient...", run_gradient).menu(&["Draw", "Gradient..."]).alias(&["gd"]).params("{points? | handles?, color1?, color2?, angle?}").interactive(|s| Ok(Box::new(HatchM::new(s, true)))),
|
||||
CommandSpec::new("hatch", "Hatch...", run_hatch).menu(&["Draw", "Hatch..."]).alias(&["h", "bh", "-hatch"]).params("{points?: [[x,y]] (internal points) and/or handles?: [hex] (closed objects), pattern?: \"ANSI31\", scale?, angle? (degrees), associative? (HPASSOC), separate? (HPSEPARATE: a hatch per point/object), origin?: [x,y] (HPORIGIN) | originMode?: \"current\"|\"bottomLeft\"|\"bottomRight\"|\"topLeft\"|\"topRight\"|\"center\" (HPORIGINMODE), style?: \"normal\"|\"outer\"|\"ignore\" (HPISLANDDETECTION)} — gaps up to HPGAPTOL close").interactive(|s| Ok(Box::new(HatchM::new(s, false)))),
|
||||
CommandSpec::new("gradient", "Gradient...", run_gradient).menu(&["Draw", "Gradient..."]).alias(&["gd"]).params("{points? and/or handles?, name?: \"LINEAR\"|\"CYLINDER\"|\"CURVED\"|\"SPHERICAL\"|\"HEMISPHERICAL\" or INV… (colours swapped), color1?, color2?, angle? (degrees), centered? (default true)}").interactive(|s| Ok(Box::new(HatchM::new(s, true)))),
|
||||
CommandSpec::new("boundary", "Boundary...", run_boundary).menu(&["Draw", "Boundary..."]).alias(&["bo", "bpoly"]).params("{points: [[x,y]]}").interactive(|_| Ok(Box::new(BoundaryM { region: false }))),
|
||||
CommandSpec::new("region", "Region", run_region).menu(&["Draw", "Region"]).alias(&["reg"]).params("{handles?}"),
|
||||
CommandSpec::new("hatchedit", "Hatch Edit", edit::run_hatchedit).menu(&["Modify", "Object", "Hatch..."]).alias(&["he", "-hatchedit"]).params("{handles?, pattern?, scale?, angle?, associative?: bool (false unlinks; true links each loop to a closed object of the same shape, or to `boundary` objects), boundary?: [hex], style?, origin? | originMode?, separate?: true, recreate?: \"polyline\"|\"region\", associate?: bool (link to the recreated boundary)}").interactive(|s| Ok(Box::new(edit::HatchEditM::new(s)))),
|
||||
CommandSpec::new("hatchedit", "Hatch Edit", edit::run_hatchedit).menu(&["Modify", "Object", "Hatch..."]).alias(&["he", "-hatchedit"]).params("{handles?, pattern?, scale?, angle?, gradient?: {name?, color1?, color2?, angle? (degrees), centered?} (makes or changes a gradient fill; `pattern` turns it back), associative?: bool (false unlinks; true links each loop to a closed object of the same shape, or to `boundary` objects), boundary?: [hex], style?, origin? | originMode?, separate?: true, recreate?: \"polyline\"|\"region\", associate?: bool (link to the recreated boundary)}").interactive(|s| Ok(Box::new(edit::HatchEditM::new(s)))),
|
||||
CommandSpec::new("hatchpick", "Hatch Pick", pick::run_hatchpick)
|
||||
.params("{points?: [[x,y]] (internal points), handles?: [hex] (closed objects)} (typed: Points, Objects, oRigin or Hatch, then the picks): the Hatch and Gradient dialog's picks, kept in the session; changes nothing")
|
||||
.enabled(has_doc)
|
||||
.noundo()
|
||||
.interactive(|_| Ok(Box::new(pick::HatchPickM::default()))),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -250,6 +257,17 @@ pub(crate) fn loops_from_handles(s: &Session, hs: &[Handle]) -> Result<Vec<Hatch
|
||||
Ok(loops)
|
||||
}
|
||||
|
||||
/// Gradient fills by name: the shapes the renderer draws (`render/src/hatch.rs`); the INV forms
|
||||
/// swap the two colours.
|
||||
pub(crate) const GRADIENT_NAMES: [&str; 10] =
|
||||
["LINEAR", "CYLINDER", "CURVED", "SPHERICAL", "HEMISPHERICAL", "INVLINEAR", "INVCYLINDER", "INVCURVED", "INVSPHERICAL", "INVHEMISPHERICAL"];
|
||||
|
||||
/// A gradient name as stored (upper case), or None when it isn't one of [`GRADIENT_NAMES`].
|
||||
pub(crate) fn gradient_name(t: &str) -> Option<&'static str> {
|
||||
let up = t.trim().to_ascii_uppercase();
|
||||
GRADIENT_NAMES.iter().copied().find(|n| *n == up)
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct HatchSettings {
|
||||
pattern: String,
|
||||
@@ -273,11 +291,11 @@ impl HatchSettings {
|
||||
));
|
||||
let pattern = str_param(p, "pattern").map(str::to_ascii_uppercase).unwrap_or(hdr.0);
|
||||
let g = gradient.then(|| Gradient {
|
||||
name: str_param(p, "name").unwrap_or("LINEAR").to_string(),
|
||||
name: str_param(p, "name").and_then(gradient_name).unwrap_or("LINEAR").to_string(),
|
||||
color1: str_param(p, "color1").and_then(Color::parse).unwrap_or(Color::Index(5)),
|
||||
color2: str_param(p, "color2").and_then(Color::parse).unwrap_or(Color::Index(7)),
|
||||
angle: f64_or(p, "angle", 0.0).to_radians(),
|
||||
centered: true,
|
||||
centered: bool_or(p, "centered", true),
|
||||
});
|
||||
HatchSettings {
|
||||
pattern,
|
||||
@@ -333,14 +351,21 @@ fn make(s: &mut Session, p: &Value, gradient: bool) -> Result<Value> {
|
||||
if str_param(p, "originMode").is_some_and(|t| OriginMode::parse(t).is_none()) {
|
||||
return Err(bad(cmd, "unknown `originMode`"));
|
||||
}
|
||||
if gradient && str_param(p, "name").is_some_and(|t| gradient_name(t).is_none()) {
|
||||
return Err(bad(cmd, format!("unknown gradient `name`; use one of {}", GRADIENT_NAMES.join(", "))));
|
||||
}
|
||||
let st = HatchSettings::from(s, p, gradient);
|
||||
// Loops by picked point or selected object: one hatch each when separate.
|
||||
let mut groups: Vec<Vec<HatchLoop>> = Vec::new();
|
||||
if let Some(pts) = points_param(p, "points") {
|
||||
for q in pts {
|
||||
groups.push(loops_at(s, q)?);
|
||||
}
|
||||
} else {
|
||||
// Internal points and boundary objects can come together (the dialog's Pick points and
|
||||
// Select objects); points alone don't fall back to the selection.
|
||||
let pts = points_param(p, "points");
|
||||
let given = |k: &str| p.get(k).is_some_and(|v| !v.is_null());
|
||||
let objects = pts.is_none() || given("handles") || given("handle");
|
||||
for q in pts.into_iter().flatten() {
|
||||
groups.push(loops_at(s, q)?);
|
||||
}
|
||||
if objects {
|
||||
let hs = targets(s, p)?;
|
||||
if st.separate {
|
||||
groups.extend(loops_from_handles(s, &hs)?.into_iter().map(|l| vec![HatchLoop { outer: true, ..l }]));
|
||||
@@ -428,6 +453,13 @@ fn add_hatches(s: &mut Session, st: &HatchSettings, groups: Vec<Vec<HatchLoop>>)
|
||||
|
||||
fn run_hatch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
patfile::load_param(p)?;
|
||||
// Like the prompt: a name without a definition would be drawn as ANSI31.
|
||||
if let Some(up) = str_param(p, "pattern").map(str::to_ascii_uppercase)
|
||||
&& cadcraft_doc::library::pattern(&up).is_none()
|
||||
&& patfile::user_pattern(&up).is_none()
|
||||
{
|
||||
return Err(bad("hatch", format!("unknown pattern {up}")));
|
||||
}
|
||||
make(s, p, false)
|
||||
}
|
||||
fn run_gradient(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -443,11 +475,27 @@ fn boundary_at(s: &mut Session, q: Vec2) -> Result<Vec<Handle>> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Most internal points one BOUNDARY call accepts (checked before the list is copied).
|
||||
const MAX_BOUNDARY_POINTS: usize = 256;
|
||||
/// Most polylines one BOUNDARY call may create. Each pick adds polylines the next pick traces
|
||||
/// against, so this bounds the aggregate tracing work of the batch.
|
||||
const MAX_BOUNDARY_OUTPUT: usize = 1024;
|
||||
|
||||
fn run_boundary(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let raw = p.get("points").and_then(Value::as_array).ok_or_else(|| bad("boundary", "`points` is required"))?;
|
||||
if raw.len() > MAX_BOUNDARY_POINTS {
|
||||
return Err(bad("boundary", format!("at most {MAX_BOUNDARY_POINTS} points per call (got {})", raw.len())));
|
||||
}
|
||||
let pts = points_param(p, "points").ok_or_else(|| bad("boundary", "`points` is required"))?;
|
||||
let before = s.state()?.doc.clone();
|
||||
let mut out = Vec::new();
|
||||
for q in pts {
|
||||
out.extend(boundary_at(s, q)?.into_iter().map(|h| h.hex()));
|
||||
if out.len() > MAX_BOUNDARY_OUTPUT {
|
||||
// The refused call leaves no partial result behind.
|
||||
s.state_mut()?.doc = before;
|
||||
return Err(bad("boundary", format!("more than {MAX_BOUNDARY_OUTPUT} boundary polylines in one call; split the work")));
|
||||
}
|
||||
}
|
||||
Ok(json!({ "handles": out, "message": format!("BOUNDARY created {} polyline(s)", out.len()) }))
|
||||
}
|
||||
@@ -750,6 +798,21 @@ mod tests {
|
||||
assert!(hatch.gradient.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hatchedit_solid_replaces_gradient() {
|
||||
let mut s = Session::new();
|
||||
s.execute("rectang", &json!({ "p1": [0, 0], "p2": [10, 10] })).unwrap();
|
||||
s.execute("gradient", &json!({ "points": [[5, 5]], "color1": "1", "color2": "3" })).unwrap();
|
||||
let (h, before) = only_hatch(&s);
|
||||
assert!(before.gradient.is_some());
|
||||
s.execute("hatchedit", &json!({ "handles": [h.clone()], "pattern": "SOLID" })).unwrap();
|
||||
let (h2, hatch) = only_hatch(&s);
|
||||
assert_eq!(h2, h);
|
||||
assert!(hatch.solid);
|
||||
assert!(hatch.gradient.is_none(), "SOLID is a plain fill, not the gradient");
|
||||
assert_eq!(hatch.loops, before.loops);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_pattern_picker_is_offered_and_its_choice_sets_the_pattern() {
|
||||
let mut s = Session::new();
|
||||
@@ -777,6 +840,19 @@ mod tests {
|
||||
assert!(s.current_prompt().unwrap().picker.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_hatch_refuses_an_undefined_pattern() {
|
||||
let mut s = Session::new();
|
||||
s.execute("rectang", &json!({ "p1": [0, 0], "p2": [10, 10] })).unwrap();
|
||||
let e = s.execute("hatch", &json!({ "points": [[5, 5]], "pattern": "NOPE" })).unwrap_err();
|
||||
assert!(e.to_string().contains("unknown pattern NOPE"), "{e}");
|
||||
assert_eq!(s.doc().unwrap().model.len(), 1, "only the rectangle");
|
||||
for name in ["ansi31", "SOLID"] {
|
||||
s.execute("hatch", &json!({ "points": [[5, 5]], "pattern": name })).unwrap();
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().model.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pattern_choices_list_the_library_with_its_lines() {
|
||||
let all = pattern_choices();
|
||||
|
||||
@@ -2,14 +2,15 @@
|
||||
//! associativity (unlink, or link to boundary objects), separate hatches and recreated
|
||||
//! boundaries of existing hatches.
|
||||
|
||||
use cadcraft_doc::{Drawing, EntityKind, Handle, Hatch, HatchLoop, entity_bounds};
|
||||
use cadcraft_color::Color;
|
||||
use cadcraft_doc::{Drawing, EntityKind, Gradient, Handle, Hatch, HatchLoop, entity_bounds};
|
||||
use cadcraft_geom::{Bounds2, Polyline, Vec2, point_in_polygon};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::assoc::{detach, same_shape};
|
||||
use super::{ORIGIN_KW, OriginMode, closed_loop, island_style};
|
||||
use super::{GRADIENT_NAMES, ORIGIN_KW, OriginMode, closed_loop, gradient_name, island_style};
|
||||
use crate::cmd::helpers::lwpoly;
|
||||
use crate::cmd::{bad, bool_or, point_param, str_param, targets};
|
||||
use crate::cmd::{bad, bool_or, point_param, size_param, str_param, targets};
|
||||
use crate::{Accept, Input, Interactive, Picker, Prompt, Result, Session, Step};
|
||||
|
||||
/// Upper bound on hatches edited by one call.
|
||||
@@ -145,9 +146,10 @@ fn properties(h: &mut Hatch, p: &Value) {
|
||||
if let Some(v) = str_param(p, "pattern").map(str::trim).filter(|v| !v.is_empty()) {
|
||||
h.pattern = v.to_ascii_uppercase();
|
||||
h.solid = h.pattern == "SOLID";
|
||||
// A named pattern, SOLID included, replaces the gradient fill; the renderer draws the
|
||||
// gradient first when it is set.
|
||||
h.gradient = None;
|
||||
if !h.solid {
|
||||
// A named pattern replaces the gradient fill; the renderer draws the gradient first when it is set.
|
||||
h.gradient = None;
|
||||
// A library pattern is drawn from the library; a loaded .pat pattern brings its lines;
|
||||
// any other name is the hatch's own and keeps its definition.
|
||||
if cadcraft_doc::library::pattern(&h.pattern).is_some() {
|
||||
@@ -165,7 +167,100 @@ fn properties(h: &mut Hatch, p: &Value) {
|
||||
}
|
||||
}
|
||||
|
||||
/// HATCHEDIT's `gradient`: the fields to change, all checked before any hatch is touched.
|
||||
struct GradientEdit {
|
||||
name: Option<&'static str>,
|
||||
color1: Option<Color>,
|
||||
color2: Option<Color>,
|
||||
/// Radians.
|
||||
angle: Option<f64>,
|
||||
centered: Option<bool>,
|
||||
}
|
||||
|
||||
impl GradientEdit {
|
||||
/// Make `h` a gradient fill (from the default LINEAR blue-to-white one when it has none) and
|
||||
/// set the given fields.
|
||||
fn apply(&self, h: &mut Hatch) {
|
||||
let mut g = h.gradient.clone().unwrap_or(Gradient {
|
||||
name: "LINEAR".into(),
|
||||
color1: Color::Index(5),
|
||||
color2: Color::Index(7),
|
||||
angle: 0.0,
|
||||
centered: true,
|
||||
});
|
||||
if let Some(n) = self.name {
|
||||
g.name = n.into();
|
||||
}
|
||||
if let Some(c) = self.color1 {
|
||||
g.color1 = c;
|
||||
}
|
||||
if let Some(c) = self.color2 {
|
||||
g.color2 = c;
|
||||
}
|
||||
if let Some(a) = self.angle {
|
||||
g.angle = a;
|
||||
}
|
||||
if let Some(c) = self.centered {
|
||||
g.centered = c;
|
||||
}
|
||||
h.gradient = Some(g);
|
||||
h.pattern = "SOLID".into();
|
||||
h.solid = true;
|
||||
h.pattern_lines.clear();
|
||||
}
|
||||
}
|
||||
|
||||
fn gradient_edit(p: &Value) -> Result<Option<GradientEdit>> {
|
||||
let g = match p.get("gradient") {
|
||||
None | Some(Value::Null) => return Ok(None),
|
||||
Some(Value::Object(g)) => g,
|
||||
Some(_) => return Err(bad("hatchedit", "`gradient` is an object {name?, color1?, color2?, angle?, centered?}")),
|
||||
};
|
||||
let field = |k: &str| g.get(k).filter(|v| !v.is_null());
|
||||
let name = match field("name") {
|
||||
None => None,
|
||||
Some(v) => Some(
|
||||
v.as_str()
|
||||
.and_then(gradient_name)
|
||||
.ok_or_else(|| bad("hatchedit", format!("unknown gradient `name`; use one of {}", GRADIENT_NAMES.join(", "))))?,
|
||||
),
|
||||
};
|
||||
let color = |k: &str| -> Result<Option<Color>> {
|
||||
match field(k) {
|
||||
None => Ok(None),
|
||||
Some(v) => v
|
||||
.as_str()
|
||||
.and_then(Color::parse)
|
||||
.map(Some)
|
||||
.ok_or_else(|| bad("hatchedit", format!("`gradient.{k}` is a colour such as \"5\", \"red\" or \"255,128,0\""))),
|
||||
}
|
||||
};
|
||||
let angle = match field("angle") {
|
||||
None => None,
|
||||
Some(v) => Some(
|
||||
v.as_f64().map(f64::to_radians).filter(|a| a.is_finite()).ok_or_else(|| bad("hatchedit", "`gradient.angle` is a number of degrees"))?,
|
||||
),
|
||||
};
|
||||
let centered = match field("centered") {
|
||||
None => None,
|
||||
Some(v) => Some(v.as_bool().ok_or_else(|| bad("hatchedit", "`gradient.centered` is true or false"))?),
|
||||
};
|
||||
Ok(Some(GradientEdit { name, color1: color("color1")?, color2: color("color2")?, angle, centered }))
|
||||
}
|
||||
|
||||
/// An explicit `scale` must be positive and an explicit `angle` a number, checked before anything
|
||||
/// is edited so a refused request changes nothing (absent and null leave them as they are).
|
||||
fn check_numbers(p: &Value) -> Result<()> {
|
||||
size_param("hatchedit", p, "scale", false)?;
|
||||
match p.get("angle") {
|
||||
None | Some(Value::Null) => Ok(()),
|
||||
Some(v) if v.as_f64().is_some_and(f64::is_finite) => Ok(()),
|
||||
Some(_) => Err(bad("hatchedit", "`angle` must be a number of degrees")),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn run_hatchedit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
check_numbers(p)?;
|
||||
let hs = hatches(s, p)?;
|
||||
let style = match str_param(p, "style") {
|
||||
Some(t) => Some(island_style(t).ok_or_else(|| bad("hatchedit", "`style` is \"normal\", \"outer\" or \"ignore\""))?),
|
||||
@@ -185,6 +280,10 @@ pub(super) fn run_hatchedit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
Some(_) => return Err(bad("hatchedit", "`recreate` is \"polyline\"")),
|
||||
};
|
||||
let grad = gradient_edit(p)?;
|
||||
if grad.is_some() && str_param(p, "pattern").map(|v| v.trim().to_ascii_uppercase()).is_some_and(|v| !v.is_empty() && v != "SOLID") {
|
||||
return Err(bad("hatchedit", "give `pattern` or `gradient`, not both"));
|
||||
}
|
||||
// Like the prompt: a pattern must be one CADCraft defines, SOLID, or the hatches' own (a
|
||||
// pattern read from a file keeps its definition).
|
||||
if let Some(up) = str_param(p, "pattern").map(|v| v.trim().to_ascii_uppercase()).filter(|v| !v.is_empty())
|
||||
@@ -210,6 +309,9 @@ pub(super) fn run_hatchedit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for handle in &hs {
|
||||
let Some(EntityKind::Hatch(mut h)) = s.doc()?.entity(*handle).map(|e| e.kind.clone()) else { continue };
|
||||
properties(&mut h, p);
|
||||
if let Some(g) = &grad {
|
||||
g.apply(&mut h);
|
||||
}
|
||||
if let Some(st) = style {
|
||||
h.style = st;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
//! HATCHPICK: the Hatch and Gradient dialog's picks (internal points, boundary objects, a pattern
|
||||
//! origin, or the hatches to edit), kept in the session for the dialog to take. It never changes
|
||||
//! the drawing.
|
||||
|
||||
use cadcraft_doc::{EntityKind, Handle, HatchLoop};
|
||||
use cadcraft_geom::Vec2;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{HatchSettings, closed_loop, loops_at, loops_from_handles};
|
||||
use crate::cmd::{bad, points_param, targets};
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
/// Upper bound on the points and objects one HATCHPICK keeps.
|
||||
pub(crate) const MAX_PICKS: usize = 1000;
|
||||
|
||||
/// The first prompt's keywords: what to pick.
|
||||
const MODES: [&str; 4] = ["Points", "Objects", "oRigin", "Hatch"];
|
||||
|
||||
/// What HATCHPICK picks.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum PickMode {
|
||||
/// Internal points, each inside a closed boundary.
|
||||
#[default]
|
||||
Points,
|
||||
/// Closed boundary objects.
|
||||
Objects,
|
||||
/// One point: the pattern origin.
|
||||
Origin,
|
||||
/// Hatches to edit.
|
||||
Hatch,
|
||||
}
|
||||
|
||||
/// What the last HATCHPICK kept ([`Session::hatch_picks`]).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct HatchPicks {
|
||||
pub mode: PickMode,
|
||||
pub points: Vec<Vec2>,
|
||||
/// Closed boundary objects (Objects) or hatches (Hatch).
|
||||
pub handles: Vec<Handle>,
|
||||
/// The pattern origin (Origin).
|
||||
pub origin: Option<Vec2>,
|
||||
}
|
||||
|
||||
fn mode_named(t: &str) -> Option<PickMode> {
|
||||
Some(match t.trim().to_ascii_lowercase().as_str() {
|
||||
"points" | "p" => PickMode::Points,
|
||||
"objects" | "o" => PickMode::Objects,
|
||||
"origin" | "r" => PickMode::Origin,
|
||||
"hatch" | "h" => PickMode::Hatch,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// JSON form: `{points?, handles?}`, checked as the prompts check them.
|
||||
pub(super) fn run_hatchpick(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let given = |k: &str| p.get(k).is_some_and(|v| !v.is_null());
|
||||
if !given("points") && !given("handles") {
|
||||
return Err(bad("hatchpick", "give internal `points` or boundary `handles`"));
|
||||
}
|
||||
let mut picks = HatchPicks::default();
|
||||
if given("points") {
|
||||
let pts = points_param(p, "points").ok_or_else(|| bad("hatchpick", "`points` is an array of [x, y]"))?;
|
||||
if pts.len() > MAX_PICKS {
|
||||
return Err(bad("hatchpick", format!("at most {MAX_PICKS} points")));
|
||||
}
|
||||
for q in pts {
|
||||
loops_at(s, q).map_err(|_| bad("hatchpick", format!("no closed boundary around [{}, {}]", q.x, q.y)))?;
|
||||
picks.points.push(q);
|
||||
}
|
||||
}
|
||||
if given("handles") {
|
||||
let hs = targets(s, p)?;
|
||||
if hs.len() > MAX_PICKS {
|
||||
return Err(bad("hatchpick", format!("at most {MAX_PICKS} objects")));
|
||||
}
|
||||
let d = s.doc()?;
|
||||
for h in hs {
|
||||
match d.entity(h) {
|
||||
Some(e) if closed_loop(d, e).is_some() => picks.handles.push(h),
|
||||
_ => return Err(bad("hatchpick", format!("{} is not a closed object", h.hex()))),
|
||||
}
|
||||
}
|
||||
if picks.points.is_empty() {
|
||||
picks.mode = PickMode::Objects;
|
||||
}
|
||||
}
|
||||
let out = json!({
|
||||
"points": picks.points.iter().map(|q| [q.x, q.y]).collect::<Vec<_>>(),
|
||||
"handles": picks.handles.iter().map(|h| h.hex()).collect::<Vec<_>>(),
|
||||
});
|
||||
s.hatch_picks = Some(picks);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// One pick, so Undo can drop it.
|
||||
enum Pick {
|
||||
Point(Vec2, Vec<HatchLoop>),
|
||||
Objects(Vec<Handle>, Vec<HatchLoop>),
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct HatchPickM {
|
||||
mode: Option<PickMode>,
|
||||
picks: Vec<Pick>,
|
||||
}
|
||||
|
||||
impl HatchPickM {
|
||||
fn count(&self) -> usize {
|
||||
self.picks
|
||||
.iter()
|
||||
.map(|p| match p {
|
||||
Pick::Point(..) => 1,
|
||||
Pick::Objects(hs, _) => hs.len(),
|
||||
})
|
||||
.sum()
|
||||
}
|
||||
fn keep(&self, s: &mut Session, mode: PickMode) {
|
||||
let mut out = HatchPicks { mode, ..HatchPicks::default() };
|
||||
for p in &self.picks {
|
||||
match p {
|
||||
Pick::Point(q, _) => out.points.push(*q),
|
||||
Pick::Objects(hs, _) => out.handles.extend(hs.iter().copied()),
|
||||
}
|
||||
}
|
||||
s.hatch_picks = Some(out);
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for HatchPickM {
|
||||
fn name(&self) -> &'static str {
|
||||
"HATCHPICK"
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
match self.mode {
|
||||
None => Prompt::new("Enter what to pick", Accept::TEXT).kw(&MODES),
|
||||
Some(PickMode::Points) => Prompt::new("Pick internal point", Accept::POINT).kw(&["Undo"]),
|
||||
Some(PickMode::Objects) => Prompt::new("Select objects", Accept::SELECT).kw(&["Undo"]),
|
||||
Some(PickMode::Origin) => Prompt::new("Specify the pattern origin", Accept::POINT),
|
||||
Some(PickMode::Hatch) => Prompt::new("Select hatch object", Accept::SELECT),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
let Some(mode) = self.mode else {
|
||||
match &i {
|
||||
Input::Keyword(t) | Input::Text(t) => self.mode = mode_named(t),
|
||||
Input::Enter | Input::Cancel => return Ok(Step::Cancel),
|
||||
_ => {}
|
||||
}
|
||||
return Ok(Step::Continue);
|
||||
};
|
||||
match (mode, i) {
|
||||
(_, Input::Cancel) => Ok(Step::Cancel),
|
||||
(PickMode::Points | PickMode::Objects, Input::Keyword(k)) if k == "Undo" => {
|
||||
if self.picks.pop().is_none() {
|
||||
s.echo("Nothing to undo.");
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(PickMode::Points | PickMode::Objects, Input::Enter) => {
|
||||
self.keep(s, mode);
|
||||
Ok(Step::Done)
|
||||
}
|
||||
(PickMode::Points, Input::Point(q)) => {
|
||||
if self.count() >= MAX_PICKS {
|
||||
s.echo(format!("At most {MAX_PICKS} picks."));
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
match loops_at(s, q) {
|
||||
Ok(l) => {
|
||||
s.echo(format!("Analyzing the selected data... {} loop(s)", l.len()));
|
||||
self.picks.push(Pick::Point(q, l));
|
||||
}
|
||||
Err(e) => s.echo(e.to_string()),
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(PickMode::Objects, Input::Pick(hs)) => {
|
||||
let room = MAX_PICKS.saturating_sub(self.count());
|
||||
let closed: Vec<Handle> = {
|
||||
let d = s.doc()?;
|
||||
hs.iter().copied().filter(|h| d.entity(*h).is_some_and(|e| closed_loop(d, e).is_some())).take(room).collect()
|
||||
};
|
||||
let others = hs.len().saturating_sub(closed.len());
|
||||
if !closed.is_empty() {
|
||||
let loops = loops_from_handles(s, &closed)?;
|
||||
self.picks.push(Pick::Objects(closed.clone(), loops));
|
||||
}
|
||||
s.echo(if others == 0 { format!("{} found", closed.len()) } else { format!("{} found, {others} not closed", closed.len()) });
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(PickMode::Origin, Input::Point(q)) => {
|
||||
s.hatch_picks = Some(HatchPicks { mode, origin: Some(q), ..HatchPicks::default() });
|
||||
Ok(Step::Done)
|
||||
}
|
||||
(PickMode::Hatch, Input::Pick(hs)) => {
|
||||
let hatches: Vec<Handle> = {
|
||||
let d = s.doc()?;
|
||||
hs.into_iter().filter(|h| matches!(d.entity(*h).map(|e| &e.kind), Some(EntityKind::Hatch(_)))).take(MAX_PICKS).collect()
|
||||
};
|
||||
if hatches.is_empty() {
|
||||
s.echo("No hatch selected.");
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
s.hatch_picks = Some(HatchPicks { mode, handles: hatches, ..HatchPicks::default() });
|
||||
Ok(Step::Done)
|
||||
}
|
||||
(PickMode::Origin | PickMode::Hatch, Input::Enter) => Ok(Step::Done),
|
||||
_ => Ok(Step::Continue),
|
||||
}
|
||||
}
|
||||
fn preview(&self, s: &Session, _c: Vec2) -> Vec<EntityKind> {
|
||||
let loops: Vec<HatchLoop> = self
|
||||
.picks
|
||||
.iter()
|
||||
.flat_map(|p| match p {
|
||||
Pick::Point(_, l) | Pick::Objects(_, l) => l.iter().cloned(),
|
||||
})
|
||||
.collect();
|
||||
if loops.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
vec![HatchSettings::from(s, &Value::Null, false).hatch(loops)]
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("entities", "Query Entities", run_entities)
|
||||
.params("{type?, layer?, window?: [[x,y],[x,y]], crossing?: bool (default true; false = entirely inside), limit?, offset?} → {count (all matches), returned, offset, entities}")
|
||||
.noundo(),
|
||||
CommandSpec::new("texts", "Query Texts", run_texts)
|
||||
.params("{contains?: text (any case), layer?, window?: [[x,y],[x,y]] (by insertion point), limit? (default 200), offset?} → {count (all matches), returned, offset, texts: [{handle, type, layer, text, at, height, rotation (degrees), style, tag? (attributes)}]}: the readable text of TEXT, MTEXT (formatting removed), block attributes and multileaders")
|
||||
.noundo(),
|
||||
CommandSpec::new("countlist", "Count List", count::run_countlist)
|
||||
.params("{space?: all (default)|current|model|<layout>, nested?: bool} → per-block counts, as COUNT")
|
||||
.noundo(),
|
||||
@@ -283,7 +286,8 @@ fn run_inspect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"title": st.title,
|
||||
"path": st.path,
|
||||
"dirty": st.is_dirty(),
|
||||
"space": match &space { cadcraft_doc::Space::Model => "Model".to_string(), cadcraft_doc::Space::Paper(n) => n.clone() },
|
||||
"blockEditor": st.block_editor(),
|
||||
"space": space.tab_name(),
|
||||
"entityCount": d.entity_count(),
|
||||
"counts": counts,
|
||||
"extents": if ext.is_empty() { Value::Null } else { json!([[ext.min.x, ext.min.y], [ext.max.x, ext.max.y]]) },
|
||||
@@ -297,6 +301,8 @@ fn run_inspect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"selection": st.selection.iter().map(|h| h.hex()).collect::<Vec<_>>(),
|
||||
"undo": st.undo.iter().rev().take(20).map(|u| u.label.clone()).collect::<Vec<_>>(),
|
||||
"view": { "center": [st.view().center.x, st.view().center.y], "height": st.view().height },
|
||||
// Records of the file the drawing came from that the reader could not place.
|
||||
"skipped": { "count": d.skipped_records.values().fold(0usize, |a, n| a.saturating_add(*n)), "sections": d.skipped_records },
|
||||
});
|
||||
if bool_or(p, "entities", true)
|
||||
&& let (Some(o), Some(store)) = (out.as_object_mut(), d.space(&space))
|
||||
@@ -346,6 +352,78 @@ fn run_entities(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "count": matches.len(), "returned": page.len(), "offset": offset, "entities": page }))
|
||||
}
|
||||
|
||||
/// TEXTS: the readable text of the space's texts, multiline texts, block attributes and
|
||||
/// multileaders, with where they are, filtered by content, layer and a window around their
|
||||
/// insertion points. Read-only (FIND selects and can replace; this only reports).
|
||||
fn run_texts(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "texts";
|
||||
let given = |k: &str| p.get(k).filter(|v| !v.is_null());
|
||||
let text = |k: &str| -> Result<Option<String>> {
|
||||
given(k).map(|v| v.as_str().map(str::to_lowercase).ok_or_else(|| bad(CMD, format!("`{k}` must be a string")))).transpose()
|
||||
};
|
||||
let count = |k: &str, default: usize| -> Result<usize> {
|
||||
given(k).map_or(Ok(default), |v| {
|
||||
v.as_u64().map(|n| usize::try_from(n).unwrap_or(usize::MAX)).ok_or_else(|| bad(CMD, format!("`{k}` must be a whole number, 0 or more")))
|
||||
})
|
||||
};
|
||||
let contains = text("contains")?;
|
||||
let layer = text("layer")?;
|
||||
let window = given("window")
|
||||
.map(|w| {
|
||||
let corners: Option<Vec<Vec2>> = w.as_array().filter(|a| a.len() == 2).and_then(|a| a.iter().map(point_value).collect());
|
||||
match corners.as_deref() {
|
||||
Some([a, b]) => Ok(cadcraft_geom::Bounds2::new(*a, *b)),
|
||||
_ => Err(bad(CMD, "`window` must be two corner points [[x0, y0], [x1, y1]]")),
|
||||
}
|
||||
})
|
||||
.transpose()?;
|
||||
let limit = count("limit", 200)?;
|
||||
let offset = count("offset", 0)?;
|
||||
let st = s.state()?;
|
||||
let Some(store) = st.doc.space(&st.space) else { return Ok(json!({ "count": 0, "returned": 0, "offset": offset, "texts": [] })) };
|
||||
let mut found: Vec<Value> = Vec::new();
|
||||
for e in store.iter() {
|
||||
if layer.as_ref().is_some_and(|l| e.common.layer.to_lowercase() != *l) {
|
||||
continue;
|
||||
}
|
||||
let mut push = |ty: &str, value: String, at: cadcraft_geom::Vec3, height: f64, rotation: f64, style: &str, tag: Option<&str>| {
|
||||
let value = value.trim().to_string();
|
||||
if value.is_empty()
|
||||
|| contains.as_ref().is_some_and(|c| !value.to_lowercase().contains(c.as_str()))
|
||||
|| window.as_ref().is_some_and(|w| !w.contains(Vec2::new(at.x, at.y)))
|
||||
{
|
||||
return;
|
||||
}
|
||||
let mut v = json!({
|
||||
"handle": e.handle.hex(), "type": ty, "layer": e.common.layer, "text": value,
|
||||
"at": [at.x, at.y], "height": height, "rotation": rotation.to_degrees(), "style": style,
|
||||
});
|
||||
if let (Some(t), Some(o)) = (tag, v.as_object_mut()) {
|
||||
o.insert("tag".into(), json!(t));
|
||||
}
|
||||
found.push(v);
|
||||
};
|
||||
match &e.kind {
|
||||
EntityKind::Text(t) => push("Text", t.value.clone(), t.insert, t.height, t.rotation, &t.style, None),
|
||||
EntityKind::MText(t) => push("MText", cadcraft_fonts::plain_mtext(&t.contents), t.insert, t.height, t.rotation, &t.style, None),
|
||||
EntityKind::Insert(i) => {
|
||||
for a in i.attribs.iter().filter(|a| !a.invisible) {
|
||||
push("Attribute", a.text.value.clone(), a.text.insert, a.text.height, a.text.rotation, &a.text.style, Some(&a.tag));
|
||||
}
|
||||
}
|
||||
EntityKind::MLeader(m) => {
|
||||
if let Some(t) = &m.text {
|
||||
push("MLeader", cadcraft_fonts::plain_mtext(&t.contents), t.insert, t.height, t.rotation, &t.style, None);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let n = found.len();
|
||||
let page: Vec<Value> = found.into_iter().skip(offset).take(limit).collect();
|
||||
Ok(json!({ "count": n, "returned": page.len(), "offset": offset, "texts": page }))
|
||||
}
|
||||
|
||||
mod count;
|
||||
|
||||
#[derive(Default)]
|
||||
|
||||
@@ -74,10 +74,7 @@ impl Scope {
|
||||
}
|
||||
/// The top-level objects in scope, each with the name of its space.
|
||||
fn objects<'a>(&self, d: &'a Drawing) -> Vec<(String, &'a std::sync::Arc<Entity>)> {
|
||||
let name = |sp: &Space| match sp {
|
||||
Space::Model => "Model".to_string(),
|
||||
Space::Paper(n) => n.clone(),
|
||||
};
|
||||
let name = |sp: &Space| sp.tab_name().to_string();
|
||||
let mut out = Vec::new();
|
||||
let mut add = |sp: &Space| {
|
||||
if let Some(store) = d.space(sp) {
|
||||
|
||||
@@ -74,17 +74,17 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("pagesetup", "Page Setup Manager...", run_pagesetup)
|
||||
.menu(&["File", "Page Setup Manager..."])
|
||||
.params(
|
||||
"{layout?: current, device?: plotter name (PLOTTERMANAGER) | \"None\", paper?: the device's paper | \"A4\"|\"A3\"|\"Letter\"|\"ANSI B\"|…, width?, height? (mm), landscape?, margins?: [l,b,r,t] mm, lineweights?, plotArea?, scale?, scaleToFit?, center?, plotStyleTable?: \"monochrome.ctb\"|\"grayscale.ctb\"|\"default.ctb\"|\"None\"|…, displayPlotStyles?: bool}",
|
||||
"{layout?: current, device?: plotter name (PLOTTERMANAGER) | \"None\", paper?: the device's paper | \"A4\"|\"A3\"|\"Letter\"|\"ANSI B\"|…, width?, height? (mm), landscape?, margins?: [l,b,r,t] mm, lineweights?, plotArea?, scale?, scaleToFit?, center?, plotStyleTable?: \"monochrome.ctb\"|\"grayscale.ctb\"|\"default.ctb\"|\"None\"|…, displayPlotStyles?: bool, plotTransparency?: bool}",
|
||||
),
|
||||
CommandSpec::new("plot", "Print...", |s, p| run_plot(s, &super::plotters::plot_options(s, p, "plot")?, "plot"))
|
||||
.menu(&["File", "Print..."])
|
||||
.key("Cmd+P")
|
||||
.alias(&["print"])
|
||||
.params("{path? (else returns base64 `data`), layout?: current|\"Model\", paper?, landscape?, fit?: bool, scale?: number | \"1:50\", lineweights?: bool, plotArea?: extents|display|limits|window (Model) | layout, window?: [[x1,y1],[x2,y2]]} (typed or from the menu: the Plot dialog)")
|
||||
.params("{path? (else returns base64 `data`), layout?: current|\"Model\", paper?, landscape?, fit?: bool, scale?: number | \"1:50\", center?: bool (Model, default true), offset?: [x,y] mm (Model, when center is false; each within ±100000), lineweights?: bool, plotTransparency?: bool (default: the page setup's; PLOTTRANSPARENCYOVERRIDE 0/2 overrides it), plotArea?: extents|display|limits|window (Model) | layout, window?: [[x1,y1],[x2,y2]]} (typed or from the menu: the Plot dialog)")
|
||||
.noundo(),
|
||||
CommandSpec::new("exportpdf", "Export to PDF...", |s, p| run_plot(s, &super::plotters::plot_options(s, p, "exportpdf")?, "exportpdf"))
|
||||
.menu(&["File", "Export to PDF..."])
|
||||
.params("{path? (else returns base64 `data`), layout?, paper?, landscape?, fit?, scale?, lineweights?, plotArea?, window?} (as PLOT)")
|
||||
.params("{path? (else returns base64 `data`), layout?, paper?, landscape?, fit?, scale?, center?: bool (Model, default true), offset?: [x,y] mm (Model, when center is false; each within ±100000), lineweights?, plotTransparency?, plotArea?, window?} (as PLOT)")
|
||||
.noundo(),
|
||||
CommandSpec::new("mspace", "Model Space (in viewport)", run_mspace)
|
||||
.alias(&["ms"])
|
||||
@@ -136,6 +136,7 @@ fn run_mspace(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.state_mut()?;
|
||||
st.mspace = Some(pick);
|
||||
st.selection.clear();
|
||||
s.pending_window = None;
|
||||
s.echo("MSPACE");
|
||||
Ok(json!({ "viewport": pick.hex() }))
|
||||
}
|
||||
@@ -147,6 +148,7 @@ fn run_pspace(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let st = s.state_mut()?;
|
||||
st.mspace = None;
|
||||
st.selection.clear();
|
||||
s.pending_window = None;
|
||||
Ok(json!({ "space": "paper" }))
|
||||
}
|
||||
|
||||
@@ -260,10 +262,10 @@ fn run_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn run_rename(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let from = match (str_param(p, "from"), s.layout_space()) {
|
||||
let from = match (str_param(p, "from"), s.layout_space().layout()) {
|
||||
(Some(f), _) => f.to_string(),
|
||||
(None, Space::Paper(n)) => n,
|
||||
(None, Space::Model) => return Err(bad("layout.rename", "`from` is required in the Model tab")),
|
||||
(None, Some(n)) => n.to_string(),
|
||||
(None, None) => return Err(bad("layout.rename", "`from` is required in the Model tab")),
|
||||
};
|
||||
let from = layout_name("layout.rename", s.doc()?, &from)?;
|
||||
let to = str_param(p, "to").ok_or_else(|| bad("layout.rename", "`to` is required"))?;
|
||||
@@ -634,12 +636,12 @@ fn run_pagesetup(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"center",
|
||||
"plotStyleTable",
|
||||
"displayPlotStyles",
|
||||
"plotTransparency",
|
||||
];
|
||||
let changes = keys.iter().any(|k| p.get(k).is_some());
|
||||
let name = match (str_param(p, "layout"), s.space()) {
|
||||
(Some(n), _) => Some(layout_name(cmd, s.doc()?, n)?),
|
||||
(None, Space::Paper(n)) => Some(n),
|
||||
(None, Space::Model) => None,
|
||||
let name = match str_param(p, "layout") {
|
||||
Some(n) => Some(layout_name(cmd, s.doc()?, n)?),
|
||||
None => s.space().layout().map(str::to_string),
|
||||
};
|
||||
let Some(name) = name else {
|
||||
if changes {
|
||||
@@ -716,6 +718,9 @@ fn run_pagesetup(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(v) = p.get("displayPlotStyles").and_then(Value::as_bool) {
|
||||
page.show_plot_styles = v;
|
||||
}
|
||||
if let Some(v) = p.get("plotTransparency").and_then(Value::as_bool) {
|
||||
page.plot_transparency = v;
|
||||
}
|
||||
if changes {
|
||||
if let Some(l) = s.doc_mut()?.layouts.iter_mut().find(|l| l.name == name) {
|
||||
l.page = page.clone();
|
||||
@@ -774,6 +779,28 @@ fn run_plot(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
|
||||
None => s.space(),
|
||||
};
|
||||
let mut opts = if p.is_object() { p.clone() } else { json!({}) };
|
||||
if let Some(raw) = p.get("offset") {
|
||||
let values = raw
|
||||
.as_array()
|
||||
.filter(|values| values.len() == 2)
|
||||
.ok_or_else(|| bad(cmd, "`offset` must contain exactly two finite millimetre values within ±100000"))?;
|
||||
let x = values.first().and_then(Value::as_f64);
|
||||
let y = values.get(1).and_then(Value::as_f64);
|
||||
if !x.zip(y).is_some_and(|(x, y)| {
|
||||
x.is_finite()
|
||||
&& y.is_finite()
|
||||
&& x.abs() <= cadcraft_render::paper::MAX_PLOT_OFFSET_MM
|
||||
&& y.abs() <= cadcraft_render::paper::MAX_PLOT_OFFSET_MM
|
||||
}) {
|
||||
return Err(bad(cmd, "`offset` must contain exactly two finite millimetre values within ±100000"));
|
||||
}
|
||||
}
|
||||
if matches!(space, Space::Model)
|
||||
&& opts.get("output").and_then(Value::as_str).is_some_and(|output| output.eq_ignore_ascii_case("svg"))
|
||||
&& opts.get("center").and_then(Value::as_bool) == Some(false)
|
||||
{
|
||||
return Err(bad(cmd, "model-space plot offsets are not supported for SVG; choose PDF/PNG or center the plot"));
|
||||
}
|
||||
if let Some(o) = opts.as_object_mut() {
|
||||
if let Some(v) = p.get("scale") {
|
||||
let k = parse_scale(v).ok_or_else(|| bad(cmd, "`scale` must be positive: paper units per drawing unit, or a ratio such as \"1:50\""))?;
|
||||
@@ -782,12 +809,16 @@ fn run_plot(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
|
||||
if let Some(w) = plot_window(s, p, cmd, &space)? {
|
||||
o.insert("window".into(), json!([[w.min.x, w.min.y], [w.max.x, w.max.y]]));
|
||||
}
|
||||
// Without one, the plotter uses each sheet's page setup.
|
||||
match super::plotters::plot_transparency(s, p) {
|
||||
Some(t) => o.insert("plotTransparency".into(), json!(t)),
|
||||
None => o.remove("plotTransparency"),
|
||||
};
|
||||
}
|
||||
let bytes = hook(s.doc()?, &space, &opts).map_err(|e| bad(cmd, e))?;
|
||||
let layout = match (&sheets, &space) {
|
||||
(Some(names), _) => names.first().cloned().unwrap_or_default(),
|
||||
(None, Space::Model) => "Model".to_string(),
|
||||
(None, Space::Paper(n)) => n.clone(),
|
||||
let layout = match &sheets {
|
||||
Some(names) => names.first().cloned().unwrap_or_default(),
|
||||
None => space.tab_name().to_string(),
|
||||
};
|
||||
let pages = sheets.as_ref().map_or(1, Vec::len);
|
||||
match str_param(p, "path") {
|
||||
@@ -1055,12 +1086,20 @@ mod tests {
|
||||
assert!(text.starts_with("%PDF") && text.contains("Paper(\"Layout1\")") && text.contains("A4"));
|
||||
let r = s.execute("exportpdf", &json!({})).unwrap();
|
||||
assert_eq!(r["layout"], "Model");
|
||||
let baseline = String::from_utf8(super::super::file::base64_decode(r["data"].as_str().unwrap()).unwrap()).unwrap();
|
||||
assert!(!baseline.contains("\"center\"") && !baseline.contains("\"offset\""), "omitted options remain omitted: {baseline}");
|
||||
assert!(s.execute("plot", &json!({"layout": "Nope"})).is_err());
|
||||
// Plot area and a ratio scale reach the plotter as a model-space window and a number.
|
||||
let opts = |r: Value| String::from_utf8(super::super::file::base64_decode(r["data"].as_str().unwrap()).unwrap()).unwrap();
|
||||
let p = json!({"layout": "Model", "plotArea": "window", "window": [[10, 0], [0, 5]], "fit": false, "scale": "1:2"});
|
||||
let text = opts(s.execute("plot", &p).unwrap());
|
||||
assert!(text.contains(r#""window":[[0.0,0.0],[10.0,5.0]]"#) && text.contains(r#""scale":0.5"#), "{text}");
|
||||
let text = opts(s.execute("plot", &json!({"layout": "Model", "center": false, "offset": [12.5, -7.0]})).unwrap());
|
||||
assert!(text.contains(r#""center":false"#) && text.contains(r#""offset":[12.5,-7.0]"#), "{text}");
|
||||
for offset in [json!([1]), json!([1, 2, 3]), json!([f64::NAN, 0]), json!([100_001, 0])] {
|
||||
assert!(s.execute("plot", &json!({"layout": "Model", "center": false, "offset": offset})).is_err());
|
||||
}
|
||||
assert!(s.execute("plot", &json!({"layout": "Model", "output": "svg", "center": false, "offset": [0, 0]})).is_err());
|
||||
assert!(opts(s.execute("plot", &json!({"layout": "Model", "plotArea": "limits"})).unwrap()).contains(r#""window":[[0.0,0.0],"#));
|
||||
assert!(s.execute("plot", &json!({"layout": "Model", "plotArea": "window"})).is_err(), "window needs corners");
|
||||
assert!(s.execute("plot", &json!({"layout": "Layout1", "plotArea": "display"})).is_err(), "layouts plot their sheet");
|
||||
|
||||
@@ -87,10 +87,7 @@ impl LayoutM {
|
||||
let pats: Vec<&str> = pat.split(',').map(str::trim).filter(|p| !p.is_empty()).take(1000).collect();
|
||||
let pats = if pats.is_empty() { vec!["*"] } else { pats };
|
||||
let names: Vec<String> = ls.iter().filter(|l| pats.iter().any(|p| wildcard(p, &l.name))).map(|l| l.name.clone()).collect();
|
||||
let current = match s.layout_space() {
|
||||
Space::Paper(n) => n,
|
||||
Space::Model => "Model".into(),
|
||||
};
|
||||
let current = s.layout_space().tab_name().to_string();
|
||||
s.echo(format!("Active Layout: {current}"));
|
||||
if names.is_empty() {
|
||||
s.echo(format!("No layout matches \"{pat}\"."));
|
||||
@@ -131,10 +128,7 @@ impl Interactive for LayoutM {
|
||||
Stage::RenameFrom => with_cur(Prompt::new("Enter layout to rename", Accept::TEXT)),
|
||||
Stage::RenameTo(_) => Prompt::new("Enter new layout name", Accept::TEXT),
|
||||
Stage::Set => {
|
||||
let tab = match s.layout_space() {
|
||||
Space::Paper(n) => n,
|
||||
Space::Model => "Model".into(),
|
||||
};
|
||||
let tab = s.layout_space().tab_name().to_string();
|
||||
Prompt::new("Enter layout to make current", Accept::TEXT).default(tab)
|
||||
}
|
||||
Stage::List => Prompt::new("Enter layout name(s) to list", Accept::TEXT).default("*"),
|
||||
@@ -199,10 +193,7 @@ impl Interactive for LayoutM {
|
||||
Stage::Set => {
|
||||
let n = match name {
|
||||
Some(n) => n,
|
||||
None => match s.layout_space() {
|
||||
Space::Paper(n) => n,
|
||||
Space::Model => "Model".into(),
|
||||
},
|
||||
None => s.layout_space().tab_name().to_string(),
|
||||
};
|
||||
let set = find_command("layout.set").ok_or_else(|| EngineError::UnknownCommand("layout.set".into()))?;
|
||||
(set.run)(s, &json!({ "name": n })).map_err(retry)?;
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
//! AutoLISP commands: APPLOAD loads a `.lsp` file; `lisp` evaluates LISP for programmatic
|
||||
//! callers (scripts, the control channel, MCP, the CLI).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{bad, str_param};
|
||||
use crate::lisp::value::{Value as Lv, to_text};
|
||||
use crate::{Accept, CommandSpec, EngineError, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("appload", "Load Application...", run_appload)
|
||||
.menu(&["Tools", "Load Application..."])
|
||||
.alias(&["ap"])
|
||||
.params("{path: .lsp file} | {name, data: base64 | text} → {value?, error?}")
|
||||
.noundo()
|
||||
.interactive(|_| Ok(Box::new(ApploadM))),
|
||||
CommandSpec::new("lisp", "Evaluate LISP", run_lisp)
|
||||
.params(
|
||||
"{code: LISP source, inputs?: [answers to get… calls: numbers, strings, [x,y] points]} → {value (printed as prin1), output: lines printed, error?}",
|
||||
)
|
||||
.noundo(),
|
||||
]
|
||||
}
|
||||
|
||||
/// The text of a LISP file from `{path}` or `{name, data | text}`, and its name.
|
||||
fn source(p: &Value) -> Result<(String, String)> {
|
||||
if let Some(text) = str_param(p, "text") {
|
||||
return Ok((str_param(p, "name").unwrap_or("untitled.lsp").to_string(), text.to_string()));
|
||||
}
|
||||
if let Some(data) = str_param(p, "data") {
|
||||
let bytes = super::file::base64_decode(data).ok_or_else(|| bad("appload", "invalid base64"))?;
|
||||
return Ok((str_param(p, "name").unwrap_or("untitled.lsp").to_string(), String::from_utf8_lossy(&bytes).into_owned()));
|
||||
}
|
||||
let path = str_param(p, "path").ok_or_else(|| bad("appload", "`path`, or `name` with `data` (base64) or `text`, is required"))?;
|
||||
let text = crate::lisp::builtins::read_text_file(path).ok_or_else(|| bad("appload", format!("{path}: can't read the file")))?;
|
||||
Ok((path.to_string(), text))
|
||||
}
|
||||
|
||||
/// The result of a blocking LISP run as JSON, with the lines it printed.
|
||||
fn report(s: &Session, from_log: usize, r: Option<std::result::Result<Lv, String>>) -> Value {
|
||||
let output: Vec<String> = s.log.get(from_log..).map(|l| l.to_vec()).unwrap_or_default();
|
||||
match r {
|
||||
Some(Ok(v)) => json!({ "value": to_text(&v, true), "output": output }),
|
||||
Some(Err(e)) => json!({ "error": e, "output": output }),
|
||||
None => json!({ "output": output }),
|
||||
}
|
||||
}
|
||||
|
||||
fn run_appload(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (name, text) = source(p)?;
|
||||
let from = s.log.len();
|
||||
let r = s.lisp_load(&name, &text, true)?;
|
||||
Ok(report(s, from, r))
|
||||
}
|
||||
|
||||
/// A JSON input as a LISP value: numbers, strings, `[x, y(, z)]` points, null for Enter.
|
||||
fn input_value(v: &Value) -> Lv {
|
||||
match v {
|
||||
Value::Array(a) if a.len() >= 2 && a.iter().all(Value::is_number) => Lv::point(&a.iter().filter_map(Value::as_f64).collect::<Vec<_>>()),
|
||||
other => crate::lisp::drawing_from_json(other),
|
||||
}
|
||||
}
|
||||
|
||||
fn run_lisp(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let code = str_param(p, "code").ok_or_else(|| bad("lisp", "`code` is required"))?;
|
||||
let inputs = p.get("inputs").and_then(Value::as_array).map(|a| a.iter().map(input_value).collect()).unwrap_or_default();
|
||||
let from = s.log.len();
|
||||
let r = s.lisp_eval(code, false, true, inputs).map_err(|e| match e {
|
||||
EngineError::Other(m) => bad("lisp", m),
|
||||
e => e,
|
||||
})?;
|
||||
Ok(report(s, from, r))
|
||||
}
|
||||
|
||||
/// APPLOAD typed or chosen from the menu: asks for the file by name.
|
||||
struct ApploadM;
|
||||
|
||||
impl Interactive for ApploadM {
|
||||
fn name(&self) -> &'static str {
|
||||
"APPLOAD"
|
||||
}
|
||||
fn prompt(&self, _: &Session) -> Prompt {
|
||||
Prompt::new("Enter name of LISP file to load", Accept::TEXT)
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
if matches!(i, Input::Cancel | Input::Enter) {
|
||||
return Ok(Step::Cancel);
|
||||
}
|
||||
let Input::Text(t) = i else { return Ok(Step::Continue) };
|
||||
let path = t.trim().trim_matches('"').to_string();
|
||||
if path.is_empty() {
|
||||
return Ok(Step::Cancel);
|
||||
}
|
||||
match source(&json!({ "path": path })) {
|
||||
Ok((name, text)) => {
|
||||
// The routine may wait for the user: it carries on after this command ends.
|
||||
if let Err(e) = s.lisp_load(&name, &text, false) {
|
||||
s.echo(e.to_string());
|
||||
}
|
||||
Ok(Step::Done)
|
||||
}
|
||||
Err(EngineError::BadParams { msg, .. }) => {
|
||||
s.echo(msg);
|
||||
Ok(Step::Done)
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,780 @@
|
||||
//! MLEADEREDIT (add / remove leaders), MLEADERALIGN and MLEADERCOLLECT.
|
||||
//!
|
||||
//! The model has one landing per multileader: its leaders are polylines from an arrowhead to that
|
||||
//! landing, so "add leader" pushes one more arrowhead polyline and "remove leader" drops one.
|
||||
|
||||
use cadcraft_doc::{Entity, EntityKind, Handle, Insert, MLeader};
|
||||
use cadcraft_geom::{Vec2, Vec3};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::helpers::v3;
|
||||
use super::machines::{SelOutcome, SelectPhase};
|
||||
use super::*;
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
/// Most arrowheads added by one call, and most multileaders aligned / collected by one call.
|
||||
const MAX_ITEMS: usize = 10_000;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("mleaderedit", "Edit Multileader", run_edit)
|
||||
.alias(&["mledit"])
|
||||
.params("{handle, mode?: add|remove (default add), points?: [[x,y]...] (arrowheads to add; with remove, points picking the leaders to remove), index?: number|[number] (remove by position)}")
|
||||
.interactive(|_| Ok(Box::new(EditM::new(None)))),
|
||||
CommandSpec::new("mleaderedit.add", "Add Leader", |s, p| run_edit(s, &with_mode(p, "add")))
|
||||
.menu(&["Modify", "Object", "Multileader", "Add Leader"])
|
||||
.params("{handle, points: [[x,y]...] (arrowhead locations)}")
|
||||
.interactive(|_| Ok(Box::new(EditM::new(Some(false))))),
|
||||
CommandSpec::new("mleaderedit.remove", "Remove Leader", |s, p| run_edit(s, &with_mode(p, "remove")))
|
||||
.menu(&["Modify", "Object", "Multileader", "Remove Leader"])
|
||||
.params("{handle, points?: [[x,y]...] (pick the leaders), index?: number|[number]}")
|
||||
.interactive(|_| Ok(Box::new(EditM::new(Some(true))))),
|
||||
CommandSpec::new("mleaderalign", "Align Multileaders", run_align)
|
||||
.menu(&["Modify", "Object", "Multileader", "Align"])
|
||||
.alias(&["mleadera"])
|
||||
.params("{handles?, option?: current|distribute|parallel|spacing (default current), spacing?: number, to?: hex (multileader to align to), direction?: [[x,y],[x,y]] | angle?: degrees, points?: [[x,y],[x,y]] (distribute: first and last position)}")
|
||||
.interactive(|_| Ok(Box::new(AlignM::default()))),
|
||||
CommandSpec::new("mleadercollect", "Collect Multileaders", run_collect)
|
||||
.menu(&["Modify", "Object", "Multileader", "Collect"])
|
||||
.alias(&["mleaderc"])
|
||||
.params("{handles?, at: [x,y] (top left of the collected blocks), mode?: horizontal|vertical|wrap (default horizontal), width?: number (wrap), spacing?: number}")
|
||||
.interactive(|_| Ok(Box::new(CollectM::default()))),
|
||||
]
|
||||
}
|
||||
|
||||
fn with_mode(p: &Value, mode: &str) -> Value {
|
||||
let mut o = p.as_object().cloned().unwrap_or_default();
|
||||
o.insert("mode".into(), json!(mode));
|
||||
Value::Object(o)
|
||||
}
|
||||
|
||||
fn handle_of(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)))
|
||||
}
|
||||
|
||||
fn get_mleader(s: &Session, h: Handle) -> Option<MLeader> {
|
||||
match s.doc().ok()?.entity(h).map(|e| &e.kind) {
|
||||
Some(EntityKind::MLeader(m)) => Some(m.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn locked(s: &Session, h: Handle) -> bool {
|
||||
s.doc().ok().is_some_and(|d| d.entity(h).and_then(|e| d.layer(&e.common.layer)).is_some_and(|l| l.locked))
|
||||
}
|
||||
|
||||
/// Keyword abbreviation test: "p" / "par" / "parallel" all match "parallel".
|
||||
fn kw_is(input: &str, full: &str) -> bool {
|
||||
let t = input.trim().to_ascii_lowercase();
|
||||
!t.is_empty() && full.to_ascii_lowercase().starts_with(&t)
|
||||
}
|
||||
|
||||
fn seg_dist(p: Vec2, a: Vec2, b: Vec2) -> f64 {
|
||||
let ab = b - a;
|
||||
let l2 = ab.dot(ab);
|
||||
let t = if l2 > 0.0 { ((p - a).dot(ab) / l2).clamp(0.0, 1.0) } else { 0.0 };
|
||||
(p - (a + ab * t)).len()
|
||||
}
|
||||
|
||||
/// The leader of `m` nearest `p`.
|
||||
fn nearest_leader(m: &MLeader, p: Vec2) -> Option<usize> {
|
||||
let mut best: Option<(usize, f64)> = None;
|
||||
for (i, l) in m.leaders.iter().enumerate() {
|
||||
let pts: Vec<Vec2> = l.iter().map(|v| v.xy()).chain(std::iter::once(m.landing.xy())).collect();
|
||||
let d = pts.windows(2).filter_map(|w| Some(seg_dist(p, *w.first()?, *w.get(1)?))).fold(f64::INFINITY, f64::min);
|
||||
if d.is_finite() && best.is_none_or(|(_, bd)| d < bd) {
|
||||
best = Some((i, d));
|
||||
}
|
||||
}
|
||||
best.map(|(i, _)| i)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- MLEADEREDIT
|
||||
|
||||
fn add_leaders(s: &mut Session, h: Handle, pts: &[Vec2]) -> Result<()> {
|
||||
get_mleader(s, h).ok_or_else(|| bad("mleaderedit", "`handle` is not a multileader"))?;
|
||||
if locked(s, h) {
|
||||
return Err(bad("mleaderedit", "the multileader is on a locked layer"));
|
||||
}
|
||||
s.doc_mut()?.modify_entity(h, |e| {
|
||||
if let EntityKind::MLeader(m) = &mut e.kind {
|
||||
m.leaders.extend(pts.iter().map(|p| vec![v3(*p)]));
|
||||
}
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_leaders(s: &mut Session, h: Handle, idx: &[usize]) -> Result<()> {
|
||||
let m = get_mleader(s, h).ok_or_else(|| bad("mleaderedit", "`handle` is not a multileader"))?;
|
||||
if locked(s, h) {
|
||||
return Err(bad("mleaderedit", "the multileader is on a locked layer"));
|
||||
}
|
||||
if let Some(i) = idx.iter().find(|i| **i >= m.leaders.len()) {
|
||||
return Err(bad("mleaderedit", format!("no leader {i} (the multileader has {})", m.leaders.len())));
|
||||
}
|
||||
s.doc_mut()?.modify_entity(h, |e| {
|
||||
if let EntityKind::MLeader(m) = &mut e.kind {
|
||||
let mut sorted: Vec<usize> = idx.to_vec();
|
||||
sorted.sort_unstable();
|
||||
sorted.dedup();
|
||||
for i in sorted.into_iter().rev() {
|
||||
if i < m.leaders.len() {
|
||||
m.leaders.remove(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let h = match handle_of(p, "handle") {
|
||||
Some(h) => h,
|
||||
None => targets(s, p)?.into_iter().next().ok_or_else(|| bad("mleaderedit", "`handle` (a multileader) is required"))?,
|
||||
};
|
||||
let pts = match p.get("points") {
|
||||
None | Some(Value::Null) => Vec::new(),
|
||||
Some(_) => points_param(p, "points").ok_or_else(|| bad("mleaderedit", "`points` must be [[x,y]...]"))?,
|
||||
};
|
||||
if pts.len() > MAX_ITEMS {
|
||||
return Err(bad("mleaderedit", "too many points"));
|
||||
}
|
||||
match str_param(p, "mode").map(str::to_ascii_lowercase).as_deref() {
|
||||
None | Some("add") => {
|
||||
if pts.is_empty() {
|
||||
return Err(bad("mleaderedit", "`points` (arrowhead locations) is required"));
|
||||
}
|
||||
add_leaders(s, h, &pts)?;
|
||||
Ok(json!({ "added": pts.len() }))
|
||||
}
|
||||
Some("remove") => {
|
||||
let m = get_mleader(s, h).ok_or_else(|| bad("mleaderedit", "`handle` is not a multileader"))?;
|
||||
let mut idx: Vec<usize> = Vec::new();
|
||||
match p.get("index") {
|
||||
None | Some(Value::Null) => {}
|
||||
Some(Value::Array(a)) => {
|
||||
for v in a {
|
||||
idx.push(
|
||||
v.as_u64()
|
||||
.and_then(|n| usize::try_from(n).ok())
|
||||
.ok_or_else(|| bad("mleaderedit", "`index` must be non-negative whole numbers"))?,
|
||||
);
|
||||
}
|
||||
}
|
||||
Some(v) => idx.push(
|
||||
v.as_u64()
|
||||
.and_then(|n| usize::try_from(n).ok())
|
||||
.ok_or_else(|| bad("mleaderedit", "`index` must be a non-negative whole number"))?,
|
||||
),
|
||||
}
|
||||
idx.extend(pts.iter().filter_map(|q| nearest_leader(&m, *q)));
|
||||
if idx.is_empty() {
|
||||
return Err(bad("mleaderedit", "`points` or `index` (the leaders to remove) is required"));
|
||||
}
|
||||
let n = {
|
||||
let mut u = idx.clone();
|
||||
u.sort_unstable();
|
||||
u.dedup();
|
||||
u.len()
|
||||
};
|
||||
remove_leaders(s, h, &idx)?;
|
||||
Ok(json!({ "removed": n }))
|
||||
}
|
||||
Some(_) => Err(bad("mleaderedit", "`mode` must be \"add\" or \"remove\"")),
|
||||
}
|
||||
}
|
||||
|
||||
/// MLEADEREDIT at the command line: select a multileader, then arrowhead locations to add (or
|
||||
/// leaders to remove by picking them), Enter to finish.
|
||||
struct EditM {
|
||||
remove: bool,
|
||||
fixed: bool,
|
||||
target: Option<Handle>,
|
||||
sel: SelectPhase,
|
||||
changed: usize,
|
||||
}
|
||||
|
||||
impl EditM {
|
||||
fn new(mode: Option<bool>) -> Self {
|
||||
EditM { remove: mode.unwrap_or(false), fixed: mode.is_some(), target: None, sel: SelectPhase::single(), changed: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for EditM {
|
||||
fn name(&self) -> &'static str {
|
||||
"MLEADEREDIT"
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
match (self.target, self.remove) {
|
||||
(None, _) => Prompt::new("Select a multileader", Accept::SELECT),
|
||||
(Some(_), false) if self.fixed => Prompt::new("Specify leader arrowhead location", Accept::POINT),
|
||||
(Some(_), false) => Prompt::new("Specify leader arrowhead location or [Remove leaders]", Accept::POINT).kw(&["Remove leaders"]),
|
||||
(Some(_), true) if self.fixed => Prompt::new("Specify leaders to remove", Accept::POINT),
|
||||
(Some(_), true) => Prompt::new("Specify leaders to remove or [Add leaders]", Accept::POINT).kw(&["Add leaders"]),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
let Some(h) = self.target else {
|
||||
return match self.sel.feed(s, &i)? {
|
||||
SelOutcome::More => Ok(Step::Continue),
|
||||
SelOutcome::Empty => Ok(Step::Done),
|
||||
SelOutcome::Done(hs) => {
|
||||
s.set_selection(Vec::new());
|
||||
match hs.into_iter().find(|h| get_mleader(s, *h).is_some()) {
|
||||
Some(h) => {
|
||||
self.target = Some(h);
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
None => {
|
||||
s.echo("Select a multileader.");
|
||||
self.sel = SelectPhase::single();
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
match i {
|
||||
Input::Enter => {
|
||||
s.echo(format!("{} leader(s) {}.", self.changed, if self.remove { "removed" } else { "added" }));
|
||||
Ok(Step::Done)
|
||||
}
|
||||
Input::Keyword(k) | Input::Text(k) if !self.fixed && (kw_is(&k, "Remove leaders") || kw_is(&k, "Add leaders")) => {
|
||||
self.remove = kw_is(&k, "Remove leaders");
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Input::Point(pt) => {
|
||||
if self.remove {
|
||||
let Some(m) = get_mleader(s, h) else { return Ok(Step::Cancel) };
|
||||
match nearest_leader(&m, pt) {
|
||||
Some(ix) => {
|
||||
remove_leaders(s, h, &[ix])?;
|
||||
self.changed += 1;
|
||||
}
|
||||
None => s.echo("No leader to remove."),
|
||||
}
|
||||
} else {
|
||||
add_leaders(s, h, &[pt])?;
|
||||
self.changed += 1;
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
_ => Ok(Step::Continue),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- shared geometry
|
||||
|
||||
/// Move a multileader's content (landing, text, block) by `d`; the arrowheads stay.
|
||||
fn shift_content(m: &mut MLeader, d: Vec2) {
|
||||
let mv = |v: &mut Vec3| *v = Vec3::new(v.x + d.x, v.y + d.y, v.z);
|
||||
mv(&mut m.landing);
|
||||
if let Some(t) = &mut m.text {
|
||||
mv(&mut t.insert);
|
||||
}
|
||||
if let Some(b) = &mut m.block {
|
||||
mv(&mut b.insert);
|
||||
}
|
||||
}
|
||||
|
||||
fn move_content(s: &mut Session, moves: &[(Handle, Vec2)]) -> Result<()> {
|
||||
let d = s.doc_mut()?;
|
||||
for (h, delta) in moves {
|
||||
if !delta.is_finite() {
|
||||
continue;
|
||||
}
|
||||
d.modify_entity(*h, |e| {
|
||||
if let EntityKind::MLeader(m) = &mut e.kind {
|
||||
shift_content(m, *delta);
|
||||
}
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- MLEADERALIGN
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Spacing {
|
||||
Current,
|
||||
Distribute,
|
||||
Parallel,
|
||||
Value(f64),
|
||||
}
|
||||
|
||||
/// The direction of the last leader segment (arrowhead side to landing).
|
||||
fn segment_dir(m: &MLeader) -> Option<Vec2> {
|
||||
let last = m.leaders.first()?.last()?;
|
||||
let v = m.landing.xy() - last.xy();
|
||||
(v.len() > 1e-12).then(|| v.normalized())
|
||||
}
|
||||
|
||||
fn align(s: &mut Session, hs: &[Handle], to: Option<Handle>, spacing: Spacing, line: Option<(Vec2, Vec2)>) -> Result<usize> {
|
||||
let mut items: Vec<(Handle, MLeader)> = Vec::new();
|
||||
for h in hs {
|
||||
if Some(*h) == to || items.iter().any(|(x, _)| x == h) || locked(s, *h) {
|
||||
continue;
|
||||
}
|
||||
if let Some(m) = get_mleader(s, *h) {
|
||||
items.push((*h, m));
|
||||
}
|
||||
}
|
||||
if items.is_empty() {
|
||||
return Err(bad("mleaderalign", "no multileaders to align"));
|
||||
}
|
||||
if items.len() > MAX_ITEMS {
|
||||
return Err(bad("mleaderalign", "too many multileaders"));
|
||||
}
|
||||
let reference = match (spacing, to) {
|
||||
(Spacing::Distribute, _) => None,
|
||||
(_, Some(t)) => Some(get_mleader(s, t).ok_or_else(|| bad("mleaderalign", "`to` is not a multileader"))?),
|
||||
(_, None) => return Err(bad("mleaderalign", "`to` (the multileader to align to) is required")),
|
||||
};
|
||||
let mut moves: Vec<(Handle, Vec2)> = Vec::new();
|
||||
match spacing {
|
||||
Spacing::Parallel => {
|
||||
let rdir = reference.as_ref().and_then(segment_dir).ok_or_else(|| bad("mleaderalign", "the reference multileader has no leader"))?;
|
||||
for (h, m) in &items {
|
||||
let Some(arrow) = m.leaders.first().and_then(|l| l.last()) else { continue };
|
||||
let len = (m.landing.xy() - arrow.xy()).len();
|
||||
moves.push((*h, arrow.xy() + rdir * len - m.landing.xy()));
|
||||
}
|
||||
}
|
||||
Spacing::Distribute => {
|
||||
let (a, b) = line.ok_or_else(|| bad("mleaderalign", "`points` (first and last position) are required"))?;
|
||||
let d = b - a;
|
||||
if d.len() < 1e-12 {
|
||||
return Err(bad("mleaderalign", "the two points coincide"));
|
||||
}
|
||||
let u = d.normalized();
|
||||
items.sort_by(|x, y| (x.1.landing.xy() - a).dot(u).total_cmp(&(y.1.landing.xy() - a).dot(u)));
|
||||
let n = items.len();
|
||||
for (i, (h, m)) in items.iter().enumerate() {
|
||||
let f = if n > 1 { i as f64 / (n - 1) as f64 } else { 0.0 };
|
||||
moves.push((*h, a + d * f - m.landing.xy()));
|
||||
}
|
||||
}
|
||||
Spacing::Current | Spacing::Value(_) => {
|
||||
let (a, b) = line.ok_or_else(|| bad("mleaderalign", "`direction` (two points or an angle) is required"))?;
|
||||
let d = b - a;
|
||||
if d.len() < 1e-12 {
|
||||
return Err(bad("mleaderalign", "the direction is zero"));
|
||||
}
|
||||
let u = d.normalized();
|
||||
let origin = reference.as_ref().map(|r| r.landing.xy()).unwrap_or(a);
|
||||
let t_of = |m: &MLeader| (m.landing.xy() - origin).dot(u);
|
||||
if let Spacing::Value(sp) = spacing {
|
||||
let (mut pos, mut neg): (Vec<_>, Vec<_>) = items.iter().partition(|(_, m)| t_of(m) >= 0.0);
|
||||
pos.sort_by(|x, y| t_of(&x.1).total_cmp(&t_of(&y.1)));
|
||||
neg.sort_by(|x, y| t_of(&y.1).total_cmp(&t_of(&x.1)));
|
||||
for (k, (h, m)) in pos.iter().enumerate() {
|
||||
moves.push((*h, origin + u * (sp * (k + 1) as f64) - m.landing.xy()));
|
||||
}
|
||||
for (k, (h, m)) in neg.iter().enumerate() {
|
||||
moves.push((*h, origin - u * (sp * (k + 1) as f64) - m.landing.xy()));
|
||||
}
|
||||
} else {
|
||||
for (h, m) in &items {
|
||||
moves.push((*h, origin + u * t_of(m) - m.landing.xy()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
move_content(s, &moves)?;
|
||||
Ok(moves.len())
|
||||
}
|
||||
|
||||
fn parse_line(p: &Value, cmd: &str) -> Result<Option<(Vec2, Vec2)>> {
|
||||
if let Some(a) = p.get("angle").and_then(Value::as_f64) {
|
||||
if !a.is_finite() {
|
||||
return Err(bad(cmd, "`angle` must be a finite number"));
|
||||
}
|
||||
return Ok(Some((Vec2::new(0.0, 0.0), Vec2::from_angle(a.to_radians()))));
|
||||
}
|
||||
for key in ["direction", "points"] {
|
||||
if p.get(key).is_some_and(|v| !v.is_null()) {
|
||||
let pts = points_param(p, key).ok_or_else(|| bad(cmd, format!("`{key}` must be [[x,y],[x,y]]")))?;
|
||||
if let (Some(a), Some(b)) = (pts.first(), pts.get(1)) {
|
||||
return Ok(Some((*a, *b)));
|
||||
}
|
||||
return Err(bad(cmd, format!("`{key}` needs two points")));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn run_align(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let hs = targets(s, p)?;
|
||||
let to = match p.get("to") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(_) => Some(handle_of(p, "to").ok_or_else(|| bad("mleaderalign", "`to` must be an object handle"))?),
|
||||
};
|
||||
let spacing = match (str_param(p, "option").map(str::to_ascii_lowercase).as_deref(), p.get("spacing")) {
|
||||
(_, Some(v)) if !v.is_null() => match v.as_f64().filter(|x| x.is_finite() && *x >= 0.0) {
|
||||
Some(x) => Spacing::Value(x),
|
||||
None => return Err(bad("mleaderalign", "`spacing` must be a non-negative number")),
|
||||
},
|
||||
(None | Some("current"), _) => Spacing::Current,
|
||||
(Some("distribute"), _) => Spacing::Distribute,
|
||||
(Some("parallel"), _) => Spacing::Parallel,
|
||||
(Some("spacing"), _) => return Err(bad("mleaderalign", "`spacing` (a number) is required")),
|
||||
(Some(_), _) => return Err(bad("mleaderalign", "`option` must be current, distribute, parallel or spacing")),
|
||||
};
|
||||
let line = parse_line(p, "mleaderalign")?;
|
||||
let n = align(s, &hs, to, spacing, line)?;
|
||||
Ok(json!({ "aligned": n }))
|
||||
}
|
||||
|
||||
/// MLEADERALIGN at the command line: select, Options (Distribute / make segments Parallel /
|
||||
/// Specify spacing / Use current spacing), the multileader to align to, then the direction.
|
||||
#[derive(Default)]
|
||||
struct AlignM {
|
||||
sel: SelectPhase,
|
||||
picked: Vec<Handle>,
|
||||
selecting: bool,
|
||||
spacing: Option<Spacing>,
|
||||
asking_spacing: bool,
|
||||
to: Option<Handle>,
|
||||
first: Option<Vec2>,
|
||||
pick_to: SelectPhase,
|
||||
}
|
||||
|
||||
impl AlignM {
|
||||
fn mode(&self) -> Spacing {
|
||||
self.spacing.unwrap_or(Spacing::Current)
|
||||
}
|
||||
fn finish(&self, s: &mut Session, line: Option<(Vec2, Vec2)>) -> Result<Step> {
|
||||
let r = align(s, &self.picked, self.to, self.mode(), line)?;
|
||||
s.set_selection(Vec::new());
|
||||
s.echo(format!("{r} multileader(s) aligned."));
|
||||
Ok(Step::Done)
|
||||
}
|
||||
fn option(&mut self, s: &mut Session, k: &str) -> bool {
|
||||
if kw_is(k, "Distribute") {
|
||||
self.spacing = Some(Spacing::Distribute);
|
||||
} else if kw_is(k, "make segments Parallel") || kw_is(k, "Parallel") {
|
||||
self.spacing = Some(Spacing::Parallel);
|
||||
} else if kw_is(k, "Specify spacing") {
|
||||
self.asking_spacing = true;
|
||||
} else if kw_is(k, "Use current spacing") {
|
||||
self.spacing = Some(Spacing::Current);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
let _ = s;
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for AlignM {
|
||||
fn name(&self) -> &'static str {
|
||||
"MLEADERALIGN"
|
||||
}
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
self.sel = SelectPhase::begin(s);
|
||||
self.pick_to = SelectPhase::single();
|
||||
if self.sel.done {
|
||||
self.picked = self.sel.picked.clone();
|
||||
self.selecting = true;
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
const OPTS: &[&str] = &["Options"];
|
||||
if !self.selecting {
|
||||
return self.sel.prompt();
|
||||
}
|
||||
if self.asking_spacing {
|
||||
return Prompt::new("Specify spacing", Accept::NUMBER);
|
||||
}
|
||||
match (self.mode(), self.to, self.first) {
|
||||
(Spacing::Distribute, _, None) => Prompt::new("Specify first point or [Options]", Accept::POINT).kw(OPTS),
|
||||
(Spacing::Distribute, _, Some(a)) => {
|
||||
let mut p = Prompt::new("Specify second point", Accept::POINT);
|
||||
p.base = Some(a);
|
||||
p
|
||||
}
|
||||
(_, None, _) => Prompt::new("Select multileader to align to or [Options]", Accept::SELECT).kw(OPTS),
|
||||
(Spacing::Parallel, Some(_), _) => Prompt::new("Specify direction", Accept::POINT),
|
||||
(_, Some(_), None) => Prompt::new("Specify direction (first point)", Accept::POINT),
|
||||
(_, Some(_), Some(a)) => {
|
||||
let mut p = Prompt::new("Specify direction (second point)", Accept::POINT);
|
||||
p.base = Some(a);
|
||||
p
|
||||
}
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
if !self.selecting {
|
||||
return match self.sel.feed(s, &i)? {
|
||||
SelOutcome::More => Ok(Step::Continue),
|
||||
SelOutcome::Empty => Ok(Step::Done),
|
||||
SelOutcome::Done(hs) => {
|
||||
self.picked = hs;
|
||||
self.selecting = true;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
};
|
||||
}
|
||||
if self.asking_spacing {
|
||||
return match &i {
|
||||
Input::Text(t) => match super::machines::number(t).filter(|v| v.is_finite() && *v >= 0.0) {
|
||||
Some(v) => {
|
||||
self.spacing = Some(Spacing::Value(v));
|
||||
self.asking_spacing = false;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
None => {
|
||||
s.echo("Requires a non-negative number.");
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
},
|
||||
_ => Ok(Step::Continue),
|
||||
};
|
||||
}
|
||||
if let Input::Keyword(k) | Input::Text(k) = &i {
|
||||
if kw_is(k, "Options") {
|
||||
s.echo("Enter an option [Distribute/make segments Parallel/Specify spacing/Use current spacing]: type D, P, S or U.");
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
let k = k.clone();
|
||||
if self.option(s, &k) {
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
}
|
||||
let distribute = self.mode() == Spacing::Distribute;
|
||||
if !distribute && self.to.is_none() {
|
||||
if let SelOutcome::Done(hs) = self.pick_to.feed(s, &i)? {
|
||||
s.set_selection(Vec::new());
|
||||
match hs.into_iter().find(|h| get_mleader(s, *h).is_some()) {
|
||||
Some(h) => self.to = Some(h),
|
||||
None => {
|
||||
s.echo("Select a multileader.");
|
||||
self.pick_to = SelectPhase::single();
|
||||
}
|
||||
}
|
||||
}
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
let Input::Point(pt) = i else { return Ok(Step::Continue) };
|
||||
if self.mode() == Spacing::Parallel {
|
||||
return self.finish(s, None);
|
||||
}
|
||||
match self.first.take() {
|
||||
None => {
|
||||
self.first = Some(pt);
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Some(a) => self.finish(s, Some((a, pt))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- MLEADERCOLLECT
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Layout {
|
||||
Horizontal,
|
||||
Vertical,
|
||||
Wrap(f64),
|
||||
}
|
||||
|
||||
/// Size and centre of a block reference's extents (the insertion point when it has none).
|
||||
fn block_box(s: &Session, ins: &Insert) -> Result<(Vec2, Vec2)> {
|
||||
let d = s.doc()?;
|
||||
let probe = Entity { handle: Handle(0), common: Default::default(), kind: EntityKind::Insert(ins.clone()) };
|
||||
let b = cadcraft_doc::entity_bounds(d, &probe, 0);
|
||||
if b.is_empty() || !b.center().is_finite() {
|
||||
return Ok((Vec2::new(0.0, 0.0), ins.insert.xy()));
|
||||
}
|
||||
Ok((b.max - b.min, b.center()))
|
||||
}
|
||||
|
||||
fn collect(s: &mut Session, hs: &[Handle], at: Vec2, layout: Layout, spacing: Option<f64>) -> Result<Value> {
|
||||
let mut items: Vec<(Handle, MLeader)> = Vec::new();
|
||||
for h in hs {
|
||||
if items.iter().any(|(x, _)| x == h) || locked(s, *h) {
|
||||
continue;
|
||||
}
|
||||
if let Some(m) = get_mleader(s, *h).filter(|m| m.block.is_some()) {
|
||||
items.push((*h, m));
|
||||
}
|
||||
}
|
||||
if items.len() > MAX_ITEMS {
|
||||
return Err(bad("mleadercollect", "too many multileaders"));
|
||||
}
|
||||
if items.len() < 2 {
|
||||
return Err(bad("mleadercollect", "need two or more multileaders with block content"));
|
||||
}
|
||||
let gap = spacing.unwrap_or_else(|| {
|
||||
let d = s.doc().ok();
|
||||
d.and_then(|d| d.mleader_styles.iter().find(|m| m.name.eq_ignore_ascii_case(&items[0].1.style)).map(|m| m.landing_gap)).unwrap_or(0.09)
|
||||
});
|
||||
// Lay the blocks out from the top left corner at `at`.
|
||||
let (mut x, mut y, mut row_h) = (0.0_f64, 0.0_f64, 0.0_f64);
|
||||
let mut placed: Vec<Insert> = Vec::new();
|
||||
for (_, m) in &items {
|
||||
let Some(b) = &m.block else { continue };
|
||||
let (size, centre) = block_box(s, b)?;
|
||||
match layout {
|
||||
Layout::Horizontal => {}
|
||||
Layout::Vertical => {
|
||||
if x > 0.0 || row_h > 0.0 {
|
||||
y -= row_h + gap;
|
||||
x = 0.0;
|
||||
row_h = 0.0;
|
||||
}
|
||||
}
|
||||
Layout::Wrap(w) => {
|
||||
if x > 0.0 && x + size.x > w {
|
||||
y -= row_h + gap;
|
||||
x = 0.0;
|
||||
row_h = 0.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
let target = Vec2::new(at.x + x + size.x / 2.0, at.y + y - size.y / 2.0);
|
||||
let mut ins = b.clone();
|
||||
ins.insert = Vec3::new(b.insert.x + target.x - centre.x, b.insert.y + target.y - centre.y, b.insert.z);
|
||||
placed.push(ins);
|
||||
x += size.x + gap;
|
||||
row_h = row_h.max(size.y);
|
||||
}
|
||||
let (first_h, first) = items[0].clone();
|
||||
let mut merged = first;
|
||||
merged.leaders = items.iter().flat_map(|(_, m)| m.leaders.iter().cloned()).collect();
|
||||
merged.landing = Vec3::new(at.x, at.y, merged.landing.z);
|
||||
merged.dogleg = 0.0;
|
||||
merged.text = None;
|
||||
let mut rest = placed.split_off(1.min(placed.len()));
|
||||
merged.block = placed.pop();
|
||||
let common = s.doc()?.entity(first_h).map(|e| e.common.clone());
|
||||
{
|
||||
let d = s.doc_mut()?;
|
||||
d.modify_entity(first_h, |e| e.kind = EntityKind::MLeader(merged))?;
|
||||
for (h, _) in items.iter().skip(1) {
|
||||
d.remove_entity(*h);
|
||||
}
|
||||
}
|
||||
// The other blocks stay block references next to the first (the model has one block per multileader).
|
||||
let mut extra = Vec::new();
|
||||
for ins in rest.drain(..) {
|
||||
let h = s.add_entity(EntityKind::Insert(ins))?;
|
||||
if let Some(c) = &common {
|
||||
s.doc_mut()?.modify_entity(h, |e| e.common = c.clone())?;
|
||||
}
|
||||
extra.push(h.hex());
|
||||
}
|
||||
Ok(json!({ "handle": first_h.hex(), "blocks": extra, "collected": items.len() }))
|
||||
}
|
||||
|
||||
fn run_collect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let hs = targets(s, p)?;
|
||||
let at = point_req("mleadercollect", p, "at")?;
|
||||
let spacing = match p.get("spacing") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => {
|
||||
Some(v.as_f64().filter(|x| x.is_finite() && *x >= 0.0).ok_or_else(|| bad("mleadercollect", "`spacing` must be a non-negative number"))?)
|
||||
}
|
||||
};
|
||||
let layout = match str_param(p, "mode").map(str::to_ascii_lowercase).as_deref() {
|
||||
None | Some("horizontal") => Layout::Horizontal,
|
||||
Some("vertical") => Layout::Vertical,
|
||||
Some("wrap") => {
|
||||
let w = p.get("width").and_then(Value::as_f64).filter(|w| w.is_finite() && *w > 0.0);
|
||||
Layout::Wrap(w.ok_or_else(|| bad("mleadercollect", "`width` (a positive number) is required for wrap"))?)
|
||||
}
|
||||
Some(_) => return Err(bad("mleadercollect", "`mode` must be horizontal, vertical or wrap")),
|
||||
};
|
||||
collect(s, &hs, at, layout, spacing)
|
||||
}
|
||||
|
||||
/// MLEADERCOLLECT at the command line: select multileaders, then the location (Vertical /
|
||||
/// Horizontal / Wrap to change the arrangement; Wrap asks for the width).
|
||||
struct CollectM {
|
||||
sel: SelectPhase,
|
||||
picked: Vec<Handle>,
|
||||
selecting: bool,
|
||||
layout: Layout,
|
||||
asking_width: bool,
|
||||
}
|
||||
|
||||
impl Default for CollectM {
|
||||
fn default() -> Self {
|
||||
CollectM { sel: SelectPhase::default(), picked: Vec::new(), selecting: false, layout: Layout::Horizontal, asking_width: false }
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for CollectM {
|
||||
fn name(&self) -> &'static str {
|
||||
"MLEADERCOLLECT"
|
||||
}
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
self.sel = SelectPhase::begin(s);
|
||||
if self.sel.done {
|
||||
self.picked = self.sel.picked.clone();
|
||||
self.selecting = true;
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
if !self.selecting {
|
||||
return self.sel.prompt();
|
||||
}
|
||||
if self.asking_width {
|
||||
return Prompt::new("Specify width", Accept::NUMBER);
|
||||
}
|
||||
Prompt::new("Specify collected multileader location or [Vertical/Horizontal/Wrap]", Accept::POINT)
|
||||
.kw(&["Vertical", "Horizontal", "Wrap"])
|
||||
.default("Horizontal")
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
if !self.selecting {
|
||||
return match self.sel.feed(s, &i)? {
|
||||
SelOutcome::More => Ok(Step::Continue),
|
||||
SelOutcome::Empty => Ok(Step::Done),
|
||||
SelOutcome::Done(hs) => {
|
||||
self.picked = hs;
|
||||
self.selecting = true;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
};
|
||||
}
|
||||
if self.asking_width {
|
||||
if let Input::Text(t) = &i {
|
||||
match super::machines::number(t).filter(|v| v.is_finite() && *v > 0.0) {
|
||||
Some(w) => {
|
||||
self.layout = Layout::Wrap(w);
|
||||
self.asking_width = false;
|
||||
}
|
||||
None => s.echo("Requires a positive number."),
|
||||
}
|
||||
}
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
match i {
|
||||
Input::Keyword(k) | Input::Text(k) => {
|
||||
if kw_is(&k, "Vertical") {
|
||||
self.layout = Layout::Vertical;
|
||||
} else if kw_is(&k, "Horizontal") {
|
||||
self.layout = Layout::Horizontal;
|
||||
} else if kw_is(&k, "Wrap") {
|
||||
self.asking_width = true;
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Input::Point(at) => {
|
||||
let r = collect(s, &self.picked, at, self.layout, None)?;
|
||||
s.set_selection(Vec::new());
|
||||
s.echo(format!("{} multileader(s) collected.", r.get("collected").and_then(Value::as_u64).unwrap_or(0)));
|
||||
Ok(Step::Done)
|
||||
}
|
||||
_ => Ok(Step::Continue),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "mleader_edit_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,122 @@
|
||||
use cadcraft_doc::{EntityKind, Handle, MLeader};
|
||||
use cadcraft_geom::Vec2;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{Input, Session};
|
||||
|
||||
fn hd(v: &Value) -> Handle {
|
||||
Handle::parse_hex(v["handle"].as_str().unwrap_or("0")).unwrap_or(Handle(0))
|
||||
}
|
||||
|
||||
fn ml(s: &Session, h: Handle) -> MLeader {
|
||||
match s.doc().unwrap().entity(h).map(|e| e.kind.clone()) {
|
||||
Some(EntityKind::MLeader(m)) => m,
|
||||
other => panic!("not a multileader: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn text_ml(s: &mut Session, arrow: [f64; 2], land: [f64; 2]) -> Handle {
|
||||
hd(&s.execute("mleader", &json!({ "points": [arrow, land], "text": "N" })).unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_adds_and_removes_leaders() {
|
||||
let mut s = Session::new();
|
||||
let h = text_ml(&mut s, [0.0, 0.0], [5.0, 5.0]);
|
||||
s.execute("mleaderedit", &json!({ "handle": h.hex(), "points": [[1, 9], [8, 0]] })).unwrap();
|
||||
assert_eq!(ml(&s, h).leaders.len(), 3);
|
||||
// Pick the leader near (8, 0): it is leader 2.
|
||||
s.execute("mleaderedit", &json!({ "handle": h.hex(), "mode": "remove", "points": [[7.5, 0.5]] })).unwrap();
|
||||
let m = ml(&s, h);
|
||||
assert_eq!(m.leaders.len(), 2);
|
||||
assert!(m.leaders.iter().all(|l| l[0].x != 8.0));
|
||||
assert!(s.execute("mleaderedit", &json!({ "handle": h.hex(), "mode": "remove", "index": 7 })).is_err());
|
||||
// Interactive: Add Leader then Remove Leader, one undo step each.
|
||||
s.start("mleaderedit.add").unwrap();
|
||||
s.input(Input::Pick(vec![h])).unwrap();
|
||||
s.input(Input::Point(Vec2::new(-3.0, 2.0))).unwrap();
|
||||
s.input(Input::Enter).unwrap();
|
||||
assert!(s.running.is_none());
|
||||
assert_eq!(ml(&s, h).leaders.len(), 3);
|
||||
s.start("mleaderedit.remove").unwrap();
|
||||
s.input(Input::Pick(vec![h])).unwrap();
|
||||
s.input(Input::Point(Vec2::new(-3.0, 2.0))).unwrap();
|
||||
s.input(Input::Enter).unwrap();
|
||||
assert_eq!(ml(&s, h).leaders.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn align_distribute_parallel_and_spacing() {
|
||||
let mut s = Session::new();
|
||||
let a = text_ml(&mut s, [0.0, 0.0], [0.0, 4.0]);
|
||||
let b = text_ml(&mut s, [3.0, 0.0], [3.0, 7.0]);
|
||||
let c = text_ml(&mut s, [9.0, 0.0], [9.0, 2.0]);
|
||||
// Current spacing: all landings on the horizontal line through a's landing.
|
||||
s.execute("mleaderalign", &json!({ "handles": [b.hex(), c.hex()], "to": a.hex(), "direction": [[0, 0], [1, 0]] })).unwrap();
|
||||
assert!((ml(&s, b).landing.y - 4.0).abs() < 1e-9 && (ml(&s, c).landing.y - 4.0).abs() < 1e-9);
|
||||
assert!((ml(&s, b).text.unwrap().insert.y - 4.0).abs() < 1e-6, "text moves with the landing");
|
||||
assert_eq!(ml(&s, b).leaders[0][0].y, 0.0, "arrowheads stay");
|
||||
// Specified spacing along +x.
|
||||
s.execute("mleaderalign", &json!({ "handles": [b.hex(), c.hex()], "to": a.hex(), "direction": [[0, 0], [1, 0]], "spacing": 2 })).unwrap();
|
||||
assert!((ml(&s, b).landing.x - 2.0).abs() < 1e-9 && (ml(&s, c).landing.x - 4.0).abs() < 1e-9);
|
||||
// Distribute between two points.
|
||||
s.execute("mleaderalign", &json!({ "handles": [a.hex(), b.hex(), c.hex()], "option": "distribute", "points": [[0, 10], [10, 10]] })).unwrap();
|
||||
let xs: Vec<f64> = [a, b, c].iter().map(|h| ml(&s, *h).landing.x).collect();
|
||||
assert!(xs.iter().any(|x| (x - 5.0).abs() < 1e-9) && xs.iter().any(|x| (x - 10.0).abs() < 1e-9));
|
||||
// Parallel: segments take the reference's direction.
|
||||
s.execute("mleaderalign", &json!({ "handles": [b.hex()], "to": a.hex(), "option": "parallel" })).unwrap();
|
||||
let (ra, rb) = (ml(&s, a), ml(&s, b));
|
||||
let da = (ra.landing.xy() - ra.leaders[0][0].xy()).normalized();
|
||||
let db = (rb.landing.xy() - rb.leaders[0][0].xy()).normalized();
|
||||
assert!((da.x * db.y - da.y * db.x).abs() < 1e-9);
|
||||
// Interactive.
|
||||
s.set_selection(Vec::new());
|
||||
s.start("mleaderalign").unwrap();
|
||||
s.input(Input::Pick(vec![b, c])).unwrap();
|
||||
s.input(Input::Enter).unwrap();
|
||||
s.input(Input::Pick(vec![a])).unwrap();
|
||||
s.input(Input::Point(Vec2::new(0.0, 0.0))).unwrap();
|
||||
s.input(Input::Point(Vec2::new(0.0, 1.0))).unwrap();
|
||||
assert!(s.running.is_none());
|
||||
assert!((ml(&s, b).landing.x - ml(&s, a).landing.x).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_blocks_into_one_multileader() {
|
||||
let mut s = Session::new();
|
||||
s.execute("line", &json!({ "points": [[0, 0], [2, 2]] })).unwrap();
|
||||
let l = s.doc().unwrap().model.iter().next().map(|e| e.handle).unwrap();
|
||||
s.execute("block", &json!({ "name": "B", "base": [0, 0], "handles": [l.hex()], "keep": "delete" })).unwrap();
|
||||
let mk = |s: &mut Session, x: f64| hd(&s.execute("mleader", &json!({ "points": [[x, 0], [x, 5]], "block": "B" })).unwrap());
|
||||
let (a, b, c) = (mk(&mut s, 10.0), mk(&mut s, 20.0), mk(&mut s, 30.0));
|
||||
let r =
|
||||
s.execute("mleadercollect", &json!({ "handles": [a.hex(), b.hex(), c.hex()], "at": [0, 20], "mode": "horizontal", "spacing": 1 })).unwrap();
|
||||
assert_eq!(r["collected"], 3);
|
||||
let m = ml(&s, a);
|
||||
assert_eq!(m.leaders.len(), 3, "all arrowheads on the collected multileader");
|
||||
assert!(s.doc().unwrap().entity(b).is_none() && s.doc().unwrap().entity(c).is_none());
|
||||
assert_eq!(r["blocks"].as_array().map(Vec::len), Some(2));
|
||||
// Wrap needs a width.
|
||||
assert!(s.execute("mleadercollect", &json!({ "handles": [a.hex()], "at": [0, 0], "mode": "wrap" })).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_mleader_edit_params_never_panic() {
|
||||
let mut s = Session::new();
|
||||
let h = text_ml(&mut s, [0.0, 0.0], [5.0, 5.0]);
|
||||
for (id, p) in [
|
||||
("mleaderedit", json!({ "handle": "zz", "points": [[1, 1]] })),
|
||||
("mleaderedit", json!({ "handle": h.hex(), "mode": "remove", "index": -1 })),
|
||||
("mleaderedit", json!({ "handle": h.hex(), "mode": "remove", "index": 1e300 })),
|
||||
("mleaderedit", json!({ "handle": h.hex(), "points": [[1e308, f64::MAX], "x"] })),
|
||||
("mleaderalign", json!({ "handles": [h.hex()], "to": h.hex(), "direction": [[0, 0], [0, 0]] })),
|
||||
("mleaderalign", json!({ "handles": [], "option": "distribute", "points": [[0, 0], [1, 1]] })),
|
||||
("mleaderalign", json!({ "handles": [h.hex()], "spacing": -1, "to": "7", "angle": 1e308 })),
|
||||
("mleaderalign", json!({ "handles": [h.hex(), "q"], "option": "nonsense" })),
|
||||
("mleadercollect", json!({ "handles": [h.hex()], "at": [0, 0] })),
|
||||
("mleadercollect", json!({ "handles": [h.hex(), h.hex()], "at": [1e308, 1e308], "mode": "wrap", "width": 0 })),
|
||||
] {
|
||||
let _ = s.execute(id, &p);
|
||||
}
|
||||
let _ = cadcraft_render::build(s.doc().unwrap(), &cadcraft_doc::Space::Model, &cadcraft_render::Options::default());
|
||||
}
|
||||
@@ -5,7 +5,7 @@ pub(crate) mod annoscale;
|
||||
mod annotate;
|
||||
mod array;
|
||||
mod audit;
|
||||
mod blocks;
|
||||
pub(crate) mod blocks;
|
||||
pub mod clipboard;
|
||||
pub mod constraints;
|
||||
mod draw;
|
||||
@@ -21,7 +21,9 @@ pub(crate) mod hatch;
|
||||
mod inquiry;
|
||||
mod layer;
|
||||
mod layout;
|
||||
mod lisp;
|
||||
mod mleader_content;
|
||||
mod mleader_edit;
|
||||
mod modify;
|
||||
mod modify2;
|
||||
mod mtext_opts;
|
||||
@@ -176,6 +178,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(draw::specs());
|
||||
v.extend(draw2::specs());
|
||||
v.extend(annotate::specs());
|
||||
v.extend(mleader_edit::specs());
|
||||
v.extend(annoscale::specs());
|
||||
v.extend(hatch::specs());
|
||||
v.extend(overkill::specs());
|
||||
@@ -198,6 +201,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(table::specs());
|
||||
v.extend(tolerance::specs());
|
||||
v.extend(constraints::specs());
|
||||
v.extend(lisp::specs());
|
||||
v
|
||||
})
|
||||
}
|
||||
|
||||
+170
-48
@@ -11,6 +11,7 @@ use super::helpers::*;
|
||||
use super::machines::{SelOutcome, SelectPhase, number};
|
||||
use super::trimextend::{self, TrimM};
|
||||
use super::*;
|
||||
use crate::select::{SelectKind, SelectMode};
|
||||
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -48,7 +49,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("stretch", "Stretch", run_stretch)
|
||||
.menu(&["Modify", "Stretch"])
|
||||
.alias(&["s"])
|
||||
.params("{window: [[x,y],[x,y]], delta: [dx,dy]}")
|
||||
.params("{window: [[x,y],[x,y]] | polygon: [[x,y],...] | fence: [[x,y],...], delta: [dx,dy]}")
|
||||
.interactive(|_| Ok(Box::new(StretchM::default()))),
|
||||
CommandSpec::new("offset", "Offset", run_offset)
|
||||
.menu(&["Modify", "Offset"])
|
||||
@@ -201,9 +202,26 @@ fn run_mirror(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "handles": r.iter().map(|h| h.hex()).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
/// Move the vertices of `e` that lie inside any of the windows `wins` by `d` (STRETCH).
|
||||
fn stretch_entity(e: &mut Entity, wins: &[cadcraft_geom::Bounds2], d: Vec2) {
|
||||
let inside = |p: Vec2| wins.iter().any(|bx| bx.contains(p));
|
||||
/// An area whose vertices STRETCH moves: a window's box, or a polygon closed back to its first
|
||||
/// point.
|
||||
#[derive(Clone)]
|
||||
enum StretchArea {
|
||||
Box(cadcraft_geom::Bounds2),
|
||||
Polygon(Vec<Vec2>),
|
||||
}
|
||||
|
||||
impl StretchArea {
|
||||
fn contains(&self, p: Vec2) -> bool {
|
||||
match self {
|
||||
StretchArea::Box(bx) => bx.contains(p),
|
||||
StretchArea::Polygon(poly) => cadcraft_geom::point_in_polygon(poly, p),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Move the vertices of `e` that lie inside any of the areas `wins` by `d` (STRETCH).
|
||||
fn stretch_entity(e: &mut Entity, wins: &[StretchArea], d: Vec2) {
|
||||
let inside = |p: Vec2| wins.iter().any(|a| a.contains(p));
|
||||
let mv = |p: &mut cadcraft_geom::Vec3| {
|
||||
if inside(p.xy()) {
|
||||
p.x += d.x;
|
||||
@@ -246,16 +264,34 @@ fn stretch_entity(e: &mut Entity, wins: &[cadcraft_geom::Bounds2], d: Vec2) {
|
||||
}
|
||||
|
||||
fn run_stretch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let w = points_param(p, "window").ok_or_else(|| bad("stretch", "`window` [[x,y],[x,y]] is required"))?;
|
||||
let (Some(a), Some(b)) = (w.first(), w.get(1)) else { return Err(bad("stretch", "window needs 2 corners")) };
|
||||
let bx = cadcraft_geom::Bounds2::new(*a, *b);
|
||||
let d = delta_of("stretch", p)?;
|
||||
let space = s.space();
|
||||
let mut hs = crate::select::select_window(s.doc()?, &space, bx, true);
|
||||
let listed = |key: &str, least: usize| -> Result<Option<Vec<Vec2>>> {
|
||||
let Some(pts) = points_param(p, key) else { return Ok(None) };
|
||||
if pts.len() < least || pts.len() > crate::select::MAX_MODE_POINTS || pts.iter().any(|q| !q.is_finite()) {
|
||||
return Err(bad("stretch", format!("`{key}` needs {least} to {} finite points", crate::select::MAX_MODE_POINTS)));
|
||||
}
|
||||
Ok(Some(pts))
|
||||
};
|
||||
// A fence marks no area: the objects it crosses move whole.
|
||||
if let Some(fence) = listed("fence", 2)? {
|
||||
let hs = crate::select::select_fence(s.doc()?, &space, &fence);
|
||||
let moved = transform_entities(s, &hs, &Mat3::translate(d), false)?;
|
||||
return Ok(json!({ "stretched": moved.len() }));
|
||||
}
|
||||
let (mut hs, area) = if let Some(poly) = listed("polygon", 3)? {
|
||||
(crate::select::select_polygon(s.doc()?, &space, &poly, true), StretchArea::Polygon(poly))
|
||||
} else {
|
||||
let w = points_param(p, "window").ok_or_else(|| bad("stretch", "`window` [[x,y],[x,y]], `polygon` or `fence` is required"))?;
|
||||
let (Some(a), Some(b)) = (w.first(), w.get(1)) else { return Err(bad("stretch", "window needs 2 corners")) };
|
||||
let bx = cadcraft_geom::Bounds2::new(*a, *b);
|
||||
(crate::select::select_window(s.doc()?, &space, bx, true), StretchArea::Box(bx))
|
||||
};
|
||||
hs.retain(|h| !super::curves::is_locked(s, *h));
|
||||
let doc = s.doc_mut()?;
|
||||
let areas = [area];
|
||||
for h in &hs {
|
||||
doc.modify_entity(*h, |e| stretch_entity(e, &[bx], d))?;
|
||||
doc.modify_entity(*h, |e| stretch_entity(e, &areas, d))?;
|
||||
}
|
||||
Ok(json!({ "stretched": hs.len() }))
|
||||
}
|
||||
@@ -270,20 +306,20 @@ enum StretchPhase {
|
||||
}
|
||||
|
||||
/// STRETCH: "Select objects" repeats until Enter. Crossing windows (implied right to left, or after
|
||||
/// `C`) and windows (left to right, or after `W`) select objects and mark the area whose vertices
|
||||
/// move; objects picked one at a time (or by All/Last/Previous) move whole. Then the base point or
|
||||
/// Displacement, and the second point.
|
||||
/// `C`), windows (left to right, or after `W`) and polygons (`CP`, `WP`) select objects and mark
|
||||
/// the area whose vertices move; objects picked one at a time (or by a fence, `F`, or
|
||||
/// All/Last/Previous) move whole. Then the base point or Displacement, and the second point.
|
||||
#[derive(Default)]
|
||||
struct StretchM {
|
||||
phase: StretchPhase,
|
||||
objs: Vec<Handle>,
|
||||
/// Objects selected without a window: they move whole.
|
||||
whole: Vec<Handle>,
|
||||
/// The selection windows: vertices inside any of them move.
|
||||
windows: Vec<cadcraft_geom::Bounds2>,
|
||||
/// The selection windows and polygons: vertices inside any of them move.
|
||||
windows: Vec<StretchArea>,
|
||||
removing: bool,
|
||||
/// `C` (true) or `W` (false) was typed: the next two points are that kind of window.
|
||||
mode: Option<bool>,
|
||||
/// `C`, `W`, `BOX`, `CP`, `WP` or `F` was typed: the next points are that kind of selection.
|
||||
mode: Option<SelectMode>,
|
||||
/// First corner of the window being drawn.
|
||||
corner: Option<Vec2>,
|
||||
base: Option<Vec2>,
|
||||
@@ -313,20 +349,63 @@ impl StretchM {
|
||||
s.set_selection(self.objs.clone());
|
||||
}
|
||||
|
||||
/// Select with a typed mode that has its points. With too few (a fence of one point, a polygon
|
||||
/// of two) the mode is dropped.
|
||||
fn finish_mode(&mut self, s: &mut Session, m: SelectMode) -> Result<()> {
|
||||
let space = s.space();
|
||||
let Some(hs) = m.select(s.doc()?, &space) else { return Ok(()) };
|
||||
let area = match (m.kind, m.pts.first().zip(m.pts.get(1))) {
|
||||
(SelectKind::WPolygon | SelectKind::CPolygon, _) => Some(StretchArea::Polygon(m.pts)),
|
||||
(SelectKind::Window | SelectKind::Crossing | SelectKind::Box, Some((a, b))) => {
|
||||
Some(StretchArea::Box(cadcraft_geom::Bounds2::new(*a, *b)))
|
||||
}
|
||||
// A fence marks no area: what it crosses moves whole.
|
||||
_ => None,
|
||||
};
|
||||
let whole = area.is_none();
|
||||
if let (Some(a), false) = (area, self.removing) {
|
||||
self.windows.push(a);
|
||||
}
|
||||
self.add(s, hs, whole);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn select(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
if let Some(mut m) = self.mode.take() {
|
||||
match &i {
|
||||
Input::Point(p) => {
|
||||
if m.push(*p) {
|
||||
self.finish_mode(s, m)?;
|
||||
} else {
|
||||
self.mode = Some(m);
|
||||
}
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
Input::Text(t) | Input::Keyword(t) if matches!(t.trim().to_ascii_lowercase().as_str(), "u" | "undo") => {
|
||||
m.pts.pop();
|
||||
self.mode = Some(m);
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
// Enter ends a fence or closes a polygon.
|
||||
Input::Enter => {
|
||||
self.finish_mode(s, m)?;
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
// Anything else leaves the mode and is read as usual.
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
match i {
|
||||
Input::Point(p) => match self.corner.take() {
|
||||
Some(c) => {
|
||||
let bx = cadcraft_geom::Bounds2::new(c, p);
|
||||
let crossing = self.mode.take().unwrap_or(p.x < c.x);
|
||||
let space = s.space();
|
||||
let hs = crate::select::select_window(s.doc()?, &space, bx, crossing);
|
||||
let hs = crate::select::select_window(s.doc()?, &space, bx, p.x < c.x);
|
||||
if !self.removing {
|
||||
self.windows.push(bx);
|
||||
self.windows.push(StretchArea::Box(bx));
|
||||
}
|
||||
self.add(s, hs, false);
|
||||
}
|
||||
None if self.mode.is_some() => self.corner = Some(p),
|
||||
None => {
|
||||
let aperture = s.pixel_size() * s.settings.pickbox.max(1.0) * 1.5;
|
||||
let space = s.space();
|
||||
@@ -341,15 +420,12 @@ impl StretchM {
|
||||
self.corner = None;
|
||||
let d = s.doc()?;
|
||||
let store = d.space(&s.space());
|
||||
let picked = match t.trim().to_ascii_lowercase().as_str() {
|
||||
"c" | "crossing" => {
|
||||
self.mode = Some(true);
|
||||
None
|
||||
}
|
||||
"w" | "window" => {
|
||||
self.mode = Some(false);
|
||||
None
|
||||
}
|
||||
let word = t.trim().to_ascii_lowercase();
|
||||
if let Some(m) = SelectMode::from_keyword(&word).filter(|m| m.kind != SelectKind::Group) {
|
||||
self.mode = Some(m);
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
let picked = match word.as_str() {
|
||||
"r" | "remove" => {
|
||||
self.removing = true;
|
||||
None
|
||||
@@ -361,10 +437,6 @@ impl StretchM {
|
||||
"all" => Some(store.map(|st| st.iter().filter(|e| d.is_visible(e)).map(|e| e.handle).collect()).unwrap_or_default()),
|
||||
"l" | "last" => Some(store.and_then(|st| st.last()).map(|e| vec![e.handle]).unwrap_or_default()),
|
||||
"p" | "previous" => Some(s.state()?.previous_selection.clone()),
|
||||
"cp" | "wp" | "f" | "fence" => {
|
||||
s.echo("Polygon and fence selection are not available yet in STRETCH; use a crossing window.");
|
||||
None
|
||||
}
|
||||
_ => {
|
||||
s.echo("*Invalid selection*");
|
||||
None
|
||||
@@ -374,10 +446,7 @@ impl StretchM {
|
||||
self.add(s, hs, true);
|
||||
}
|
||||
}
|
||||
Input::Enter if self.corner.is_some() || self.mode.is_some() => {
|
||||
self.corner = None;
|
||||
self.mode = None;
|
||||
}
|
||||
Input::Enter if self.corner.is_some() => self.corner = None,
|
||||
Input::Enter if self.objs.is_empty() => return Ok(Step::Done),
|
||||
Input::Enter => {
|
||||
s.remember_selection(&self.objs);
|
||||
@@ -413,9 +482,11 @@ impl Interactive for StretchM {
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
if let (StretchPhase::Select, Some(m)) = (self.phase, &self.mode) {
|
||||
return m.prompt();
|
||||
}
|
||||
match (self.phase, self.corner) {
|
||||
(StretchPhase::Select, Some(c)) => Prompt::new("Specify opposite corner", Accept::POINT).base(c),
|
||||
(StretchPhase::Select, None) if self.mode.is_some() => Prompt::new("Specify first corner", Accept::POINT),
|
||||
(StretchPhase::Select, None) => Prompt::new(if self.removing { "Remove objects" } else { "Select objects" }, Accept::POINT),
|
||||
(StretchPhase::Base, _) => Prompt::new("Specify base point", Accept::POINT).kw(&["Displacement"]).default("Displacement"),
|
||||
(StretchPhase::Displacement, _) => Prompt::new("Specify displacement", Accept::POINT).default("0,0"),
|
||||
@@ -446,11 +517,25 @@ impl Interactive for StretchM {
|
||||
}
|
||||
}
|
||||
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
match (self.phase, self.corner, self.base) {
|
||||
(StretchPhase::Select, Some(a), _) => {
|
||||
let (b, d) = (Vec2::new(c.x, a.y), Vec2::new(a.x, c.y));
|
||||
vec![line(a, b), line(b, c), line(c, d), line(d, a)]
|
||||
let rect = |a: Vec2| {
|
||||
let (b, d) = (Vec2::new(c.x, a.y), Vec2::new(a.x, c.y));
|
||||
vec![line(a, b), line(b, c), line(c, d), line(d, a)]
|
||||
};
|
||||
if let (StretchPhase::Select, Some(m)) = (self.phase, &self.mode) {
|
||||
let Some(first) = m.pts.first().copied() else { return Vec::new() };
|
||||
if !matches!(m.kind, SelectKind::Fence | SelectKind::WPolygon | SelectKind::CPolygon) {
|
||||
return rect(first);
|
||||
}
|
||||
// The fence or polygon so far, on to the cursor; a polygon closes back to its start.
|
||||
let mut out: Vec<EntityKind> = m.pts.windows(2).filter_map(|w| Some(line(*w.first()?, *w.get(1)?))).collect();
|
||||
out.extend(m.pts.last().map(|l| line(*l, c)));
|
||||
if m.kind != SelectKind::Fence && m.pts.len() >= 2 {
|
||||
out.push(line(c, first));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
match (self.phase, self.corner, self.base) {
|
||||
(StretchPhase::Select, Some(a), _) => rect(a),
|
||||
(StretchPhase::Second, _, Some(base)) => {
|
||||
let Ok(doc) = s.doc() else { return Vec::new() };
|
||||
let m = Mat3::translate(c - base);
|
||||
@@ -597,20 +682,40 @@ pub(crate) fn offset_polyline(pl: &Polyline, d: f64) -> Option<Vec<PolyVertex>>
|
||||
return None;
|
||||
}
|
||||
let n = segs.len();
|
||||
let mut verts: Vec<PolyVertex> = Vec::new();
|
||||
// Where each offset segment starts after joining it to the previous one.
|
||||
let mut starts: Vec<Vec2> = Vec::with_capacity(n);
|
||||
for i in 0..n {
|
||||
let s = segs.get(i)?;
|
||||
let start = if i == 0 && !pl.closed {
|
||||
starts.push(if i == 0 && !pl.closed {
|
||||
s.start()
|
||||
} else {
|
||||
let prev = segs.get((i + n - 1) % n)?;
|
||||
join_point(prev, s).unwrap_or(s.start())
|
||||
});
|
||||
}
|
||||
let mut verts: Vec<PolyVertex> = Vec::new();
|
||||
for i in 0..n {
|
||||
let s = segs.get(i)?;
|
||||
let start = *starts.get(i)?;
|
||||
let end = if i + 1 < n {
|
||||
*starts.get(i + 1)?
|
||||
} else if pl.closed {
|
||||
*starts.first()?
|
||||
} else {
|
||||
s.end()
|
||||
};
|
||||
let bulge = match s {
|
||||
Segment::Line(_) => 0.0,
|
||||
Segment::Arc { arc, ccw } => {
|
||||
let sw = arc.sweep();
|
||||
cadcraft_geom::arc_to_bulge(if *ccw { sw } else { -sw })
|
||||
// The joined endpoints may have moved along the circle: take the sweep between them.
|
||||
let sweep = if start.near(end, 1e-9) {
|
||||
arc.sweep()
|
||||
} else if *ccw {
|
||||
ccw_sweep(arc.center.angle_to(start), arc.center.angle_to(end))
|
||||
} else {
|
||||
ccw_sweep(arc.center.angle_to(end), arc.center.angle_to(start))
|
||||
};
|
||||
cadcraft_geom::arc_to_bulge(if *ccw { sweep } else { -sweep })
|
||||
}
|
||||
};
|
||||
verts.push(PolyVertex { p: start, bulge, start_width: 0.0, end_width: 0.0 });
|
||||
@@ -618,7 +723,6 @@ pub(crate) fn offset_polyline(pl: &Polyline, d: f64) -> Option<Vec<PolyVertex>>
|
||||
if !pl.closed {
|
||||
verts.push(PolyVertex::new(segs.last()?.end()));
|
||||
}
|
||||
// Recompute arc bulges where endpoints moved (approximation: keep sweep sign).
|
||||
Some(verts)
|
||||
}
|
||||
|
||||
@@ -910,12 +1014,18 @@ fn trim_polyline(p: &cadcraft_doc::LwPolyline, cut: &[Segment], pick: Vec2) -> R
|
||||
let mut out = Vec::new();
|
||||
if p.closed {
|
||||
// Keep the complementary run from hi around to lo.
|
||||
// The pick lies before the first cut or after the last: the piece wraps past the start
|
||||
// vertex, so the run to keep is the stretch between the first and the last cut.
|
||||
let (lo, hi) = (
|
||||
if lo.is_finite() { lo } else { ts.last().copied().unwrap_or(0.0) - total },
|
||||
if hi.is_finite() { hi } else { ts.first().copied().unwrap_or(total) + total },
|
||||
);
|
||||
let doubled: Vec<Segment> = segs.iter().chain(segs.iter()).copied().collect();
|
||||
let (a, b) = if hi > total { (hi - total, lo + total) } else { (hi, lo + total) };
|
||||
let (a, mut b) = if hi > total { (hi - total, lo) } else { (hi, lo + total) };
|
||||
// A single cut leaves the whole outline, opened there.
|
||||
if b <= a + 1e-9 {
|
||||
b += total;
|
||||
}
|
||||
out.push(mk(sub_polyline(&doubled, a, b)));
|
||||
} else {
|
||||
if lo.is_finite() {
|
||||
@@ -1295,9 +1405,15 @@ fn explode(s: &mut Session, hs: &[Handle]) -> Result<Vec<Handle>> {
|
||||
}
|
||||
let Some(e) = s.doc()?.entity(*h).map(|e| (**e).clone()) else { continue };
|
||||
let Some(parts) = explode_kind(s.doc()?, &e) else { continue };
|
||||
// The pieces of an object are new objects: its xdata goes with it. A block reference's
|
||||
// pieces are copies of the block's objects and keep theirs.
|
||||
let own_pieces = !matches!(e.kind, EntityKind::Insert(_));
|
||||
let d = s.doc_mut()?;
|
||||
d.remove_entity(*h);
|
||||
for mut p in parts {
|
||||
if own_pieces {
|
||||
p.common.xdata.clear();
|
||||
}
|
||||
p.handle = d.new_handle();
|
||||
out.push(p.handle);
|
||||
if let Some(st) = d.space_mut(&space) {
|
||||
@@ -1317,6 +1433,9 @@ fn run_explode(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
/// Break `h` between two points (or at one point when p1 == p2).
|
||||
pub(crate) fn break_entity(s: &mut Session, h: Handle, p1: Vec2, p2: Vec2) -> Result<Vec<Handle>> {
|
||||
if super::curves::is_locked(s, h) {
|
||||
return Err(EngineError::Other("The object is on a locked layer.".into()));
|
||||
}
|
||||
let e = s.doc()?.entity(h).map(|e| (**e).clone()).ok_or_else(|| EngineError::Other("no such object".into()))?;
|
||||
let pieces: Vec<EntityKind> = match &e.kind {
|
||||
EntityKind::Line(l) => {
|
||||
@@ -1426,6 +1545,9 @@ pub(crate) fn join(s: &mut Session, hs: &[Handle]) -> Result<Option<Handle>> {
|
||||
if let Some(h) = super::modify2::join_curves(s, hs)? {
|
||||
return Ok(Some(h));
|
||||
}
|
||||
if hs.iter().any(|h| super::curves::is_locked(s, *h)) {
|
||||
return Err(EngineError::Other("An object is on a locked layer.".into()));
|
||||
}
|
||||
let d = s.doc()?;
|
||||
let mut segs: Vec<Segment> = Vec::new();
|
||||
// Source entity of each segment in `segs`, so only entities that end up in the chain are removed.
|
||||
|
||||
@@ -454,10 +454,17 @@ impl Interactive for PeditM {
|
||||
let sel = s.selection();
|
||||
if let [h] = sel.as_slice() {
|
||||
let k = curves::entity(s, *h)?.kind;
|
||||
if matches!(k, EntityKind::LwPolyline(_)) {
|
||||
self.h = Some(*h);
|
||||
self.original = self.poly(s).map(|p| p.vertices);
|
||||
self.phase = PPhase::Main;
|
||||
match k {
|
||||
EntityKind::LwPolyline(_) => {
|
||||
self.h = Some(*h);
|
||||
self.original = self.poly(s).map(|p| p.vertices);
|
||||
self.phase = PPhase::Main;
|
||||
}
|
||||
EntityKind::Line(_) | EntityKind::Arc(_) => {
|
||||
self.h = Some(*h);
|
||||
self.phase = PPhase::Convert;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
@@ -1308,6 +1315,9 @@ pub(crate) fn flatten_kind(k: &mut EntityKind) {
|
||||
EntityKind::AttDef(a) => {
|
||||
z(&mut a.text.insert);
|
||||
a.text.align_pt.iter_mut().for_each(z);
|
||||
if let Some(ml) = &mut a.multiline {
|
||||
z(&mut ml.mtext.insert);
|
||||
}
|
||||
}
|
||||
EntityKind::MText(t) => z(&mut t.insert),
|
||||
EntityKind::Insert(i) => {
|
||||
@@ -1315,6 +1325,9 @@ pub(crate) fn flatten_kind(k: &mut EntityKind) {
|
||||
for a in &mut i.attribs {
|
||||
z(&mut a.text.insert);
|
||||
a.text.align_pt.iter_mut().for_each(z);
|
||||
if let Some(ml) = &mut a.multiline {
|
||||
z(&mut ml.mtext.insert);
|
||||
}
|
||||
}
|
||||
}
|
||||
EntityKind::Dimension(d) => {
|
||||
@@ -1445,7 +1458,7 @@ fn chspace(s: &mut Session, hs: &[Handle], to: Option<&str>, vp: Option<Handle>)
|
||||
fn run_chspace(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let hs = targets(s, p)?;
|
||||
let (n, _, target) = chspace(s, &hs, str_param(p, "to"), curves::handle_param(p, "viewport"))?;
|
||||
Ok(json!({ "moved": n, "to": match target { Space::Model => "model".to_string(), Space::Paper(n) => n } }))
|
||||
Ok(json!({ "moved": n, "to": target.layout().unwrap_or("model") }))
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
|
||||
@@ -228,6 +228,9 @@ fn ends(sp: &Spline) -> (Vec2, Vec2) {
|
||||
/// larger arc or the full ellipse (gaps between them close). Otherwise the objects joined end
|
||||
/// to end with the first one become one spline. The first joined object keeps its handle.
|
||||
pub(crate) fn join_curves(s: &mut Session, hs: &[Handle]) -> Result<Option<Handle>> {
|
||||
// A handle named twice is one object: a repeat would join it with itself or remove the result.
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let hs: Vec<Handle> = hs.iter().copied().filter(|h| seen.insert(*h)).collect();
|
||||
let d = s.doc()?;
|
||||
let ents: Vec<Entity> = hs.iter().filter_map(|h| d.entity(*h).map(|e| (**e).clone())).collect();
|
||||
if !ents.iter().any(|e| matches!(e.kind, EntityKind::Ellipse(_) | EntityKind::Spline(_))) {
|
||||
|
||||
@@ -683,3 +683,41 @@ fn editing_commands_leave_locked_layer_objects_alone() {
|
||||
s.execute("explode", &json!({"handles": [r]})).unwrap();
|
||||
assert_ne!(model_dump(&s), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pedit_preselected_line_asks_to_convert() {
|
||||
// Yes converts the pre-selected line and continues with the option prompt.
|
||||
let mut s = Session::new();
|
||||
let l = h(&s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap());
|
||||
s.set_selection(vec![Handle::parse_hex(&l).unwrap()]);
|
||||
s.cmdline("pedit").unwrap();
|
||||
assert!(s.running.is_some());
|
||||
assert!(matches!(kind(&s, &l), EntityKind::Line(_)), "nothing changes before the answer");
|
||||
s.cmdline("y").unwrap();
|
||||
assert!(matches!(kind(&s, &l), EntityKind::LwPolyline(_)));
|
||||
s.cmdline("c").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
assert!(s.running.is_none());
|
||||
|
||||
// No ends the command and leaves the line alone.
|
||||
let m = h(&s.execute("line", &json!({"points": [[0, 5], [10, 5]]})).unwrap());
|
||||
s.set_selection(vec![Handle::parse_hex(&m).unwrap()]);
|
||||
s.cmdline("pedit").unwrap();
|
||||
s.cmdline("n").unwrap();
|
||||
assert!(s.running.is_none());
|
||||
assert!(matches!(kind(&s, &m), EntityKind::Line(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_polyline_arc_bulge_follows_joined_endpoints() {
|
||||
let mut s = Session::new();
|
||||
// A diagonal line into a CCW semicircle centred on (10, 5); offsetting inward moves the arc start.
|
||||
let pl = pline(&mut s, json!([[0, -5], {"p": [10, 0], "bulge": 1}, [10, 10]]), false);
|
||||
s.execute("offset", &json!({"handle": pl, "distance": 1, "side": [12, 5]})).unwrap();
|
||||
let p = poly(&last(&s));
|
||||
let (a, b) = (p.vertices[1], p.vertices[2]);
|
||||
let (arc, ccw) = cadcraft_geom::bulge_to_arc(a.p, b.p, a.bulge).unwrap();
|
||||
assert!(ccw);
|
||||
assert!(near(arc.center, 10.0, 5.0), "arc centre drifted to {:?}", arc.center);
|
||||
assert!((arc.radius - 4.0).abs() < 1e-6, "radius {}", arc.radius);
|
||||
}
|
||||
@@ -364,9 +364,9 @@ fn trim(v: f64) -> String {
|
||||
|
||||
/// The JSON form's paragraph formatting: `list` (`bullet`, `number`, `lower`, `upper`: each
|
||||
/// paragraph labelled, with a hanging `indent`), or `indent` / `firstIndent` / `rightIndent`
|
||||
/// (text heights), `tabs` (tab stops in text heights) and `align` (`left`, `center`, `right`,
|
||||
/// (drawing units; a list's default is 3 text heights), `tabs` (tab stops, drawing units) and `align` (`left`, `center`, `right`,
|
||||
/// `justify`, `distributed`) as a `\p` code in front of the text.
|
||||
pub(super) fn json_paragraphs(p: &serde_json::Value, text: String) -> Result<String> {
|
||||
pub(super) fn json_paragraphs(p: &serde_json::Value, text: String, height: f64) -> Result<String> {
|
||||
use cadcraft_fonts::{ListStyle, ParaAlign, ParaProps, TabKind};
|
||||
let bad = |m: &str| super::bad("mtext", m);
|
||||
let num = |k: &str| -> Result<Option<f64>> {
|
||||
@@ -384,7 +384,7 @@ pub(super) fn json_paragraphs(p: &serde_json::Value, text: String) -> Result<Str
|
||||
_ => return Err(bad("`list` must be bullet, number, lower or upper")),
|
||||
};
|
||||
let paras: Vec<&str> = text.split("\\P").collect();
|
||||
return Ok(cadcraft_fonts::list_contents(¶s, style, num("indent")?.unwrap_or(3.0)));
|
||||
return Ok(cadcraft_fonts::list_contents(¶s, style, num("indent")?.unwrap_or(3.0 * height)));
|
||||
}
|
||||
let mut props = ParaProps {
|
||||
left: num("indent")?.unwrap_or(0.0).max(0.0),
|
||||
|
||||
@@ -33,6 +33,17 @@ pub const PROFILE_VARS: &[&str] = &[
|
||||
"OSNAPHATCH",
|
||||
"LWDISPLAY",
|
||||
"QPMODE",
|
||||
"SELECTIONEFFECT",
|
||||
"SELECTIONPREVIEW",
|
||||
"PREVIEWEFFECT",
|
||||
"PREVIEWFILTER",
|
||||
"GRIPS",
|
||||
"GRIPOBJLIMIT",
|
||||
"SELECTIONAREA",
|
||||
"WINDOWAREACOLOR",
|
||||
"CROSSINGAREACOLOR",
|
||||
"SELECTIONAREAOPACITY",
|
||||
"PICKSTYLE",
|
||||
"PICKBOX",
|
||||
"APERTURE",
|
||||
"GRIPSIZE",
|
||||
|
||||
@@ -286,9 +286,9 @@ pub(crate) fn plot_options(s: &Session, p: &Value, cmd: &str) -> Result<Value> {
|
||||
let layout = match str_param(p, "layout") {
|
||||
Some(n) if n.eq_ignore_ascii_case("model") => None,
|
||||
Some(n) => s.doc()?.layout(n).map(|l| l.page.clone()),
|
||||
None => match s.space() {
|
||||
cadcraft_doc::Space::Paper(n) => s.doc()?.layout(&n).map(|l| l.page.clone()),
|
||||
cadcraft_doc::Space::Model => None,
|
||||
None => match s.space().layout() {
|
||||
Some(n) => s.doc()?.layout(n).map(|l| l.page.clone()),
|
||||
None => None,
|
||||
},
|
||||
};
|
||||
let dev = match given {
|
||||
@@ -326,6 +326,16 @@ pub(crate) fn plot_options(s: &Session, p: &Value, cmd: &str) -> Result<Value> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Whether a plot draws transparency, as PLOTTRANSPARENCYOVERRIDE decides: 0 never, 2 always,
|
||||
/// otherwise the plot's `plotTransparency` (`None` when it has none: the page setup decides).
|
||||
pub(crate) fn plot_transparency(s: &Session, p: &Value) -> Option<bool> {
|
||||
match s.settings.plot_transparency_override {
|
||||
0 => Some(false),
|
||||
2 => Some(true),
|
||||
_ => p.get("plotTransparency").and_then(Value::as_bool),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- PLOTWINDOW ----------
|
||||
|
||||
fn run_plotwindow(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
@@ -341,6 +341,12 @@ fn run_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
q.y = r.y;
|
||||
}
|
||||
t.rotation = cadcraft_geom::norm_angle(t.rotation + turn);
|
||||
if let Some(ml) = &mut a.multiline {
|
||||
let r = ml.mtext.insert.xy().rotate_about(base, turn);
|
||||
ml.mtext.insert.x = r.x;
|
||||
ml.mtext.insert.y = r.y;
|
||||
ml.mtext.rotation = cadcraft_geom::norm_angle(ml.mtext.rotation + turn);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(x) = num("scale").filter(|x| *x != 0.0) {
|
||||
@@ -353,6 +359,7 @@ fn run_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let EntityKind::Text(t) = k {
|
||||
a.text = t;
|
||||
}
|
||||
a.transform_multiline(&m);
|
||||
}
|
||||
}
|
||||
i.scale = Vec3::new(x, x, x);
|
||||
|
||||
@@ -178,7 +178,9 @@ pub(crate) fn zoom(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
_ => return Err(bad("zoom", "unknown mode")),
|
||||
}
|
||||
s.touch();
|
||||
// No `touch`: a zoom changes the view, not the drawing. The canvas follows the view by
|
||||
// itself and regenerates in the background (AutoCAD redraws instead of regenerating);
|
||||
// zooming an unlocked viewport changes the drawing, which shows on its own.
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
@@ -265,7 +267,9 @@ fn run_view_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if h <= 0.0 {
|
||||
return Err(bad("view.set", "height must be positive"));
|
||||
}
|
||||
s.state_mut()?.set_view(View { center: c, height: h });
|
||||
if !s.state_mut()?.set_view(View { center: c, height: h }) {
|
||||
return Err(bad("view.set", "the view is too large to show. Nothing was changed"));
|
||||
}
|
||||
ok()
|
||||
}
|
||||
|
||||
@@ -277,6 +281,7 @@ fn run_layout_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let l = s.doc()?.layout(name).ok_or_else(|| bad("layout.set", format!("no layout `{name}`")))?;
|
||||
Space::Paper(l.name.clone())
|
||||
};
|
||||
s.pending_window = None;
|
||||
let st = s.state_mut()?;
|
||||
st.space = space.clone();
|
||||
st.mspace = None;
|
||||
@@ -382,7 +387,6 @@ impl ZoomM {
|
||||
let extents = if ext.is_empty() { s.zoom_limits()? } else { ext };
|
||||
let all = extents.union(&DynBox::rect(frame.center, frame.height, aspect));
|
||||
fit(s, all.expand(all.height().max(all.width()) * 0.05))?;
|
||||
s.touch();
|
||||
self.stage = ZoomStage::Dynamic(DynBox { screen, frame, extents, center: frame.center, height: frame.height, aspect, sizing: false });
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
@@ -420,7 +424,7 @@ impl Interactive for ZoomM {
|
||||
.kw(&["All", "Center", "Dynamic", "Extents", "Previous", "Scale", "Window", "Object"])
|
||||
.default("real time"),
|
||||
(true, None) => Prompt::new("Specify first corner", Accept::POINT),
|
||||
(_, Some(a)) => Prompt::new("Specify opposite corner", Accept::POINT).base(a),
|
||||
(_, Some(a)) => Prompt::new("Specify opposite corner", Accept::POINT).corner(a),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
@@ -437,7 +441,6 @@ impl Interactive for ZoomM {
|
||||
st.view_history.pop();
|
||||
}
|
||||
st.set_zoom_frame(View { center: b.center, height: b.height });
|
||||
s.touch();
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
(ZoomStage::Dynamic(mut b), Input::Point(p)) => {
|
||||
|
||||
@@ -152,10 +152,10 @@ fn restore(s: &mut Session, name: &str) -> Result<Value> {
|
||||
match &v.layout {
|
||||
Some(l) => {
|
||||
// A paper space view: its layout (an unknown one: the current or first layout).
|
||||
let target = match (l.is_empty(), s.layout_space()) {
|
||||
let target = match (l.is_empty(), s.layout_space().layout()) {
|
||||
(false, _) => l.clone(),
|
||||
(true, Space::Paper(cur)) => cur,
|
||||
(true, Space::Model) => s.doc()?.layouts.iter().min_by_key(|x| x.tab_order).map(|x| x.name.clone()).unwrap_or_default(),
|
||||
(true, Some(cur)) => cur.to_string(),
|
||||
(true, None) => s.doc()?.layouts.iter().min_by_key(|x| x.tab_order).map(|x| x.name.clone()).unwrap_or_default(),
|
||||
};
|
||||
if s.doc()?.layout(&target).is_none() {
|
||||
return Err(EngineError::Other(format!("View \"{}\" belongs to layout \"{target}\", which does not exist.", v.name)));
|
||||
|
||||
@@ -22,7 +22,7 @@ pub(crate) struct Guarded {
|
||||
broken: Cell<Option<&'static str>>,
|
||||
}
|
||||
|
||||
fn message(p: &(dyn Any + Send)) -> String {
|
||||
pub(crate) fn message(p: &(dyn Any + Send)) -> String {
|
||||
p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_else(|| "panic".into())
|
||||
}
|
||||
|
||||
@@ -84,6 +84,15 @@ impl Interactive for Guarded {
|
||||
None
|
||||
})
|
||||
}
|
||||
fn editing_mtext(&self) -> Option<cadcraft_doc::MText> {
|
||||
if self.broken.get().is_some() {
|
||||
return None;
|
||||
}
|
||||
catch_unwind(AssertUnwindSafe(|| self.inner.editing_mtext())).unwrap_or_else(|_| {
|
||||
self.broken.set(Some("panic in prompt"));
|
||||
None
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+328
-31
@@ -11,11 +11,14 @@
|
||||
pub mod assoc;
|
||||
pub mod audit;
|
||||
pub mod autosave;
|
||||
pub mod blockedit;
|
||||
pub mod cmd;
|
||||
mod finite;
|
||||
pub mod grip_dims;
|
||||
pub mod grips;
|
||||
mod guard;
|
||||
pub mod lisp;
|
||||
pub mod mtext_edit;
|
||||
pub mod pointmod;
|
||||
pub mod prompt;
|
||||
mod recent;
|
||||
@@ -37,7 +40,7 @@ use serde_json::Value;
|
||||
pub use cadcraft_doc as doc;
|
||||
pub use cadcraft_geom as geom;
|
||||
pub use cadcraft_render as render;
|
||||
pub use cmd::hatch::{PatternChoice, pattern_choices};
|
||||
pub use cmd::hatch::{HatchPicks, PatternChoice, PickMode, pattern_choices};
|
||||
pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command};
|
||||
pub use prompt::{Accept, Drag, Input, Interactive, Picker, Prompt, Step};
|
||||
|
||||
@@ -79,6 +82,10 @@ impl Default for View {
|
||||
}
|
||||
}
|
||||
|
||||
fn usable_view(v: &View) -> bool {
|
||||
v.center.is_finite() && v.height.is_finite() && v.height > 1e-12
|
||||
}
|
||||
|
||||
/// Tiled model viewports (VPORTS): the session side of the drawing's `*Active` configuration.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct ModelTiles {
|
||||
@@ -132,6 +139,9 @@ pub struct DocState {
|
||||
pub uid: u64,
|
||||
/// The drawing is saved as binary DXF (it was opened from one, or last saved as one).
|
||||
pub binary_dxf: bool,
|
||||
/// The Block Editor (BEDIT), when it is open: `doc` is then the editor's drawing, and the
|
||||
/// drawing itself waits in it ([`DocState::drawing`]).
|
||||
pub block_edit: Option<blockedit::BlockEdit>,
|
||||
}
|
||||
|
||||
static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||
@@ -157,10 +167,13 @@ impl DocState {
|
||||
view_history: Vec::new(),
|
||||
uid: NEXT_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
|
||||
binary_dxf: false,
|
||||
block_edit: None,
|
||||
}
|
||||
}
|
||||
/// The drawing has changes not saved to its file, or the Block Editor has changes not yet
|
||||
/// saved into its block.
|
||||
pub fn is_dirty(&self) -> bool {
|
||||
!Arc::ptr_eq(&self.doc, &self.saved)
|
||||
!Arc::ptr_eq(self.drawing(), &self.saved) || self.block_unsaved()
|
||||
}
|
||||
/// After undo/redo replaced the drawing (`before` is the one shown until then): if the current
|
||||
/// layout no longer exists, follow it to its other name when it was renamed (same tab order,
|
||||
@@ -220,7 +233,9 @@ impl DocState {
|
||||
}
|
||||
}
|
||||
/// Map a requested screen view to the paper view, or (in MSPACE) to the viewport's model view.
|
||||
fn store_view(&mut self, v: View) {
|
||||
/// A finite request can still map to a view too large to represent: then nothing changes and
|
||||
/// this returns false.
|
||||
fn store_view(&mut self, v: View) -> bool {
|
||||
if let Some((h, vp)) = self.active_viewport() {
|
||||
let pv = self.paper_view();
|
||||
let k = v.height / pv.height.max(1e-12);
|
||||
@@ -228,10 +243,17 @@ impl DocState {
|
||||
// A locked viewport keeps its scale: pan/zoom the sheet instead.
|
||||
let k0 = vp.view_height / vp.height;
|
||||
let pc = vp.center.xy() + (v.center - vp.view_center) / k0;
|
||||
self.put_paper_view(View { center: pc, height: v.height / k0 });
|
||||
let paper = View { center: pc, height: v.height / k0 };
|
||||
if !usable_view(&paper) {
|
||||
return false;
|
||||
}
|
||||
self.put_paper_view(paper);
|
||||
} else {
|
||||
let center = v.center - (pv.center - vp.center.xy()) * k;
|
||||
let height = vp.height * k;
|
||||
if !usable_view(&View { center, height }) {
|
||||
return false;
|
||||
}
|
||||
let doc = Arc::make_mut(&mut self.doc);
|
||||
if let Some(store) = doc.space_mut(&self.space.clone()) {
|
||||
store.modify(h, |e| {
|
||||
@@ -242,9 +264,10 @@ impl DocState {
|
||||
});
|
||||
}
|
||||
}
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
self.put_paper_view(v);
|
||||
true
|
||||
}
|
||||
fn put_paper_view(&mut self, v: View) {
|
||||
match self.views.iter_mut().find(|(s, _)| *s == self.space) {
|
||||
@@ -252,18 +275,23 @@ impl DocState {
|
||||
None => self.views.push((self.space.clone(), v)),
|
||||
}
|
||||
}
|
||||
pub fn set_view(&mut self, v: View) {
|
||||
if !(v.center.is_finite() && v.height.is_finite() && v.height > 1e-12) {
|
||||
return;
|
||||
/// Set the view, recording view history. Returns false, changing nothing, when the view (or,
|
||||
/// in MSPACE, the viewport view it maps to) isn't finite with a positive height.
|
||||
pub fn set_view(&mut self, v: View) -> bool {
|
||||
if !usable_view(&v) {
|
||||
return false;
|
||||
}
|
||||
let old = self.view();
|
||||
if !self.store_view(v) {
|
||||
return false;
|
||||
}
|
||||
if old != v {
|
||||
self.view_history.push(old);
|
||||
if self.view_history.len() > 50 {
|
||||
self.view_history.remove(0);
|
||||
}
|
||||
}
|
||||
self.store_view(v);
|
||||
true
|
||||
}
|
||||
/// The active MSPACE viewport when ZOOM works on its own view, as in AutoCAD: extents and
|
||||
/// windows fit the viewport, and zooms keep its centre. A locked viewport keeps its scale, so
|
||||
@@ -345,7 +373,11 @@ pub struct Settings {
|
||||
/// prompt's options there (on by default).
|
||||
pub dynprompt: bool,
|
||||
pub lwdisplay: bool,
|
||||
/// TRANSPARENCYDISPLAY: draw objects with their transparency on screen (on by default).
|
||||
pub transparency_display: bool,
|
||||
/// PLOTTRANSPARENCYOVERRIDE: 0 never plots transparency, 1 (default) uses the page setup's or
|
||||
/// the Plot dialog's "Plot transparency", 2 always plots it.
|
||||
pub plot_transparency_override: u8,
|
||||
pub selection_cycling: bool,
|
||||
/// SELECTSIMILARMODE: what SELECTSIMILAR matches besides the object type (bits: 1 colour,
|
||||
/// 2 layer, 4 linetype, 8 linetype scale, 16 lineweight, 32 plot style, 64 object style,
|
||||
@@ -357,6 +389,8 @@ pub struct Settings {
|
||||
/// APERTURE in pixels.
|
||||
pub aperture: f64,
|
||||
pub pickfirst: bool,
|
||||
/// ACADLSPASDOC: acad.lsp loads into every drawing, not once per session.
|
||||
pub acadlspasdoc: bool,
|
||||
pub pickadd: bool,
|
||||
/// PICKSTYLE: bit 1 picks whole selectable groups ([`cmd::groups`]), bit 2 associative hatches
|
||||
/// with their boundaries (stored, not used yet).
|
||||
@@ -373,6 +407,32 @@ pub struct Settings {
|
||||
pub annoautoscale: bool,
|
||||
/// QPMODE: show the Quick Properties palette when objects are selected.
|
||||
pub qpmode: bool,
|
||||
/// HIGHLIGHT: selected objects are highlighted (their grips show either way). Not saved:
|
||||
/// every session starts with it on, as in AutoCAD, so it can't stay off unnoticed.
|
||||
pub highlight: bool,
|
||||
/// SELECTIONEFFECT: how selected objects are highlighted: 0 dashed, 1 redrawn in the
|
||||
/// selection colour.
|
||||
pub selectioneffect: u8,
|
||||
/// SELECTIONPREVIEW bits: the object under the cursor is highlighted (1) when no command is
|
||||
/// active, (2) when a command asks for objects.
|
||||
pub selectionpreview: u8,
|
||||
/// PREVIEWEFFECT: how that object is highlighted: 0 dashed, 1 thicker, 2 dashed and thicker.
|
||||
pub previeweffect: u8,
|
||||
/// PREVIEWFILTER bits: objects the cursor never highlights: 1 on locked layers, 2 xrefs,
|
||||
/// 4 tables, 8 multiline text, 16 hatches, 32 in groups.
|
||||
pub previewfilter: u8,
|
||||
/// GRIPS: selected objects show their grips (0 off; AutoCAD's 2, more grips on polylines,
|
||||
/// shows them as 1 does).
|
||||
pub grips: u8,
|
||||
/// GRIPOBJLIMIT: no grips while more objects than this are selected (0: no limit).
|
||||
pub gripobjlimit: u32,
|
||||
/// SELECTIONAREA: the selection window and crossing areas are filled.
|
||||
pub selectionarea: bool,
|
||||
/// WINDOWAREACOLOR, CROSSINGAREACOLOR: their colours (ACI 1-255).
|
||||
pub windowareacolor: u8,
|
||||
pub crossingareacolor: u8,
|
||||
/// SELECTIONAREAOPACITY: their opacity, 0-100.
|
||||
pub selectionareaopacity: u8,
|
||||
/// POLARMODE bits ([`snap::tracking::polarmode`]); polar tracking itself is on while
|
||||
/// `polarmode` is (AUTOSNAP bit 8, F10).
|
||||
pub polar_flags: u32,
|
||||
@@ -407,13 +467,15 @@ impl Default for Settings {
|
||||
dynpi_absolute: false,
|
||||
dynprompt: true,
|
||||
lwdisplay: false,
|
||||
transparency_display: false,
|
||||
transparency_display: true,
|
||||
plot_transparency_override: 1,
|
||||
selection_cycling: false,
|
||||
selectsimilarmode: 130,
|
||||
otrack: true,
|
||||
pickbox: 3.0,
|
||||
aperture: 10.0,
|
||||
pickfirst: true,
|
||||
acadlspasdoc: false,
|
||||
pickadd: true,
|
||||
pickstyle: 1,
|
||||
gripsize: 5.0,
|
||||
@@ -424,6 +486,17 @@ impl Default for Settings {
|
||||
annoallvisible: true,
|
||||
annoautoscale: false,
|
||||
qpmode: false,
|
||||
highlight: true,
|
||||
selectioneffect: 1,
|
||||
selectionpreview: 3,
|
||||
previeweffect: 2,
|
||||
previewfilter: 3,
|
||||
grips: 1,
|
||||
gripobjlimit: 100,
|
||||
selectionarea: true,
|
||||
windowareacolor: 150,
|
||||
crossingareacolor: 100,
|
||||
selectionareaopacity: 25,
|
||||
polar_flags: 0,
|
||||
polaraddang: Vec::new(),
|
||||
polarsnap: false,
|
||||
@@ -555,6 +628,11 @@ pub struct Session {
|
||||
/// The last segment given at a point prompt (its base point and the point): polar angles
|
||||
/// relative to the last segment (POLARMODE bit 1) are measured from it.
|
||||
pub last_segment: Option<(Vec2, Vec2)>,
|
||||
/// The last typed `@distance<angle` input, as `(distance, angle in radians)`.
|
||||
/// Set by [`Session::typed`] and consumed by commands that resolve a deferred
|
||||
/// tangent first point (e.g. [`cmd::draw::LineM`]) when the next point is typed
|
||||
/// rather than clicked. Cleared at [`Session::start`].
|
||||
pub last_polar: Option<(f64, f64)>,
|
||||
/// Point modifiers pending at the current point prompt (`FROM`, `M2P`, `.x`, `<a`…).
|
||||
pub point_mods: Vec<pointmod::Frame>,
|
||||
/// Cursor position in world coordinates (from the UI; used for direct distance entry).
|
||||
@@ -587,6 +665,12 @@ pub struct Session {
|
||||
pub clipboard_source: Option<cmd::clipboard::ClipSource>,
|
||||
/// The window last picked with PLOTWINDOW (the Plot dialog's "Window <").
|
||||
pub plot_window: Option<cadcraft_geom::Bounds2>,
|
||||
/// AutoLISP: each drawing's symbols and the routine running ([`lisp`]).
|
||||
lisp: lisp::LispState,
|
||||
/// How the last LISP run ended (its value or error message).
|
||||
lisp_last: Option<std::result::Result<lisp::value::Value, String>>,
|
||||
/// What the last HATCHPICK kept (the Hatch and Gradient dialog's picks).
|
||||
pub hatch_picks: Option<cmd::hatch::HatchPicks>,
|
||||
}
|
||||
|
||||
impl Default for Session {
|
||||
@@ -614,6 +698,7 @@ impl Session {
|
||||
last_command: None,
|
||||
last_point: Vec2::ZERO,
|
||||
last_segment: None,
|
||||
last_polar: None,
|
||||
point_mods: Vec::new(),
|
||||
cursor: Vec2::ZERO,
|
||||
cursor_deferred: None,
|
||||
@@ -631,6 +716,9 @@ impl Session {
|
||||
last_used: LastUsed::default(),
|
||||
clipboard_source: None,
|
||||
plot_window: None,
|
||||
lisp: lisp::LispState::default(),
|
||||
lisp_last: None,
|
||||
hatch_picks: None,
|
||||
}
|
||||
}
|
||||
pub fn new_drawing(&mut self, metric: bool) -> usize {
|
||||
@@ -638,6 +726,7 @@ impl Session {
|
||||
let d = if metric { Drawing::new_metric() } else { Drawing::new_imperial() };
|
||||
let mut st = DocState::new(d, &format!("Drawing{}", self.untitled_counter), None);
|
||||
st.set_view_quiet(if metric { View { center: Vec2::new(210.0, 148.5), height: 297.0 } } else { View::default() });
|
||||
self.end_running();
|
||||
self.docs.push(st);
|
||||
self.active = self.docs.len() - 1;
|
||||
self.active
|
||||
@@ -651,10 +740,32 @@ impl Session {
|
||||
height: (ext.height().max(ext.width() * self.viewport_px.1 / self.viewport_px.0.max(1.0)) * 1.1).max(1e-6),
|
||||
});
|
||||
}
|
||||
self.end_running();
|
||||
self.docs.push(st);
|
||||
self.active = self.docs.len() - 1;
|
||||
self.active
|
||||
}
|
||||
/// A command still waiting for input belongs to the drawing it started in: end it there
|
||||
/// before another drawing becomes current, so its undo step lands in that drawing.
|
||||
fn end_running(&mut self) {
|
||||
if self.running.is_some() {
|
||||
self.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a file read off-thread with [`cmd::file::read_file`] as the active drawing (OPEN's fast
|
||||
/// half). A panic while adding it is reported as an error, never propagated.
|
||||
pub fn finish_open(&mut self, f: cmd::file::ReadFile) -> Result<Value> {
|
||||
let n = self.docs.len();
|
||||
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| cmd::file::finish_open(self, f))) {
|
||||
Ok(r) => r,
|
||||
Err(_) => {
|
||||
self.docs.truncate(n);
|
||||
self.active = self.active.min(self.docs.len().saturating_sub(1));
|
||||
Err(EngineError::Internal("open".into(), "panic".into()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn state(&self) -> Result<&DocState> {
|
||||
self.docs.get(self.active).ok_or(EngineError::NoDocument)
|
||||
@@ -752,7 +863,11 @@ impl Session {
|
||||
pub fn execute(&mut self, id: &str, params: &Value) -> Result<Value> {
|
||||
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
|
||||
(spec.enabled)(self).map_err(|m| EngineError::Disabled(spec.id.into(), m))?;
|
||||
if let Some(why) = cmd::blocks::bedit::refused_in_editor(self, spec.id) {
|
||||
return Err(EngineError::Disabled(spec.id.into(), why));
|
||||
}
|
||||
let before = self.state().ok().map(|s| (s.doc.clone(), s.selection.clone(), s.uid));
|
||||
let editing = self.state().is_ok_and(|s| s.block_edit.is_some());
|
||||
let run = spec.run;
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run(self, params)));
|
||||
let result = match result {
|
||||
@@ -770,19 +885,29 @@ impl Session {
|
||||
return Err(EngineError::Internal(id.into(), msg));
|
||||
}
|
||||
};
|
||||
cmd::groups::after_command(self, before.as_ref().map(|b| &b.0));
|
||||
cmd::constraints::after_command(self, before.as_ref().map(|b| &b.0), spec.undoable && result.is_ok());
|
||||
assoc::after_command(self, before.as_ref().map(|b| &b.0), before.as_ref().map(|b| b.2));
|
||||
let violation = before.as_ref().and_then(|b| cmd::blocks::refedit::after_command(self, &b.0, spec.id));
|
||||
let result = match (violation, result) {
|
||||
(Some(m), Ok(_)) => Err(EngineError::Other(m)),
|
||||
(_, r) => r,
|
||||
};
|
||||
// Opening or closing the Block Editor swaps the whole drawing: nothing in it changed.
|
||||
if self.state().is_ok_and(|s| s.block_edit.is_some()) == editing {
|
||||
self.after_command(before.as_ref().map(|b| &b.0), before.as_ref().map(|b| b.2), spec.undoable && result.is_ok());
|
||||
}
|
||||
let result = match (result, &before) {
|
||||
(Ok(v), Some((doc, sel, uid))) if spec.undoable => self.refuse_non_finite(Some(*uid), doc, sel, spec.id).map(|()| v),
|
||||
(r, _) => r,
|
||||
};
|
||||
let grouped = self.lisp_groups_undo();
|
||||
if spec.undoable
|
||||
&& let Some((doc, sel, uid)) = before
|
||||
&& let Some(st) = self.docs.iter_mut().find(|d| d.uid == uid)
|
||||
&& !Arc::ptr_eq(&doc, &st.doc)
|
||||
{
|
||||
st.push_undo(Snapshot { label: spec.label.to_string(), doc, selection: sel });
|
||||
// Inside a LISP routine the routine's single undo step records it.
|
||||
if !grouped {
|
||||
st.push_undo(Snapshot { label: spec.label.to_string(), doc, selection: sel });
|
||||
}
|
||||
st.revision += 1;
|
||||
}
|
||||
result
|
||||
@@ -799,6 +924,7 @@ impl Session {
|
||||
|
||||
/// Start a command by name or alias as if typed at the command line.
|
||||
pub fn start(&mut self, name: &str) -> Result<()> {
|
||||
self.last_polar = None;
|
||||
let name = name.trim();
|
||||
let (transparent, name) = match name.strip_prefix('\'') {
|
||||
Some(n) => (true, n),
|
||||
@@ -808,7 +934,24 @@ impl Session {
|
||||
// A command-line form with an id of its own (-ATTEDIT) is not the command it prefixes.
|
||||
let dashed = cmd::resolve_alias(&name.trim_start_matches(['_', '.']).to_ascii_lowercase());
|
||||
let id = if find_command(&dashed).is_some() { dashed } else { cmd::resolve_alias(&lower) };
|
||||
let spec = find_command(&id).ok_or_else(|| EngineError::UnknownCommand(name.to_string()))?;
|
||||
let Some(spec) = find_command(&id) else {
|
||||
// A command defined in LISP: (defun c:NAME …).
|
||||
if !transparent && let Some(f) = self.lisp_command(&lower) {
|
||||
if self.running.is_some() {
|
||||
self.cancel();
|
||||
}
|
||||
self.last_command = Some(lower.clone());
|
||||
self.echo(format!("Command: {}", lower.to_ascii_uppercase()));
|
||||
return self.lisp_run_command(&lower, f);
|
||||
}
|
||||
// A bare system variable name (ORTHOMODE, GRIDMODE…) acts as SETVAR on that variable.
|
||||
let var = lower.to_ascii_uppercase();
|
||||
if !name.starts_with(['_', '.', '-']) && sysvars::get(self, &var).is_some() {
|
||||
self.start(if transparent { "'setvar" } else { "setvar" })?;
|
||||
return self.input(Input::Text(var));
|
||||
}
|
||||
return Err(EngineError::UnknownCommand(name.to_string()));
|
||||
};
|
||||
if transparent && spec.transparent && self.running.is_some() {
|
||||
// Transparent commands (zoom, pan…) run without cancelling the active one.
|
||||
if spec.interactive.is_none() {
|
||||
@@ -821,10 +964,19 @@ impl Session {
|
||||
}
|
||||
}
|
||||
let nested = transparent && spec.transparent && self.running.is_some() && spec.interactive.is_some();
|
||||
// Ctrl+Z / U / UNDO while a command's prompt offers its own Undo keyword (LINE, PLINE…)
|
||||
// takes back the last segment and keeps the command running, as the keyword does.
|
||||
if !nested && matches!(spec.id, "u" | "undo") && self.current_prompt().is_some_and(|p| p.keywords.iter().any(|k| k == "Undo")) {
|
||||
self.echo(format!("Command: {}", spec.id.to_ascii_uppercase()));
|
||||
return self.input(Input::Keyword("Undo".into()));
|
||||
}
|
||||
if !nested && self.running.is_some() {
|
||||
self.cancel();
|
||||
}
|
||||
(spec.enabled)(self).map_err(|m| EngineError::Disabled(spec.id.into(), m))?;
|
||||
if let Some(why) = cmd::blocks::bedit::refused_in_editor(self, spec.id) {
|
||||
return Err(EngineError::Disabled(spec.id.into(), why));
|
||||
}
|
||||
if nested {
|
||||
// The outer command stays the one Enter repeats.
|
||||
self.echo(format!(">>{}", spec.id.to_ascii_uppercase()));
|
||||
@@ -874,8 +1026,18 @@ impl Session {
|
||||
}
|
||||
|
||||
/// Give an input to the running command. With no command running, `Enter` repeats the last
|
||||
/// command (as in AutoCAD) and points do nothing.
|
||||
/// command (as in AutoCAD) and points do nothing. A LISP routine waiting in a `get…`
|
||||
/// function takes the input instead; one paused in `command` carries on after it.
|
||||
pub fn input(&mut self, input: Input) -> Result<()> {
|
||||
if self.lisp_input(input.clone()) {
|
||||
return Ok(());
|
||||
}
|
||||
let r = self.input_to_command(input);
|
||||
self.lisp_after_command_input();
|
||||
r
|
||||
}
|
||||
|
||||
fn input_to_command(&mut self, input: Input) -> Result<()> {
|
||||
if self.running.is_none() {
|
||||
if input == Input::Enter
|
||||
&& let Some(last) = self.last_command.clone()
|
||||
@@ -1108,11 +1270,13 @@ impl Session {
|
||||
self.select_mode = None;
|
||||
let label = find_command(&run.id).map(|c| c.label).unwrap_or("Command");
|
||||
let _ = cancelled;
|
||||
cmd::groups::after_command(self, Some(&run.before));
|
||||
cmd::constraints::after_command(self, Some(&run.before), true);
|
||||
assoc::after_command(self, Some(&run.before), None);
|
||||
if self.suspended.is_some() {
|
||||
// A transparent command's changes belong to the undo step of the command it ran inside.
|
||||
if let Some(m) = cmd::blocks::refedit::after_command(self, &run.before, &run.id) {
|
||||
self.echo(m);
|
||||
}
|
||||
self.after_command(Some(&run.before), None, true);
|
||||
// A transparent command's changes belong to the command it ran inside, and a command a
|
||||
// LISP routine ran belongs to the routine's single undo step.
|
||||
if self.suspended.is_some() || self.lisp_groups_undo() {
|
||||
if let Ok(st) = self.state_mut()
|
||||
&& !Arc::ptr_eq(&run.before, &st.doc)
|
||||
{
|
||||
@@ -1128,7 +1292,20 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// After a transparent command ends, the command it interrupted prompts again.
|
||||
/// The post-command passes, in order, run after every command — by [`Session::execute`] and
|
||||
/// when an interactive command finishes — so their edits land in the command's own undo step:
|
||||
/// group membership is pruned, constraints are inferred, purged and re-solved, and
|
||||
/// associative dimensions and hatches follow their sources. A new pass is added here, once.
|
||||
///
|
||||
/// `before` is the drawing the command started from; `uid` (when known) is the document it
|
||||
/// started in, so associativity is not applied across a document switch; `ok` is false for a
|
||||
/// failed or non-undoable programmatic command, which skips the constraint pass.
|
||||
fn after_command(&mut self, before: Option<&Arc<Drawing>>, uid: Option<u64>, ok: bool) {
|
||||
cmd::groups::after_command(self, before);
|
||||
cmd::constraints::after_command(self, before, ok);
|
||||
assoc::after_command(self, before, uid);
|
||||
}
|
||||
|
||||
fn resume_suspended(&mut self) {
|
||||
if let Some(outer) = self.suspended.take() {
|
||||
self.echo(format!("Resuming {} command.", outer.id.to_ascii_uppercase()));
|
||||
@@ -1140,6 +1317,15 @@ impl Session {
|
||||
/// transparent command, cancels it and the command it interrupted.
|
||||
pub fn cancel(&mut self) {
|
||||
self.point_mods.clear();
|
||||
// Esc stops a LISP routine waiting for input, and the command it paused in.
|
||||
if self.lisp_waiting() {
|
||||
if self.running.is_some() {
|
||||
let _ = self.feed(Some(Input::Cancel));
|
||||
}
|
||||
self.echo("*Cancel*");
|
||||
self.lisp_abort("Function cancelled");
|
||||
return;
|
||||
}
|
||||
if self.running.is_some() {
|
||||
if self.suspended.is_some() {
|
||||
if let Some(nested) = self.running.take() {
|
||||
@@ -1154,7 +1340,16 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// The multiline text the running command is collecting, for the in-place editor
|
||||
/// ([`Interactive::editing_mtext`]).
|
||||
pub fn editing_mtext(&self) -> Option<cadcraft_doc::MText> {
|
||||
self.running.as_ref().and_then(|r| r.machine.editing_mtext())
|
||||
}
|
||||
|
||||
pub fn current_prompt(&self) -> Option<Prompt> {
|
||||
if let Some(p) = self.lisp_prompt() {
|
||||
return Some(p);
|
||||
}
|
||||
self.running.as_ref().map(|r| {
|
||||
let p = r.machine.prompt(self);
|
||||
match &self.select_mode {
|
||||
@@ -1174,8 +1369,26 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the object under the cursor is highlighted (SELECTIONPREVIEW): the cursor picks
|
||||
/// objects ([`Self::picking_objects`]) and the bit for a command asking for them (2) or for
|
||||
/// no command (1) is on.
|
||||
pub fn previews_selection(&self) -> bool {
|
||||
let bit = if self.current_prompt().is_some() { 2 } else { 1 };
|
||||
self.picking_objects() && self.settings.selectionpreview & bit != 0
|
||||
}
|
||||
|
||||
/// Whether selected objects show grips while `selected` objects are selected (GRIPS,
|
||||
/// GRIPOBJLIMIT).
|
||||
pub fn shows_grips(&self, selected: usize) -> bool {
|
||||
let limit = self.settings.gripobjlimit;
|
||||
self.settings.grips > 0 && (limit == 0 || selected <= limit as usize)
|
||||
}
|
||||
|
||||
/// The text shown on the command line: the active prompt, or "Command:".
|
||||
pub fn prompt_text(&self) -> String {
|
||||
if let Some(p) = self.lisp_prompt() {
|
||||
return p.display();
|
||||
}
|
||||
match &self.running {
|
||||
Some(r) if self.suspended.is_some() => format!(">>{} {}", r.id.to_ascii_uppercase(), r.machine.prompt(self).display()),
|
||||
Some(r) => format!("{} {}", r.id.to_ascii_uppercase(), r.machine.prompt(self).display()),
|
||||
@@ -1185,9 +1398,13 @@ impl Session {
|
||||
|
||||
/// Rubber-band preview entities for the cursor.
|
||||
pub fn preview(&self, cursor: Vec2) -> Vec<Entity> {
|
||||
let Some(r) = &self.running else { return Vec::new() };
|
||||
let common = self.current_common();
|
||||
r.machine.preview(self, cursor).into_iter().map(|k| Entity { handle: Handle(0), common: common.clone(), kind: k }).collect()
|
||||
let kinds = match (self.lisp_preview(cursor), &self.running) {
|
||||
(Some(k), _) => k,
|
||||
(None, Some(r)) => r.machine.preview(self, cursor),
|
||||
(None, None) => Vec::new(),
|
||||
};
|
||||
kinds.into_iter().map(|k| Entity { handle: Handle(0), common: common.clone(), kind: k }).collect()
|
||||
}
|
||||
|
||||
/// The active command's preview as a transform of existing objects (see
|
||||
@@ -1245,14 +1462,17 @@ impl Session {
|
||||
return self.typed(rest);
|
||||
}
|
||||
first = false;
|
||||
let (tok, more) = match rest.split_once([' ', '\t']) {
|
||||
Some((tok, more)) => (tok, Some(more)),
|
||||
None => (rest, None),
|
||||
};
|
||||
if self.running.is_some() {
|
||||
let (tok, more) = next_token(rest);
|
||||
if self.running.is_some() || self.lisp_prompt().is_some() {
|
||||
self.typed(tok)?;
|
||||
} else if tok.trim().is_empty() {
|
||||
self.input(Input::Enter)?;
|
||||
} else if lisp_expression(tok) {
|
||||
// `(expression)` or `!variable` at Command: evaluates LISP and shows the value.
|
||||
let src = tok.trim().strip_prefix('!').unwrap_or(tok.trim()).to_string();
|
||||
if let Err(e) = self.lisp_eval(&src, true, false, Vec::new()) {
|
||||
self.echo(e.to_string());
|
||||
}
|
||||
} else {
|
||||
self.start(tok)?;
|
||||
}
|
||||
@@ -1278,17 +1498,47 @@ impl Session {
|
||||
if tt.starts_with('\'') {
|
||||
return self.start(tt);
|
||||
}
|
||||
// A LISP expression typed at a command's prompt: its value is the answer.
|
||||
// Not at a free-text prompt: there the text is the answer (CAL takes "(3+4)*2").
|
||||
if lisp_expression(tt) && !prompt.accept.text && self.lisp_prompt().is_none() && !self.lisp_groups_undo() {
|
||||
let src = tt.strip_prefix('!').unwrap_or(tt).to_string();
|
||||
let v = match self.lisp_eval(&src, false, true, Vec::new()) {
|
||||
Ok(Some(Ok(v))) => v,
|
||||
Ok(_) => return Ok(()),
|
||||
Err(e) => {
|
||||
self.echo(e.to_string());
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
return match v {
|
||||
lisp::value::Value::Nil => Ok(()),
|
||||
lisp::value::Value::Int(i) => self.typed(&i.to_string()),
|
||||
lisp::value::Value::Real(r) => self.typed(&format!("{r}")),
|
||||
lisp::value::Value::Str(s) => self.typed(&s),
|
||||
other => match other.as_point() {
|
||||
Some(p) => self.input(Input::Point(Vec2::new(p[0], p[1]))),
|
||||
None => {
|
||||
self.echo(format!("Invalid input: {}", lisp::value::to_text(&other, true)));
|
||||
Ok(())
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
// At "Select objects" prompts the selection modes (Previous, Last, ALL) win over a command
|
||||
// keyword sharing the letter (HATCH's "picK internal point" against `P`).
|
||||
let selection_mode = prompt.accept.select && matches!(tt.to_ascii_lowercase().as_str(), "all" | "l" | "last" | "p" | "previous");
|
||||
// A leading underscore asks for a keyword by its English name, whatever the interface
|
||||
// language (`_M`, `_Yes`): scripts and LISP routines write them that way. (Snap
|
||||
// overrides such as `_cen` were taken above.)
|
||||
if !selection_mode
|
||||
&& let Some(k) = prompt.match_keyword(tt)
|
||||
&& let Some(k) = prompt.match_keyword(tt).or_else(|| tt.strip_prefix('_').and_then(|r| prompt.match_keyword(r)))
|
||||
&& !(prompt.accept.number && tt.parse::<f64>().is_ok())
|
||||
{
|
||||
return self.input(Input::Keyword(k));
|
||||
}
|
||||
if prompt.accept.point {
|
||||
if let Some(p) = prompt::parse_point_with(tt, self.last_point, &self.angle_settings()) {
|
||||
self.last_polar = prompt::parse_polar_text(tt, &self.angle_settings());
|
||||
return self.input(Input::Point(p));
|
||||
}
|
||||
// Direct distance entry along the tracking path the cursor is on, from the point it
|
||||
@@ -1303,7 +1553,7 @@ impl Session {
|
||||
&& !prompt.accept.number
|
||||
{
|
||||
let mut c = self.cursor;
|
||||
if self.settings.orthomode {
|
||||
if self.settings.orthomode && !prompt.corner {
|
||||
c = snap::iso::constrain(&self.drafting_settings(), base, c);
|
||||
}
|
||||
let dir = (c - base).normalized();
|
||||
@@ -1479,6 +1729,7 @@ impl Session {
|
||||
serde_json::json!({
|
||||
"index": i, "title": d.title, "path": d.path, "dirty": d.is_dirty(),
|
||||
"entities": d.doc.entity_count(), "active": i == self.active, "revision": d.revision,
|
||||
"blockEditor": d.block_editor(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
@@ -1492,5 +1743,51 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// Typed text that is LISP: `(expression)` or `!variable`.
|
||||
fn lisp_expression(t: &str) -> bool {
|
||||
let t = t.trim();
|
||||
t.starts_with('(') || (t.starts_with('!') && t.len() > 1)
|
||||
}
|
||||
|
||||
/// The next command-line input in `rest` and what follows it: up to a space or tab, except that
|
||||
/// a LISP expression runs to its closing parenthesis (spaces and quoted strings inside it).
|
||||
fn next_token(rest: &str) -> (&str, Option<&str>) {
|
||||
let lead = rest.len() - rest.trim_start_matches('!').len();
|
||||
if rest.get(lead..).is_some_and(|r| r.starts_with('(')) {
|
||||
let (mut depth, mut in_str, mut escaped) = (0i32, false, false);
|
||||
for (i, c) in rest.char_indices().skip(lead) {
|
||||
if in_str {
|
||||
match (escaped, c) {
|
||||
(true, _) => escaped = false,
|
||||
(false, '\\') => escaped = true,
|
||||
(false, '"') => in_str = false,
|
||||
_ => {}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match c {
|
||||
'"' => in_str = true,
|
||||
'(' => depth += 1,
|
||||
')' => {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
let end = i + 1;
|
||||
// As after any input, a space ends it and a trailing one is Enter.
|
||||
let more = rest.get(end..).filter(|m| !m.is_empty()).map(|m| m.strip_prefix([' ', '\t']).unwrap_or(m));
|
||||
return (&rest[..end], more);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// Unbalanced: all of it (the reader reports the error).
|
||||
return (rest, None);
|
||||
}
|
||||
match rest.split_once([' ', '\t']) {
|
||||
Some((tok, more)) => (tok, Some(more)),
|
||||
None => (rest, None),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,962 @@
|
||||
//! Drawing access from LISP: `command`, system variables, entity lists (`entget`, `entmake`,
|
||||
//! `entmod`, `entdel`, `entlast`, `entnext`, `handent`), selection sets (`ssget` and friends),
|
||||
//! symbol tables (`tblsearch`, `tblnext`) and unit conversions (`rtos`, `angtos`, `distof`,
|
||||
//! `angtof`).
|
||||
//!
|
||||
//! Entity lists use the DXF groups the file reader and writer use (through
|
||||
//! [`crate::cmd::file::entity_codec`]), so what `entget` shows is what a DXF file holds.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use cadcraft_doc::{Entity, Handle, Space};
|
||||
use cadcraft_dxf::Tag;
|
||||
use cadcraft_geom::Vec2;
|
||||
|
||||
use super::LispError;
|
||||
use super::builtins::args_err;
|
||||
use super::eval::{Ctx, Flow, Need};
|
||||
use super::value::{PickSet, Value, to_text};
|
||||
use crate::{Input, Session};
|
||||
|
||||
fn arity(a: &[Value], min: usize, max: usize) -> Result<(), LispError> {
|
||||
if a.len() < min {
|
||||
return Err(args_err(true));
|
||||
}
|
||||
if a.len() > max {
|
||||
return Err(args_err(false));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ename(v: &Value) -> Result<Handle, LispError> {
|
||||
match v {
|
||||
Value::Ename(h) => Ok(*h),
|
||||
other => Err(LispError::bad_arg("lentityp", other)),
|
||||
}
|
||||
}
|
||||
|
||||
fn string(v: &Value) -> Result<String, LispError> {
|
||||
match v {
|
||||
Value::Str(s) => Ok(s.to_string()),
|
||||
other => Err(LispError::bad_arg("stringp", other)),
|
||||
}
|
||||
}
|
||||
|
||||
fn pickset(v: &Value) -> Result<PickSet, LispError> {
|
||||
match v {
|
||||
Value::PickSet(p) => Ok(p.clone()),
|
||||
other => Err(LispError::bad_arg("lselsetp", other)),
|
||||
}
|
||||
}
|
||||
|
||||
fn new_pickset(ctx: &mut Ctx<'_>, items: Vec<Handle>) -> Value {
|
||||
let id = ctx.env.next_pickset;
|
||||
ctx.env.next_pickset = ctx.env.next_pickset.wrapping_add(1);
|
||||
Value::PickSet(PickSet { id, items: Arc::new(Mutex::new(items)) })
|
||||
}
|
||||
|
||||
fn items(p: &PickSet) -> Vec<Handle> {
|
||||
p.items.lock().map(|v| v.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
// ---------------- command ----------------
|
||||
|
||||
/// `(command arg…)`: arguments go to the command line as typed input: a string starts a command
|
||||
/// or answers a prompt ("" is Enter), a number or a point is entered, an entity name or a
|
||||
/// selection set picks objects, and `pause` waits for the user. A command not finished by the
|
||||
/// arguments stays active for the next `command` call. No arguments: cancel the active command.
|
||||
pub fn command(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
|
||||
if a.is_empty() {
|
||||
if ctx.s.running.is_some() {
|
||||
ctx.s.cancel();
|
||||
}
|
||||
return Ok(Flow::Value(Value::Nil));
|
||||
}
|
||||
for (i, arg) in a.iter().enumerate() {
|
||||
match arg {
|
||||
Value::Nil => {}
|
||||
Value::Str(s) if &**s == "\\" => {
|
||||
// `pause`: the user answers this prompt, then the rest goes in.
|
||||
if ctx.s.running.is_some() {
|
||||
let rest = a[i + 1..].to_vec();
|
||||
let f = super::eval::lookup(ctx.env, &super::value::Sym::new("COMMAND"));
|
||||
return Ok(if rest.is_empty() { Flow::Wait(Need::Pause) } else { Flow::WaitThen(Need::Pause, f, rest) });
|
||||
}
|
||||
}
|
||||
Value::Str(s) => {
|
||||
let r = if ctx.s.running.is_none() {
|
||||
if s.trim().is_empty() { Ok(()) } else { ctx.s.start(s) }
|
||||
} else if s.is_empty() {
|
||||
ctx.s.input(Input::Enter)
|
||||
} else {
|
||||
ctx.s.typed(s)
|
||||
};
|
||||
if let Err(e) = r {
|
||||
ctx.s.echo(e.to_string());
|
||||
}
|
||||
}
|
||||
Value::Int(_) | Value::Real(_) => {
|
||||
if ctx.s.running.is_some() {
|
||||
let text = match arg {
|
||||
Value::Int(n) => n.to_string(),
|
||||
Value::Real(r) => format!("{r}"),
|
||||
_ => String::new(),
|
||||
};
|
||||
if let Err(e) = ctx.s.typed(&text) {
|
||||
ctx.s.echo(e.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
Value::Ename(h) => feed(ctx.s, Input::Pick(vec![*h])),
|
||||
Value::PickSet(p) => feed(ctx.s, Input::Pick(items(p))),
|
||||
Value::Cons(_) => {
|
||||
if let Some(p) = arg.as_point() {
|
||||
feed(ctx.s, Input::Point(Vec2::new(p[0], p[1])));
|
||||
} else if let Value::Ename(h) = arg.car() {
|
||||
// An `entsel` result: (ename point).
|
||||
feed(ctx.s, Input::Pick(vec![h]));
|
||||
} else {
|
||||
return Err(LispError::bad_arg("command argument", arg));
|
||||
}
|
||||
}
|
||||
other => return Err(LispError::bad_arg("command argument", other)),
|
||||
}
|
||||
}
|
||||
Ok(Flow::Value(Value::Nil))
|
||||
}
|
||||
|
||||
fn feed(s: &mut Session, input: Input) {
|
||||
if s.running.is_some()
|
||||
&& let Err(e) = s.input(input)
|
||||
{
|
||||
s.echo(e.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- system variables ----------------
|
||||
|
||||
/// A JSON value as LISP: integers, reals, strings, lists (points).
|
||||
pub fn from_json(v: &serde_json::Value) -> Value {
|
||||
match v {
|
||||
serde_json::Value::Null => Value::Nil,
|
||||
serde_json::Value::Bool(b) => Value::Int(i32::from(*b)),
|
||||
serde_json::Value::Number(n) => match n.as_i64().and_then(|i| i32::try_from(i).ok()) {
|
||||
Some(i) => Value::Int(i),
|
||||
None => Value::Real(n.as_f64().unwrap_or(0.0)),
|
||||
},
|
||||
serde_json::Value::String(s) => Value::str(s),
|
||||
serde_json::Value::Array(items) => Value::list(items.iter().map(from_json).collect::<Vec<_>>()),
|
||||
serde_json::Value::Object(_) => Value::str(&v.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// A LISP value as JSON (for `setvar`).
|
||||
pub fn to_json(v: &Value) -> serde_json::Value {
|
||||
match v {
|
||||
Value::Nil => serde_json::Value::Null,
|
||||
Value::Int(i) => serde_json::json!(i),
|
||||
Value::Real(r) => serde_json::json!(r),
|
||||
Value::Str(s) => serde_json::json!(&**s),
|
||||
Value::Cons(_) => serde_json::Value::Array(v.iter().map(|x| to_json(&x)).collect()),
|
||||
other => serde_json::json!(to_text(other, false)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn getvar(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
|
||||
arity(&a, 1, 1)?;
|
||||
let name = string(&a[0])?;
|
||||
Ok(Flow::Value(crate::sysvars::get(ctx.s, &name).map(|v| from_json(&v)).unwrap_or_default()))
|
||||
}
|
||||
|
||||
pub fn setvar(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
|
||||
arity(&a, 2, 2)?;
|
||||
let name = string(&a[0])?;
|
||||
match crate::sysvars::set(ctx.s, &name, &to_json(&a[1])) {
|
||||
Ok(()) => Ok(Flow::Value(a[1].clone())),
|
||||
Err(_) => Err(LispError::new(format!("AutoCAD variable setting rejected: {} {}", to_text(&a[0], true), to_text(&a[1], true)))),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- entity lists ----------------
|
||||
|
||||
/// Groups whose value is a point: 10–18, 110–112, 210, 1010–1013 (x), with y and z 10 and 20
|
||||
/// higher.
|
||||
fn point_group(code: i32) -> bool {
|
||||
(10..=18).contains(&code) || (110..=112).contains(&code) || code == 210 || (1010..=1013).contains(&code)
|
||||
}
|
||||
|
||||
fn tag_value(t: &Tag) -> Value {
|
||||
match &t.value {
|
||||
cadcraft_dxf::Value::Str(s) | cadcraft_dxf::Value::Hex(s) => Value::str(s),
|
||||
cadcraft_dxf::Value::Real(r) => Value::Real(*r),
|
||||
cadcraft_dxf::Value::Int(i) => Value::Int(i32::try_from(*i).unwrap_or(if *i < 0 { i32::MIN } else { i32::MAX })),
|
||||
cadcraft_dxf::Value::Bool(b) => Value::Int(i32::from(*b)),
|
||||
}
|
||||
}
|
||||
|
||||
/// DXF groups as an association list: points combined into lists `(10 x y z)`.
|
||||
fn groups_to_list(tags: &[Tag]) -> Vec<Value> {
|
||||
let mut out = Vec::new();
|
||||
let mut i = 0;
|
||||
while let Some(t) = tags.get(i) {
|
||||
if point_group(t.code) {
|
||||
let x = tag_value(t).as_number().unwrap_or(0.0);
|
||||
let y = tags.get(i + 1).filter(|n| n.code == t.code + 10).and_then(|n| tag_value(n).as_number());
|
||||
let z = tags.get(i + 2).filter(|n| n.code == t.code + 20 && y.is_some()).and_then(|n| tag_value(n).as_number());
|
||||
match (y, z) {
|
||||
(Some(y), Some(z)) => {
|
||||
out.push(Value::list(vec![Value::Int(t.code), Value::Real(x), Value::Real(y), Value::Real(z)]));
|
||||
i += 3;
|
||||
}
|
||||
(Some(y), None) => {
|
||||
out.push(Value::list(vec![Value::Int(t.code), Value::Real(x), Value::Real(y)]));
|
||||
i += 2;
|
||||
}
|
||||
_ => {
|
||||
out.push(Value::cons(Value::Int(t.code), Value::Real(x)));
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
out.push(Value::cons(Value::Int(t.code), tag_value(t)));
|
||||
i += 1;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// An association list back to DXF groups (points split into x, y, z groups). The type
|
||||
/// (`0`), the entity name (`-1`), the handle (`5`), the owner (`330`) and the layout (`410`) are
|
||||
/// left out: they aren't the entity's to set.
|
||||
fn list_to_groups(list: &Value) -> Result<(String, Vec<Tag>), LispError> {
|
||||
let mut kind = None;
|
||||
let mut tags = Vec::new();
|
||||
for item in list.iter() {
|
||||
let Value::Cons(c) = &item else { return Err(LispError::bad_arg("listp", &item)) };
|
||||
let Value::Int(code) = c.car else { return Err(LispError::bad_arg("fixnump", &c.car)) };
|
||||
let v = c.cdr.clone();
|
||||
match code {
|
||||
0 => {
|
||||
kind = Some(match &v {
|
||||
Value::Str(s) => s.to_ascii_uppercase(),
|
||||
other => return Err(LispError::bad_arg("stringp", other)),
|
||||
})
|
||||
}
|
||||
-1 | 5 | 330 | 360 | 410 | 102 => {}
|
||||
_ if point_group(code) && matches!(v, Value::Cons(_)) => {
|
||||
let p = v.as_point().ok_or_else(|| LispError::bad_arg("2D/3D point", &v))?;
|
||||
tags.push(Tag::f(code, p[0]));
|
||||
tags.push(Tag::f(code + 10, p[1]));
|
||||
tags.push(Tag::f(code + 20, p[2]));
|
||||
}
|
||||
_ => tags.push(match &v {
|
||||
Value::Int(i) => Tag::i(code, i64::from(*i)),
|
||||
Value::Real(r) => Tag::f(code, *r),
|
||||
Value::Str(s) => Tag::s(code, &**s),
|
||||
Value::Ename(h) => Tag::s(code, h.hex()),
|
||||
other => return Err(LispError::bad_arg("DXF group value", other)),
|
||||
}),
|
||||
}
|
||||
}
|
||||
Ok((kind.ok_or_else(|| LispError::new("bad DXF group: no entity type (0)"))?, tags))
|
||||
}
|
||||
|
||||
fn codec() -> Result<&'static crate::cmd::file::EntityCodec, LispError> {
|
||||
crate::cmd::file::entity_codec().ok_or_else(|| LispError::new("entity lists are not available in this build"))
|
||||
}
|
||||
|
||||
fn space_of(d: &cadcraft_doc::Drawing, h: Handle) -> Option<Space> {
|
||||
d.space_of(h)
|
||||
}
|
||||
|
||||
pub fn entget(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
|
||||
arity(&a, 1, 2)?;
|
||||
let h = ename(&a[0])?;
|
||||
let Ok(d) = ctx.s.doc() else { return Ok(Flow::Value(Value::Nil)) };
|
||||
let Some(e) = d.entity(h) else { return Ok(Flow::Value(Value::Nil)) };
|
||||
let (kind, tags) = (codec()?.groups)(d, e);
|
||||
let layout = match space_of(d, h) {
|
||||
Some(Space::Paper(name)) => name,
|
||||
_ => "Model".into(),
|
||||
};
|
||||
let mut items = vec![Value::cons(Value::Int(-1), Value::Ename(h)), Value::cons(Value::Int(0), Value::str(&kind))];
|
||||
let mut placed_layout = false;
|
||||
for v in groups_to_list(&tags) {
|
||||
// The layout name follows the AcDbEntity marker, as AutoCAD lists it.
|
||||
let after_marker = !placed_layout && v.car().as_number() == Some(100.0) && matches!(v.cdr(), Value::Str(ref s) if &**s == "AcDbEntity");
|
||||
items.push(v);
|
||||
if after_marker {
|
||||
items.push(Value::cons(Value::Int(410), Value::str(&layout)));
|
||||
placed_layout = true;
|
||||
}
|
||||
}
|
||||
Ok(Flow::Value(Value::list(items)))
|
||||
}
|
||||
|
||||
/// The entity a list describes, as the reader makes it; a missing layer (8) is the current one.
|
||||
fn entity_from_list(ctx: &Ctx<'_>, list: &Value) -> Result<(String, cadcraft_doc::Common, cadcraft_doc::EntityKind), LispError> {
|
||||
let (kind, mut tags) = list_to_groups(list)?;
|
||||
if !tags.iter().any(|t| t.code == 8) {
|
||||
let layer = crate::sysvars::get(ctx.s, "CLAYER").and_then(|v| v.as_str().map(str::to_string)).unwrap_or_else(|| "0".into());
|
||||
tags.insert(0, Tag::s(8, layer));
|
||||
}
|
||||
match (codec()?.entity)(&kind, &tags) {
|
||||
// A type the reader only carries through (an unknown or invalid name) isn't made.
|
||||
Some((_, cadcraft_doc::EntityKind::Unknown(_))) | None => Err(LispError::new(format!("bad DXF group: {kind} can't be made from this list"))),
|
||||
Some((common, k)) => Ok((kind, common, k)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn entmake(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
|
||||
arity(&a, 0, 1)?;
|
||||
let Some(list) = a.first().filter(|v| !v.is_nil()) else { return Ok(Flow::Value(Value::Nil)) };
|
||||
let (_, common, kind) = match entity_from_list(ctx, list) {
|
||||
Ok(x) => x,
|
||||
Err(_) => return Ok(Flow::Value(Value::Nil)),
|
||||
};
|
||||
let space = ctx.s.space();
|
||||
let Ok(d) = ctx.s.doc_mut() else { return Ok(Flow::Value(Value::Nil)) };
|
||||
// A layer entmake names that doesn't exist yet is created.
|
||||
d.ensure_layer(&common.layer);
|
||||
match d.add(&space, common, kind) {
|
||||
Ok(_) => {
|
||||
ctx.s.touch();
|
||||
Ok(Flow::Value(list.clone()))
|
||||
}
|
||||
Err(_) => Ok(Flow::Value(Value::Nil)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn entmod(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
|
||||
arity(&a, 1, 1)?;
|
||||
let list = &a[0];
|
||||
let Some(Value::Ename(h)) = list.iter().find(|v| v.car().as_number() == Some(-1.0)).map(|v| v.cdr()) else {
|
||||
return Err(LispError::bad_arg("lentityp", &Value::Nil));
|
||||
};
|
||||
let Some(old) = ctx.s.doc().ok().and_then(|d| d.entity(h)).cloned() else { return Ok(Flow::Value(Value::Nil)) };
|
||||
let (kind_name, common, kind) = match entity_from_list(ctx, list) {
|
||||
Ok(x) => x,
|
||||
Err(_) => return Ok(Flow::Value(Value::Nil)),
|
||||
};
|
||||
// entmod changes an entity, never its type.
|
||||
if !kind_name.eq_ignore_ascii_case(old.kind.dxf_name()) {
|
||||
return Ok(Flow::Value(Value::Nil));
|
||||
}
|
||||
let Ok(d) = ctx.s.doc_mut() else { return Ok(Flow::Value(Value::Nil)) };
|
||||
d.ensure_layer(&common.layer);
|
||||
let ok = d
|
||||
.modify_entity(h, |e| {
|
||||
e.common = common;
|
||||
e.kind = kind;
|
||||
})
|
||||
.is_ok();
|
||||
if ok {
|
||||
ctx.s.touch();
|
||||
}
|
||||
Ok(Flow::Value(if ok { list.clone() } else { Value::Nil }))
|
||||
}
|
||||
|
||||
/// `(entupd ename)`: redraw an entity (the canvas redraws on its own).
|
||||
pub fn entupd(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
|
||||
arity(&a, 1, 1)?;
|
||||
let h = ename(&a[0])?;
|
||||
ctx.s.touch();
|
||||
Ok(Flow::Value(if ctx.s.doc().is_ok_and(|d| d.entity(h).is_some()) { Value::Ename(h) } else { Value::Nil }))
|
||||
}
|
||||
|
||||
/// `(entdel ename)`: delete an entity, or bring back one this routine's drawing deleted.
|
||||
pub fn entdel(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
|
||||
arity(&a, 1, 1)?;
|
||||
let h = ename(&a[0])?;
|
||||
let Ok(d) = ctx.s.doc_mut() else { return Ok(Flow::Value(Value::Nil)) };
|
||||
if let Some(space) = d.space_of(h) {
|
||||
if let Some(e) = d.remove_entity(h) {
|
||||
ctx.env.deleted.insert(h, (space, e));
|
||||
ctx.s.touch();
|
||||
return Ok(Flow::Value(Value::Ename(h)));
|
||||
}
|
||||
return Ok(Flow::Value(Value::Nil));
|
||||
}
|
||||
match ctx.env.deleted.remove(&h) {
|
||||
Some((space, e)) => {
|
||||
let restored = d.space_mut(&space).map(|store| store.push(Entity::clone(&e))).is_some();
|
||||
ctx.s.touch();
|
||||
Ok(Flow::Value(if restored { Value::Ename(h) } else { Value::Nil }))
|
||||
}
|
||||
None => Ok(Flow::Value(Value::Nil)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Every entity's handle in creation order (model space and layouts), cached per drawing.
|
||||
fn handles_in_order(ctx: &mut Ctx<'_>) -> Arc<Vec<Handle>> {
|
||||
let Ok(st) = ctx.s.state() else { return Arc::default() };
|
||||
let key = Arc::as_ptr(&st.doc) as usize;
|
||||
if let Some((k, v)) = &ctx.env.order
|
||||
&& *k == key
|
||||
{
|
||||
return v.clone();
|
||||
}
|
||||
let d = &st.doc;
|
||||
let mut v: Vec<Handle> = d.model.handles();
|
||||
for l in &d.layouts {
|
||||
v.extend(l.entities.handles());
|
||||
}
|
||||
v.sort_unstable();
|
||||
let v = Arc::new(v);
|
||||
ctx.env.order = Some((key, v.clone()));
|
||||
v
|
||||
}
|
||||
|
||||
pub fn entlast(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
|
||||
arity(&a, 0, 0)?;
|
||||
Ok(Flow::Value(handles_in_order(ctx).last().map(|h| Value::Ename(*h)).unwrap_or_default()))
|
||||
}
|
||||
|
||||
pub fn entnext(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
|
||||
arity(&a, 0, 1)?;
|
||||
let order = handles_in_order(ctx);
|
||||
let next = match a.first() {
|
||||
None | Some(Value::Nil) => order.first().copied(),
|
||||
Some(v) => {
|
||||
let h = ename(v)?;
|
||||
let i = order.partition_point(|x| *x <= h);
|
||||
order.get(i).copied()
|
||||
}
|
||||
};
|
||||
Ok(Flow::Value(next.map(Value::Ename).unwrap_or_default()))
|
||||
}
|
||||
|
||||
pub fn handent(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
|
||||
arity(&a, 1, 1)?;
|
||||
let s = string(&a[0])?;
|
||||
let h = Handle::parse_hex(&s).filter(|h| ctx.s.doc().is_ok_and(|d| d.entity(*h).is_some()));
|
||||
Ok(Flow::Value(h.map(Value::Ename).unwrap_or_default()))
|
||||
}
|
||||
|
||||
// ---------------- selection sets ----------------
|
||||
|
||||
/// `(ssget [mode] [pt1 [pt2]] [filter])`. Modes: "X" every entity, "L" the last, "P" the
|
||||
/// previous selection, "I" the implied (pickfirst) one, "W"/"C" window and crossing with two
|
||||
/// points, "F" fence, "WP"/"CP" polygons with a point list. With no mode the pickfirst
|
||||
/// selection, or the user selects objects. The filter is a list of DXF groups (`-4` operators,
|
||||
/// wildcards). nil when nothing matches.
|
||||
pub fn ssget(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
|
||||
let mut args = a.into_iter().peekable();
|
||||
let mode = match args.peek() {
|
||||
Some(Value::Str(s)) => {
|
||||
let m = s.trim_start_matches(['_', '+', '.', ':']).to_ascii_uppercase();
|
||||
args.next();
|
||||
Some(m)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let rest: Vec<Value> = args.collect();
|
||||
// A trailing list of dotted pairs is the filter.
|
||||
let (pts, filter) = match rest.last() {
|
||||
Some(f @ Value::Cons(_)) if f.car().car().as_number().is_some() && f.car().as_point().is_none() => {
|
||||
(rest[..rest.len() - 1].to_vec(), Some(f.clone()))
|
||||
}
|
||||
_ => (rest, None),
|
||||
};
|
||||
let point = |i: usize| -> Result<Vec2, LispError> {
|
||||
let v = pts.get(i).ok_or_else(|| args_err(true))?;
|
||||
let p = v.as_point().ok_or_else(|| LispError::bad_arg("2D/3D point", v))?;
|
||||
Ok(Vec2::new(p[0], p[1]))
|
||||
};
|
||||
let s = &mut *ctx.s;
|
||||
let all = |s: &Session, all_spaces: bool| -> Vec<Handle> {
|
||||
let Ok(d) = s.doc() else { return Vec::new() };
|
||||
if all_spaces {
|
||||
let mut v = d.model.handles();
|
||||
for l in &d.layouts {
|
||||
v.extend(l.entities.handles());
|
||||
}
|
||||
v
|
||||
} else {
|
||||
d.space(&s.state().map(|st| st.edit_space()).unwrap_or(Space::Model)).map(|st| st.handles()).unwrap_or_default()
|
||||
}
|
||||
};
|
||||
let space = s.state().map(|st| st.edit_space()).unwrap_or(Space::Model);
|
||||
let mut found: Vec<Handle> = match mode.as_deref() {
|
||||
Some("X") => all(s, true),
|
||||
Some("L") => {
|
||||
let order = handles_in_order(ctx);
|
||||
order.last().copied().into_iter().collect()
|
||||
}
|
||||
Some("P") => ctx.s.state().map(|st| st.previous_selection.clone()).unwrap_or_default(),
|
||||
Some("I") => ctx.s.selection(),
|
||||
Some("W") | Some("C") => {
|
||||
let (p1, p2) = (point(0)?, point(1)?);
|
||||
let s = &*ctx.s;
|
||||
let Ok(d) = s.doc() else { return Ok(Flow::Value(Value::Nil)) };
|
||||
crate::select::select_window(d, &space, cadcraft_geom::Bounds2::from_points([p1, p2]), mode.as_deref() == Some("C"))
|
||||
}
|
||||
Some("F") | Some("WP") | Some("CP") => {
|
||||
let poly: Vec<Vec2> =
|
||||
pts.first().map(|l| l.iter().filter_map(|p| p.as_point().map(|q| Vec2::new(q[0], q[1]))).collect()).unwrap_or_default();
|
||||
let s = &*ctx.s;
|
||||
let Ok(d) = s.doc() else { return Ok(Flow::Value(Value::Nil)) };
|
||||
match mode.as_deref() {
|
||||
Some("F") => crate::select::select_fence(d, &space, &poly),
|
||||
m => crate::select::select_polygon(d, &space, &poly, m == Some("CP")),
|
||||
}
|
||||
}
|
||||
Some(":S") | Some("A") | None => {
|
||||
let implied = ctx.s.selection();
|
||||
if !implied.is_empty() {
|
||||
implied
|
||||
} else if pts.is_empty() {
|
||||
// The user selects objects; the result arrives when SELECT ends.
|
||||
return Ok(Flow::Value(Value::Nil)).and_then(|_| interactive_ssget(ctx, filter));
|
||||
} else {
|
||||
// A point: the object there.
|
||||
let p = point(0)?;
|
||||
let s = &*ctx.s;
|
||||
let ap = s.settings.pickbox.max(1.0) * s.pixel_size();
|
||||
let Ok(d) = s.doc() else { return Ok(Flow::Value(Value::Nil)) };
|
||||
crate::select::pick(d, &space, p, ap).into_iter().collect()
|
||||
}
|
||||
}
|
||||
Some(m) => return Err(LispError::new(format!("bad argument value: ssget mode {m}"))),
|
||||
};
|
||||
if let Some(f) = &filter {
|
||||
let d = ctx.s.doc().map_err(|e| LispError::new(e.to_string()))?;
|
||||
let f = parse_filter(f)?;
|
||||
found.retain(|h| d.entity(*h).is_some_and(|e| matches_filter(d, e, &f)));
|
||||
}
|
||||
if found.is_empty() {
|
||||
return Ok(Flow::Value(Value::Nil));
|
||||
}
|
||||
Ok(Flow::Value(new_pickset(ctx, found)))
|
||||
}
|
||||
|
||||
/// `ssget` asking the user: the SELECT command collects the objects.
|
||||
fn interactive_ssget(ctx: &mut Ctx<'_>, filter: Option<Value>) -> Result<Flow, LispError> {
|
||||
if ctx.s.running.is_some() {
|
||||
return Ok(Flow::Value(Value::Nil));
|
||||
}
|
||||
if let Err(e) = ctx.s.start("select") {
|
||||
return Err(LispError::new(e.to_string()));
|
||||
}
|
||||
let f = super::eval::lookup(ctx.env, &super::value::Sym::new("SSGET"));
|
||||
// When SELECT ends, its objects are the previous selection: (ssget "P" [filter]).
|
||||
let mut args = vec![Value::str("P")];
|
||||
args.extend(filter);
|
||||
Ok(Flow::WaitThen(Need::Pause, f, args))
|
||||
}
|
||||
|
||||
/// A parsed `ssget` filter.
|
||||
enum Filter {
|
||||
/// One group to test, with the relational operator of a preceding `(-4 . "op")`.
|
||||
Group {
|
||||
code: i32,
|
||||
value: Value,
|
||||
op: String,
|
||||
},
|
||||
And(Vec<Filter>),
|
||||
Or(Vec<Filter>),
|
||||
Xor(Vec<Filter>),
|
||||
Not(Box<Filter>),
|
||||
}
|
||||
|
||||
fn parse_filter(list: &Value) -> Result<Filter, LispError> {
|
||||
let items = list.to_vec();
|
||||
let mut i = 0;
|
||||
let mut out = Vec::new();
|
||||
while i < items.len() {
|
||||
out.push(parse_filter_item(&items, &mut i, 0)?);
|
||||
}
|
||||
Ok(Filter::And(out))
|
||||
}
|
||||
|
||||
fn parse_filter_item(items: &[Value], i: &mut usize, depth: usize) -> Result<Filter, LispError> {
|
||||
if depth > 64 {
|
||||
return Err(LispError::new("bad ssget filter: nested too deeply"));
|
||||
}
|
||||
let Some(item) = items.get(*i) else { return Err(LispError::new("bad ssget filter")) };
|
||||
*i += 1;
|
||||
let code = item.car().as_number().map(|c| c as i32).ok_or_else(|| LispError::bad_arg("ssget filter", item))?;
|
||||
let value = item.cdr();
|
||||
if code != -4 {
|
||||
return Ok(Filter::Group { code, value, op: "=".into() });
|
||||
}
|
||||
let op = match &value {
|
||||
Value::Str(s) => s.to_ascii_uppercase(),
|
||||
other => return Err(LispError::bad_arg("stringp", other)),
|
||||
};
|
||||
let group = |items: &[Value], i: &mut usize, depth: usize, end: &str| -> Result<Vec<Filter>, LispError> {
|
||||
let mut v = Vec::new();
|
||||
loop {
|
||||
let Some(next) = items.get(*i) else { return Err(LispError::new(format!("bad ssget filter: no {end}"))) };
|
||||
if next.car().as_number() == Some(-4.0) && matches!(next.cdr(), Value::Str(ref s) if s.eq_ignore_ascii_case(end)) {
|
||||
*i += 1;
|
||||
return Ok(v);
|
||||
}
|
||||
v.push(parse_filter_item(items, i, depth + 1)?);
|
||||
}
|
||||
};
|
||||
Ok(match op.as_str() {
|
||||
"<AND" => Filter::And(group(items, i, depth, "AND>")?),
|
||||
"<OR" => Filter::Or(group(items, i, depth, "OR>")?),
|
||||
"<XOR" => Filter::Xor(group(items, i, depth, "XOR>")?),
|
||||
"<NOT" => {
|
||||
let mut v = group(items, i, depth, "NOT>")?;
|
||||
Filter::Not(Box::new(if v.len() == 1 { v.remove(0) } else { Filter::And(v) }))
|
||||
}
|
||||
_ => {
|
||||
// A relational operator applies to the next group.
|
||||
let Some(next) = items.get(*i) else { return Err(LispError::new("bad ssget filter")) };
|
||||
*i += 1;
|
||||
let code = next.car().as_number().map(|c| c as i32).ok_or_else(|| LispError::bad_arg("ssget filter", next))?;
|
||||
Filter::Group { code, value: next.cdr(), op }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn matches_filter(d: &cadcraft_doc::Drawing, e: &Entity, f: &Filter) -> bool {
|
||||
let groups = || match crate::cmd::file::entity_codec() {
|
||||
Some(c) => {
|
||||
let (kind, tags) = (c.groups)(d, e);
|
||||
let mut l = vec![Value::cons(Value::Int(0), Value::str(&kind))];
|
||||
l.extend(groups_to_list(&tags));
|
||||
l
|
||||
}
|
||||
None => vec![Value::cons(Value::Int(0), Value::str(e.kind.dxf_name())), Value::cons(Value::Int(8), Value::str(&e.common.layer))],
|
||||
};
|
||||
let groups = groups();
|
||||
eval_filter(&groups, f)
|
||||
}
|
||||
|
||||
fn eval_filter(groups: &[Value], f: &Filter) -> bool {
|
||||
match f {
|
||||
Filter::And(v) => v.iter().all(|x| eval_filter(groups, x)),
|
||||
Filter::Or(v) => v.iter().any(|x| eval_filter(groups, x)),
|
||||
Filter::Xor(v) => v.iter().filter(|x| eval_filter(groups, x)).count() == 1,
|
||||
Filter::Not(x) => !eval_filter(groups, x),
|
||||
Filter::Group { code, value, op } => {
|
||||
let found: Vec<Value> = groups.iter().filter(|g| g.car().as_number() == Some(f64::from(*code))).map(Value::cdr).collect();
|
||||
if found.is_empty() {
|
||||
// A group the entity doesn't have: layer 0 / colour BYLAYER defaults count.
|
||||
return match (code, value) {
|
||||
(62, Value::Int(256)) => op == "=",
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
found.iter().any(|have| group_matches(have, value, op))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn group_matches(have: &Value, want: &Value, op: &str) -> bool {
|
||||
if op == "*" {
|
||||
return true;
|
||||
}
|
||||
match (have, want) {
|
||||
(Value::Str(h), Value::Str(w)) => {
|
||||
// Strings match as wildcards, case-insensitively (type and layer names).
|
||||
let hit = super::builtins::wild_match(&h.to_uppercase(), &w.to_uppercase());
|
||||
if op == "!=" || op == "/=" || op == "<>" { !hit } else { hit }
|
||||
}
|
||||
_ => {
|
||||
if let (Some(h), Some(w)) = (have.as_number(), want.as_number()) {
|
||||
return compare(h, w, op);
|
||||
}
|
||||
if let (Some(h), Some(w)) = (have.as_point(), want.as_point()) {
|
||||
// Points: an operator per coordinate ("<,>,*") or one for all.
|
||||
let ops: Vec<&str> = op.split(',').map(str::trim).collect();
|
||||
return (0..3).all(|i| compare(h[i], w[i], ops.get(i).or(ops.first()).copied().unwrap_or("=")));
|
||||
}
|
||||
super::value::equal(have, want, 0.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn compare(h: f64, w: f64, op: &str) -> bool {
|
||||
match op {
|
||||
"=" => h == w,
|
||||
"!=" | "/=" | "<>" => h != w,
|
||||
"<" => h < w,
|
||||
"<=" => h <= w,
|
||||
">" => h > w,
|
||||
">=" => h >= w,
|
||||
"&" => (h as i64) & (w as i64) != 0,
|
||||
"&=" => (h as i64) & (w as i64) == (w as i64),
|
||||
"*" => true,
|
||||
_ => h == w,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ssadd(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
|
||||
arity(&a, 0, 2)?;
|
||||
match (a.first(), a.get(1)) {
|
||||
(None, _) => Ok(Flow::Value(new_pickset(ctx, Vec::new()))),
|
||||
(Some(e), None) => {
|
||||
let h = ename(e)?;
|
||||
Ok(Flow::Value(new_pickset(ctx, vec![h])))
|
||||
}
|
||||
(Some(e), Some(ss)) => {
|
||||
let h = ename(e)?;
|
||||
let p = pickset(ss)?;
|
||||
if let Ok(mut v) = p.items.lock()
|
||||
&& !v.contains(&h)
|
||||
{
|
||||
v.push(h);
|
||||
}
|
||||
Ok(Flow::Value(ss.clone()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ssdel(_ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
|
||||
arity(&a, 2, 2)?;
|
||||
let h = ename(&a[0])?;
|
||||
let p = pickset(&a[1])?;
|
||||
let removed = p.items.lock().map(|mut v| {
|
||||
let n = v.len();
|
||||
v.retain(|x| *x != h);
|
||||
n != v.len()
|
||||
});
|
||||
Ok(Flow::Value(if removed.unwrap_or(false) { a[1].clone() } else { Value::Nil }))
|
||||
}
|
||||
|
||||
pub fn sslength(_ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
|
||||
arity(&a, 1, 1)?;
|
||||
Ok(Flow::Value(Value::Int(i32::try_from(items(&pickset(&a[0])?).len()).unwrap_or(i32::MAX))))
|
||||
}
|
||||
|
||||
pub fn ssname(_ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
|
||||
arity(&a, 2, 2)?;
|
||||
let p = pickset(&a[0])?;
|
||||
let i = a[1].as_number().ok_or_else(|| LispError::bad_arg("numberp", &a[1]))?;
|
||||
if i < 0.0 {
|
||||
return Ok(Flow::Value(Value::Nil));
|
||||
}
|
||||
Ok(Flow::Value(items(&p).get(i as usize).map(|h| Value::Ename(*h)).unwrap_or_default()))
|
||||
}
|
||||
|
||||
pub fn ssmemb(_ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
|
||||
arity(&a, 2, 2)?;
|
||||
let h = ename(&a[0])?;
|
||||
Ok(Flow::Value(if items(&pickset(&a[1])?).contains(&h) { Value::Ename(h) } else { Value::Nil }))
|
||||
}
|
||||
|
||||
pub fn sssetfirst(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
|
||||
arity(&a, 1, 2)?;
|
||||
let sel = match a.get(1) {
|
||||
Some(Value::PickSet(p)) => items(p),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
ctx.s.set_selection(sel);
|
||||
Ok(Flow::Value(Value::list(a)))
|
||||
}
|
||||
|
||||
pub fn ssgetfirst(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
|
||||
arity(&a, 0, 0)?;
|
||||
let sel = ctx.s.selection();
|
||||
let ss = if sel.is_empty() { Value::Nil } else { new_pickset(ctx, sel) };
|
||||
Ok(Flow::Value(Value::list(vec![Value::Nil, ss])))
|
||||
}
|
||||
|
||||
// ---------------- symbol tables ----------------
|
||||
|
||||
fn color_index(c: cadcraft_color::Color) -> i32 {
|
||||
match c {
|
||||
cadcraft_color::Color::Index(i) => i32::from(i),
|
||||
cadcraft_color::Color::ByBlock => 0,
|
||||
cadcraft_color::Color::ByLayer => 256,
|
||||
cadcraft_color::Color::True(_) => 7,
|
||||
}
|
||||
}
|
||||
|
||||
/// The records of a symbol table, as `tblsearch` lists them.
|
||||
fn table_records(d: &cadcraft_doc::Drawing, table: &str) -> Vec<Value> {
|
||||
let pair = |c: i32, v: Value| Value::cons(Value::Int(c), v);
|
||||
match table {
|
||||
"LAYER" => d
|
||||
.layers
|
||||
.iter()
|
||||
.map(|l| {
|
||||
let flags = i32::from(l.frozen) | (i32::from(l.locked) << 2);
|
||||
let color = color_index(l.color);
|
||||
Value::list(vec![
|
||||
pair(0, Value::str("LAYER")),
|
||||
pair(2, Value::str(&l.name)),
|
||||
pair(70, Value::Int(flags)),
|
||||
pair(62, Value::Int(if l.on { color } else { -color })),
|
||||
pair(6, Value::str(&l.linetype)),
|
||||
])
|
||||
})
|
||||
.collect(),
|
||||
"LTYPE" => d
|
||||
.linetypes
|
||||
.iter()
|
||||
.map(|l| {
|
||||
Value::list(vec![
|
||||
pair(0, Value::str("LTYPE")),
|
||||
pair(2, Value::str(&l.name)),
|
||||
pair(70, Value::Int(0)),
|
||||
pair(3, Value::str(&l.description)),
|
||||
])
|
||||
})
|
||||
.collect(),
|
||||
"STYLE" => d
|
||||
.text_styles
|
||||
.iter()
|
||||
.map(|s| {
|
||||
Value::list(vec![
|
||||
pair(0, Value::str("STYLE")),
|
||||
pair(2, Value::str(&s.name)),
|
||||
pair(70, Value::Int(0)),
|
||||
pair(40, Value::Real(s.height)),
|
||||
pair(41, Value::Real(s.width_factor)),
|
||||
pair(3, Value::str(&s.font)),
|
||||
])
|
||||
})
|
||||
.collect(),
|
||||
"BLOCK" => d
|
||||
.blocks
|
||||
.values()
|
||||
.map(|b| {
|
||||
Value::list(vec![
|
||||
pair(0, Value::str("BLOCK")),
|
||||
pair(2, Value::str(&b.name)),
|
||||
pair(70, Value::Int(0)),
|
||||
Value::list(vec![Value::Int(10), Value::Real(b.base.x), Value::Real(b.base.y), Value::Real(b.base.z)]),
|
||||
])
|
||||
})
|
||||
.collect(),
|
||||
"DIMSTYLE" => d
|
||||
.dim_styles
|
||||
.iter()
|
||||
.map(|s| Value::list(vec![pair(0, Value::str("DIMSTYLE")), pair(2, Value::str(&s.name)), pair(70, Value::Int(0))]))
|
||||
.collect(),
|
||||
"APPID" => vec![Value::list(vec![pair(0, Value::str("APPID")), pair(2, Value::str("ACAD")), pair(70, Value::Int(0))])],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn record_name(r: &Value) -> String {
|
||||
r.iter().find(|p| p.car().as_number() == Some(2.0)).map(|p| to_text(&p.cdr(), false)).unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn tblsearch(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
|
||||
arity(&a, 2, 3)?;
|
||||
let table = string(&a[0])?.to_ascii_uppercase();
|
||||
let name = string(&a[1])?;
|
||||
let Ok(d) = ctx.s.doc() else { return Ok(Flow::Value(Value::Nil)) };
|
||||
let records = table_records(d, &table);
|
||||
let found = records.iter().position(|r| record_name(r).eq_ignore_ascii_case(&name));
|
||||
if let Some(i) = found
|
||||
&& a.get(2).is_some_and(|v| !v.is_nil())
|
||||
{
|
||||
ctx.env.tblnext.insert(table.clone(), i + 1);
|
||||
}
|
||||
Ok(Flow::Value(found.and_then(|i| records.get(i).cloned()).unwrap_or_default()))
|
||||
}
|
||||
|
||||
pub fn tblnext(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
|
||||
arity(&a, 1, 2)?;
|
||||
let table = string(&a[0])?.to_ascii_uppercase();
|
||||
let rewind = a.get(1).is_some_and(|v| !v.is_nil());
|
||||
let Ok(d) = ctx.s.doc() else { return Ok(Flow::Value(Value::Nil)) };
|
||||
let records = table_records(d, &table);
|
||||
let i = if rewind { 0 } else { ctx.env.tblnext.get(&table).copied().unwrap_or(0) };
|
||||
ctx.env.tblnext.insert(table, i + 1);
|
||||
Ok(Flow::Value(records.get(i).cloned().unwrap_or_default()))
|
||||
}
|
||||
|
||||
// ---------------- units ----------------
|
||||
|
||||
fn int_var(s: &Session, name: &str, default: i64) -> i64 {
|
||||
crate::sysvars::get(s, name).and_then(|v| v.as_i64()).unwrap_or(default)
|
||||
}
|
||||
|
||||
/// `(rtos number [mode [precision]])`: as LUNITS/LUPREC by default; decimal results honour
|
||||
/// DIMZIN's zero suppression (4 leading, 8 trailing).
|
||||
pub fn rtos(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
|
||||
arity(&a, 1, 3)?;
|
||||
let v = a[0].as_number().ok_or_else(|| LispError::bad_arg("numberp", &a[0]))?;
|
||||
let mode = match a.get(1) {
|
||||
Some(Value::Int(m)) => i64::from(*m),
|
||||
_ => int_var(ctx.s, "LUNITS", 2),
|
||||
};
|
||||
let prec = match a.get(2) {
|
||||
Some(Value::Int(p)) => i64::from(*p),
|
||||
_ => int_var(ctx.s, "LUPREC", 4),
|
||||
};
|
||||
if !(1..=5).contains(&mode) {
|
||||
return Err(LispError::new(format!("bad argument value: rtos mode {mode}")));
|
||||
}
|
||||
let text = if mode == 2 {
|
||||
let dimzin = int_var(ctx.s, "DIMZIN", 0);
|
||||
decimal(v, prec.clamp(0, 8) as usize, dimzin)
|
||||
} else {
|
||||
crate::units::format_distance(v, mode, prec)
|
||||
};
|
||||
Ok(Flow::Value(Value::str(&text)))
|
||||
}
|
||||
|
||||
fn decimal(v: f64, prec: usize, dimzin: i64) -> String {
|
||||
let mut s = format!("{v:.prec$}");
|
||||
if s.starts_with("-0") && s.trim_start_matches(['-', '0', '.']).is_empty() {
|
||||
s = s.trim_start_matches('-').to_string();
|
||||
}
|
||||
if dimzin & 8 != 0 && s.contains('.') {
|
||||
s = s.trim_end_matches('0').trim_end_matches('.').to_string();
|
||||
}
|
||||
if dimzin & 4 != 0 {
|
||||
if let Some(r) = s.strip_prefix("0.") {
|
||||
s = format!(".{r}");
|
||||
} else if let Some(r) = s.strip_prefix("-0.") {
|
||||
s = format!("-.{r}");
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
pub fn angtos(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
|
||||
arity(&a, 1, 3)?;
|
||||
let v = a[0].as_number().ok_or_else(|| LispError::bad_arg("numberp", &a[0]))?;
|
||||
let mode = match a.get(1) {
|
||||
Some(Value::Int(m)) => i64::from(*m),
|
||||
_ => int_var(ctx.s, "AUNITS", 0),
|
||||
};
|
||||
let prec = match a.get(2) {
|
||||
Some(Value::Int(p)) => i64::from(*p),
|
||||
_ => int_var(ctx.s, "AUPREC", 0),
|
||||
};
|
||||
Ok(Flow::Value(Value::str(&crate::units::format_angle(v, mode, prec))))
|
||||
}
|
||||
|
||||
pub fn distof(_ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
|
||||
arity(&a, 1, 2)?;
|
||||
let s = string(&a[0])?;
|
||||
Ok(Flow::Value(crate::units::parse_distance(&s).map(Value::Real).unwrap_or_default()))
|
||||
}
|
||||
|
||||
pub fn angtof(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
|
||||
arity(&a, 1, 2)?;
|
||||
let s = string(&a[0])?;
|
||||
Ok(Flow::Value(ctx.s.angle_settings().direction(&s).map(Value::Real).unwrap_or_default()))
|
||||
}
|
||||
|
||||
/// `(trans pt from to [disp])`: points are in the world system throughout (no UCS yet).
|
||||
pub fn trans(_ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
|
||||
arity(&a, 3, 4)?;
|
||||
let p = a[0].as_point().ok_or_else(|| LispError::bad_arg("2D/3D point", &a[0]))?;
|
||||
Ok(Flow::Value(Value::point(&p)))
|
||||
}
|
||||
|
||||
/// What `(load name)` reads: a file loaded through APPLOAD (the web), or one on disk, with
|
||||
/// `.lsp` added when the name has no extension.
|
||||
pub fn load_source(s: &Session, name: &str) -> Option<String> {
|
||||
if let Some(t) = s.lisp_file(name) {
|
||||
return Some(t);
|
||||
}
|
||||
let candidates =
|
||||
if std::path::Path::new(name).extension().is_none() { vec![format!("{name}.lsp"), name.to_string()] } else { vec![name.to_string()] };
|
||||
// As given (a path, or relative to the working folder), then in the support folder.
|
||||
let support =
|
||||
s.lisp_support_dir().map(|d| candidates.iter().map(|c| d.join(c).to_string_lossy().into_owned()).collect::<Vec<_>>()).unwrap_or_default();
|
||||
candidates.iter().chain(&support).find_map(|c| super::builtins::read_text_file(c))
|
||||
}
|
||||
@@ -0,0 +1,656 @@
|
||||
//! The evaluator: an explicit-stack machine. Nothing recurses on the Rust stack, so a routine
|
||||
//! can stop in the middle (a `getpoint` waiting for a click, a `pause` inside `command`) and
|
||||
//! carry on when the input arrives, on one thread (the web build has no others). Deep recursion
|
||||
//! in LISP is an error ("internal stack limit reached"), never a crash.
|
||||
//!
|
||||
//! Variables are dynamically scoped, as in AutoLISP: a function's arguments and locals are bound
|
||||
//! on entry and restored when it returns, or when an error unwinds through it.
|
||||
|
||||
use super::value::{BuiltinKind, Func, Sym, Value, to_text};
|
||||
use super::{Env, LispError};
|
||||
use crate::Session;
|
||||
|
||||
/// Frames on the machine's stack: what to do with the value of the expression just evaluated.
|
||||
pub enum Frame {
|
||||
/// Evaluating a call's arguments: `f` gets `done` and then the values of `rest`.
|
||||
Args {
|
||||
f: Value,
|
||||
rest: Value,
|
||||
done: Vec<Value>,
|
||||
},
|
||||
/// The head of a form was itself a form (`((lambda (x) …) 1)`): call its value with `args`.
|
||||
Head {
|
||||
args: Value,
|
||||
},
|
||||
Progn {
|
||||
rest: Value,
|
||||
},
|
||||
If {
|
||||
then: Value,
|
||||
els: Value,
|
||||
},
|
||||
/// A `cond` clause's test was evaluated: its body runs when true, else the next clauses.
|
||||
Cond {
|
||||
body: Value,
|
||||
rest: Value,
|
||||
},
|
||||
And {
|
||||
rest: Value,
|
||||
},
|
||||
Or {
|
||||
rest: Value,
|
||||
},
|
||||
Setq {
|
||||
sym: Sym,
|
||||
rest: Value,
|
||||
},
|
||||
WhileTest {
|
||||
test: Value,
|
||||
body: Value,
|
||||
last: Value,
|
||||
},
|
||||
WhileBody {
|
||||
test: Value,
|
||||
body: Value,
|
||||
},
|
||||
/// `repeat`'s count was evaluated.
|
||||
RepeatCount {
|
||||
body: Value,
|
||||
},
|
||||
Repeat {
|
||||
left: i64,
|
||||
body: Value,
|
||||
},
|
||||
/// `foreach`'s list was evaluated.
|
||||
ForeachList {
|
||||
var: Sym,
|
||||
body: Value,
|
||||
},
|
||||
Foreach {
|
||||
var: Sym,
|
||||
items: Value,
|
||||
body: Value,
|
||||
saved: Option<Value>,
|
||||
},
|
||||
/// A function call ends: restore the bindings its arguments and locals hid.
|
||||
Unbind {
|
||||
saved: Vec<(Sym, Option<Value>)>,
|
||||
},
|
||||
/// `mapcar`: `f` is called with the heads of `lists` in turn.
|
||||
Mapcar {
|
||||
f: Value,
|
||||
lists: Vec<Value>,
|
||||
acc: Vec<Value>,
|
||||
},
|
||||
/// Top-level forms of a loaded file or a command line, evaluated in turn.
|
||||
Forms {
|
||||
rest: Vec<Value>,
|
||||
},
|
||||
/// `vl-catch-all-apply`: an error below this frame becomes its value.
|
||||
Catch,
|
||||
/// After a wait, call `f` with `args` (the rest of a `command` after a `pause`).
|
||||
Resume {
|
||||
f: Value,
|
||||
args: Vec<Value>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Special forms: their arguments are not evaluated first.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Special {
|
||||
Quote,
|
||||
Function,
|
||||
Setq,
|
||||
If,
|
||||
Cond,
|
||||
While,
|
||||
Repeat,
|
||||
Foreach,
|
||||
Progn,
|
||||
And,
|
||||
Or,
|
||||
Defun,
|
||||
Lambda,
|
||||
}
|
||||
|
||||
/// What a built-in asks of the machine.
|
||||
pub enum Flow {
|
||||
Value(Value),
|
||||
/// Call a function with these arguments (`apply`).
|
||||
Call(Value, Vec<Value>),
|
||||
/// Evaluate an expression (`eval`).
|
||||
Eval(Value),
|
||||
/// Evaluate forms in order; the value is the last one's (`load`).
|
||||
Forms(Vec<Value>),
|
||||
/// `mapcar`.
|
||||
Map(Value, Vec<Value>),
|
||||
/// `vl-catch-all-apply`: call the function, turning an error into a value.
|
||||
Catch(Value, Vec<Value>),
|
||||
/// Stop until the session supplies a value (see [`Need`]).
|
||||
Wait(Need),
|
||||
/// Stop, then call the function with these arguments.
|
||||
WaitThen(Need, Value, Vec<Value>),
|
||||
}
|
||||
|
||||
/// Why the machine stopped before finishing.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum Need {
|
||||
/// A `get…` function waits for the user.
|
||||
Get(super::input::GetRequest),
|
||||
/// `pause` inside `command`: the running command takes one input from the user.
|
||||
Pause,
|
||||
/// The step budget ran out: let the interface draw a frame, then carry on.
|
||||
Yield,
|
||||
}
|
||||
|
||||
/// How a run ended.
|
||||
pub enum Outcome {
|
||||
Done(Value),
|
||||
Wait(Need),
|
||||
Error(LispError),
|
||||
}
|
||||
|
||||
/// What builtins get: the session and the LISP environment.
|
||||
pub struct Ctx<'a> {
|
||||
pub s: &'a mut Session,
|
||||
pub env: &'a mut Env,
|
||||
/// Nesting depth of [`Ctx::call_sync`].
|
||||
pub depth: usize,
|
||||
}
|
||||
|
||||
/// Deepest stack the machine allows, in frames: well above real routines, well below memory
|
||||
/// trouble.
|
||||
pub const MAX_STACK: usize = 100_000;
|
||||
/// Nesting depth of synchronous callbacks (`vl-sort` comparing with a LISP function, …).
|
||||
const MAX_SYNC_DEPTH: usize = 32;
|
||||
|
||||
enum State {
|
||||
Eval(Value),
|
||||
Ret(Value),
|
||||
Call(Value, Vec<Value>),
|
||||
}
|
||||
|
||||
pub struct Machine {
|
||||
stack: Vec<Frame>,
|
||||
state: State,
|
||||
need: Option<Need>,
|
||||
}
|
||||
|
||||
impl Machine {
|
||||
/// A machine evaluating `forms` in turn.
|
||||
pub fn forms(forms: Vec<Value>) -> Machine {
|
||||
Machine { stack: vec![Frame::Forms { rest: forms.into_iter().rev().collect() }], state: State::Ret(Value::Nil), need: None }
|
||||
}
|
||||
|
||||
/// A machine calling `f` with `args`.
|
||||
pub fn calling(f: Value, args: Vec<Value>) -> Machine {
|
||||
Machine { stack: Vec::new(), state: State::Call(f, args), need: None }
|
||||
}
|
||||
|
||||
/// Give the value a [`Need`] waited for, and carry on.
|
||||
pub fn resume(&mut self, v: Value) {
|
||||
self.state = State::Ret(v);
|
||||
}
|
||||
|
||||
/// Run until done, waiting, or `budget` steps.
|
||||
pub fn run(&mut self, ctx: &mut Ctx<'_>, budget: u64) -> Outcome {
|
||||
let mut n = 0u64;
|
||||
loop {
|
||||
n += 1;
|
||||
if n > budget {
|
||||
return Outcome::Wait(Need::Yield);
|
||||
}
|
||||
let r = match std::mem::replace(&mut self.state, State::Ret(Value::Nil)) {
|
||||
State::Eval(expr) => self.eval(ctx, expr),
|
||||
State::Call(f, args) => self.call(ctx, f, args),
|
||||
State::Ret(v) => match self.stack.pop() {
|
||||
None => return Outcome::Done(v),
|
||||
Some(frame) => self.ret(ctx, frame, v),
|
||||
},
|
||||
};
|
||||
// A runaway routine stops before it takes all memory.
|
||||
let r = r.and_then(|()| super::value::check_cells());
|
||||
if let Err(e) = r {
|
||||
self.need = None;
|
||||
if let Some(o) = self.unwind(ctx, e) {
|
||||
return o;
|
||||
}
|
||||
} else if self.stack.len() > MAX_STACK {
|
||||
if let Some(o) = self.unwind(ctx, LispError::new("internal stack limit reached")) {
|
||||
return o;
|
||||
}
|
||||
} else if let Some(need) = self.need.take() {
|
||||
return Outcome::Wait(need);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An error: pop frames back to a `vl-catch-all-apply` (its value becomes the error), or out
|
||||
/// of the run. Bindings are restored on the way.
|
||||
fn unwind(&mut self, ctx: &mut Ctx<'_>, e: LispError) -> Option<Outcome> {
|
||||
while let Some(frame) = self.stack.pop() {
|
||||
match frame {
|
||||
Frame::Unbind { saved } => restore(ctx.env, saved),
|
||||
Frame::Foreach { var, saved, .. } => restore(ctx.env, vec![(var, saved)]),
|
||||
Frame::Catch if !e.quit => {
|
||||
self.state = State::Ret(super::builtins::catch_all_error(&e.msg));
|
||||
return None;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Some(Outcome::Error(e))
|
||||
}
|
||||
|
||||
fn eval(&mut self, ctx: &mut Ctx<'_>, expr: Value) -> Result<(), LispError> {
|
||||
match expr {
|
||||
Value::Sym(s) => self.state = State::Ret(lookup(ctx.env, &s)),
|
||||
Value::Cons(c) => {
|
||||
let (head, args) = (c.car.clone(), c.cdr.clone());
|
||||
let f = match &head {
|
||||
Value::Sym(s) => {
|
||||
let f = lookup(ctx.env, s);
|
||||
if f.is_nil() {
|
||||
return Err(LispError::new(format!("no function definition: {}", s.name())));
|
||||
}
|
||||
f
|
||||
}
|
||||
Value::Cons(_) => {
|
||||
self.stack.push(Frame::Head { args });
|
||||
self.state = State::Eval(head);
|
||||
return Ok(());
|
||||
}
|
||||
other => other.clone(),
|
||||
};
|
||||
return self.apply_form(ctx, f, args);
|
||||
}
|
||||
atom => self.state = State::Ret(atom),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A form whose head evaluated to `f`: a special form, or a call with `args` to evaluate.
|
||||
fn apply_form(&mut self, ctx: &mut Ctx<'_>, f: Value, args: Value) -> Result<(), LispError> {
|
||||
if let Value::Subr(b) = &f
|
||||
&& let BuiltinKind::Special(sp) = b.kind
|
||||
{
|
||||
return self.special(ctx, sp, args);
|
||||
}
|
||||
if !matches!(f, Value::Subr(_) | Value::Func(_)) {
|
||||
return Err(LispError::new(format!("bad function: {}", to_text(&f, true))));
|
||||
}
|
||||
match args {
|
||||
Value::Cons(c) => {
|
||||
self.stack.push(Frame::Args { f, rest: c.cdr.clone(), done: Vec::new() });
|
||||
self.state = State::Eval(c.car.clone());
|
||||
}
|
||||
_ => self.state = State::Call(f, Vec::new()),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ret(&mut self, ctx: &mut Ctx<'_>, frame: Frame, v: Value) -> Result<(), LispError> {
|
||||
match frame {
|
||||
Frame::Args { f, rest, mut done } => {
|
||||
done.push(v);
|
||||
match rest {
|
||||
Value::Cons(c) => {
|
||||
self.stack.push(Frame::Args { f, rest: c.cdr.clone(), done });
|
||||
self.state = State::Eval(c.car.clone());
|
||||
}
|
||||
_ => self.state = State::Call(f, done),
|
||||
}
|
||||
}
|
||||
Frame::Head { args } => return self.apply_form(ctx, v, args),
|
||||
Frame::Progn { rest } => self.progn(rest, v),
|
||||
Frame::If { then, els } => self.state = State::Eval(if v.is_nil() { els } else { then }),
|
||||
Frame::Cond { body, rest } => {
|
||||
if v.is_nil() {
|
||||
self.cond(rest);
|
||||
} else if body.is_nil() {
|
||||
self.state = State::Ret(v);
|
||||
} else {
|
||||
self.progn(body, Value::Nil);
|
||||
}
|
||||
}
|
||||
Frame::And { rest } => {
|
||||
if v.is_nil() {
|
||||
self.state = State::Ret(Value::Nil);
|
||||
} else {
|
||||
self.and_or(rest, true);
|
||||
}
|
||||
}
|
||||
Frame::Or { rest } => {
|
||||
if v.is_nil() {
|
||||
self.and_or(rest, false);
|
||||
} else {
|
||||
self.state = State::Ret(Value::t());
|
||||
}
|
||||
}
|
||||
Frame::Setq { sym, rest } => {
|
||||
ctx.env.set(&sym, v.clone());
|
||||
self.setq(rest, v)?;
|
||||
}
|
||||
Frame::WhileTest { test, body, last } => {
|
||||
if v.is_nil() {
|
||||
self.state = State::Ret(last);
|
||||
} else {
|
||||
self.stack.push(Frame::WhileBody { test, body: body.clone() });
|
||||
self.progn(body, Value::Nil);
|
||||
}
|
||||
}
|
||||
Frame::WhileBody { test, body } => {
|
||||
self.stack.push(Frame::WhileTest { test: test.clone(), body, last: v });
|
||||
self.state = State::Eval(test);
|
||||
}
|
||||
Frame::RepeatCount { body } => {
|
||||
let Value::Int(n) = v else { return Err(LispError::bad_arg("fixnump", &v)) };
|
||||
self.repeat(i64::from(n), body, Value::Nil);
|
||||
}
|
||||
Frame::Repeat { left, body } => self.repeat(left, body, v),
|
||||
Frame::ForeachList { var, body } => {
|
||||
if !v.is_list() {
|
||||
return Err(LispError::bad_arg("listp", &v));
|
||||
}
|
||||
let saved = ctx.env.globals.get(&var).cloned();
|
||||
self.foreach(ctx, var, v, body, saved, Value::Nil);
|
||||
}
|
||||
Frame::Foreach { var, items, body, saved } => self.foreach(ctx, var, items, body, saved, v),
|
||||
Frame::Unbind { saved } => {
|
||||
restore(ctx.env, saved);
|
||||
self.state = State::Ret(v);
|
||||
}
|
||||
Frame::Mapcar { f, lists, mut acc } => {
|
||||
acc.push(v);
|
||||
let lists: Vec<Value> = lists.iter().map(Value::cdr).collect();
|
||||
if lists.iter().any(|l| !matches!(l, Value::Cons(_))) {
|
||||
self.state = State::Ret(Value::list(acc));
|
||||
} else {
|
||||
let args = lists.iter().map(Value::car).collect();
|
||||
self.stack.push(Frame::Mapcar { f: f.clone(), lists, acc });
|
||||
self.state = State::Call(f, args);
|
||||
}
|
||||
}
|
||||
Frame::Forms { mut rest } => match rest.pop() {
|
||||
Some(next) => {
|
||||
self.stack.push(Frame::Forms { rest });
|
||||
self.state = State::Eval(next);
|
||||
}
|
||||
None => self.state = State::Ret(v),
|
||||
},
|
||||
Frame::Catch => self.state = State::Ret(v),
|
||||
Frame::Resume { f, args } => self.state = State::Call(f, args),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Evaluate the forms of `body` in turn; `last` is the value when there are none.
|
||||
fn progn(&mut self, body: Value, last: Value) {
|
||||
match body {
|
||||
Value::Cons(c) => {
|
||||
if !c.cdr.is_nil() {
|
||||
self.stack.push(Frame::Progn { rest: c.cdr.clone() });
|
||||
}
|
||||
self.state = State::Eval(c.car.clone());
|
||||
}
|
||||
_ => self.state = State::Ret(last),
|
||||
}
|
||||
}
|
||||
|
||||
fn cond(&mut self, clauses: Value) {
|
||||
match clauses {
|
||||
Value::Cons(c) => {
|
||||
let clause = c.car.clone();
|
||||
self.stack.push(Frame::Cond { body: clause.cdr(), rest: c.cdr.clone() });
|
||||
self.state = State::Eval(clause.car());
|
||||
}
|
||||
_ => self.state = State::Ret(Value::Nil),
|
||||
}
|
||||
}
|
||||
|
||||
/// The next operand of `and` (`and_`) or `or`.
|
||||
fn and_or(&mut self, rest: Value, and_: bool) {
|
||||
match rest {
|
||||
Value::Cons(c) => {
|
||||
self.stack.push(if and_ { Frame::And { rest: c.cdr.clone() } } else { Frame::Or { rest: c.cdr.clone() } });
|
||||
self.state = State::Eval(c.car.clone());
|
||||
}
|
||||
_ => self.state = State::Ret(Value::bool(and_)),
|
||||
}
|
||||
}
|
||||
|
||||
fn setq(&mut self, pairs: Value, last: Value) -> Result<(), LispError> {
|
||||
match pairs {
|
||||
Value::Cons(c) => {
|
||||
let Value::Sym(sym) = c.car.clone() else {
|
||||
return Err(LispError::bad_arg("symbolp", &c.car));
|
||||
};
|
||||
let Value::Cons(rest) = c.cdr.clone() else { return Err(LispError::new("too few arguments")) };
|
||||
self.stack.push(Frame::Setq { sym, rest: rest.cdr.clone() });
|
||||
self.state = State::Eval(rest.car.clone());
|
||||
}
|
||||
_ => self.state = State::Ret(last),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn repeat(&mut self, left: i64, body: Value, last: Value) {
|
||||
if left <= 0 || body.is_nil() {
|
||||
self.state = State::Ret(last);
|
||||
} else {
|
||||
self.stack.push(Frame::Repeat { left: left - 1, body: body.clone() });
|
||||
self.progn(body, Value::Nil);
|
||||
}
|
||||
}
|
||||
|
||||
fn foreach(&mut self, ctx: &mut Ctx<'_>, var: Sym, items: Value, body: Value, saved: Option<Value>, last: Value) {
|
||||
match items {
|
||||
Value::Cons(c) => {
|
||||
ctx.env.set(&var, c.car.clone());
|
||||
self.stack.push(Frame::Foreach { var, items: c.cdr.clone(), body: body.clone(), saved });
|
||||
self.progn(body, Value::Nil);
|
||||
}
|
||||
_ => {
|
||||
restore(ctx.env, vec![(var, saved)]);
|
||||
self.state = State::Ret(last);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn special(&mut self, ctx: &mut Ctx<'_>, sp: Special, args: Value) -> Result<(), LispError> {
|
||||
match sp {
|
||||
Special::Quote => self.state = State::Ret(args.car()),
|
||||
Special::Function => {
|
||||
let a = args.car();
|
||||
self.state = State::Ret(if a.car().as_sym().is_some_and(|s| s.name() == "LAMBDA") { make_func(None, a.cdr())? } else { a });
|
||||
}
|
||||
Special::Setq => {
|
||||
if args.is_nil() {
|
||||
return Err(LispError::new("too few arguments"));
|
||||
}
|
||||
self.setq(args, Value::Nil)?;
|
||||
}
|
||||
Special::If => {
|
||||
let v = args.to_vec();
|
||||
if v.len() < 2 {
|
||||
return Err(LispError::new("too few arguments"));
|
||||
}
|
||||
if v.len() > 3 {
|
||||
return Err(LispError::new("too many arguments"));
|
||||
}
|
||||
let mut it = v.into_iter();
|
||||
let test = it.next().unwrap_or_default();
|
||||
let then = it.next().unwrap_or_default();
|
||||
let els = it.next().unwrap_or_default();
|
||||
self.stack.push(Frame::If { then, els });
|
||||
self.state = State::Eval(test);
|
||||
}
|
||||
Special::Cond => self.cond(args),
|
||||
Special::While => {
|
||||
let test = args.car();
|
||||
self.stack.push(Frame::WhileTest { test: test.clone(), body: args.cdr(), last: Value::Nil });
|
||||
self.state = State::Eval(test);
|
||||
}
|
||||
Special::Repeat => {
|
||||
self.stack.push(Frame::RepeatCount { body: args.cdr() });
|
||||
self.state = State::Eval(args.car());
|
||||
}
|
||||
Special::Foreach => {
|
||||
let Value::Sym(var) = args.car() else { return Err(LispError::bad_arg("symbolp", &args.car())) };
|
||||
self.stack.push(Frame::ForeachList { var, body: args.cdr().cdr() });
|
||||
self.state = State::Eval(args.cdr().car());
|
||||
}
|
||||
Special::Progn => self.progn(args, Value::Nil),
|
||||
Special::And => self.and_or(args, true),
|
||||
Special::Or => self.and_or(args, false),
|
||||
Special::Defun => {
|
||||
let Value::Sym(name) = args.car() else { return Err(LispError::bad_arg("symbolp", &args.car())) };
|
||||
let f = make_func(Some(name.clone()), args.cdr())?;
|
||||
ctx.env.set(&name, f);
|
||||
self.state = State::Ret(Value::Sym(name));
|
||||
}
|
||||
Special::Lambda => self.state = State::Ret(make_func(None, args)?),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Call `f` with evaluated `args`.
|
||||
fn call(&mut self, ctx: &mut Ctx<'_>, f: Value, args: Vec<Value>) -> Result<(), LispError> {
|
||||
match &f {
|
||||
Value::Subr(b) => match b.kind {
|
||||
BuiltinKind::Pure(func) => {
|
||||
let v = func(&args)?;
|
||||
super::value::charge_result(&v)?;
|
||||
self.state = State::Ret(v);
|
||||
}
|
||||
BuiltinKind::Ctx(func) => {
|
||||
let flow = func(ctx, args)?;
|
||||
if let Flow::Value(v) = &flow {
|
||||
super::value::charge_result(v)?;
|
||||
}
|
||||
self.flow(flow);
|
||||
}
|
||||
BuiltinKind::Special(_) => return Err(LispError::new(format!("bad function: {}", b.name))),
|
||||
},
|
||||
Value::Func(func) => {
|
||||
if args.len() < func.params.len() {
|
||||
return Err(LispError::new("too few arguments"));
|
||||
}
|
||||
if args.len() > func.params.len() {
|
||||
return Err(LispError::new("too many arguments"));
|
||||
}
|
||||
let mut saved = Vec::with_capacity(func.params.len() + func.locals.len());
|
||||
for (p, a) in func.params.iter().zip(args) {
|
||||
saved.push((p.clone(), ctx.env.globals.get(p).cloned()));
|
||||
ctx.env.set(p, a);
|
||||
}
|
||||
for l in &func.locals {
|
||||
saved.push((l.clone(), ctx.env.globals.get(l).cloned()));
|
||||
ctx.env.set(l, Value::Nil);
|
||||
}
|
||||
self.stack.push(Frame::Unbind { saved });
|
||||
self.progn(func.body.clone(), Value::Nil);
|
||||
}
|
||||
other => return Err(LispError::new(format!("bad function: {}", to_text(other, true)))),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn flow(&mut self, flow: Flow) {
|
||||
match flow {
|
||||
Flow::Value(v) => self.state = State::Ret(v),
|
||||
Flow::Call(f, args) => self.state = State::Call(f, args),
|
||||
Flow::Eval(e) => self.state = State::Eval(e),
|
||||
Flow::Forms(forms) => {
|
||||
self.stack.push(Frame::Forms { rest: forms.into_iter().rev().collect() });
|
||||
self.state = State::Ret(Value::Nil);
|
||||
}
|
||||
Flow::Map(f, lists) => {
|
||||
if lists.is_empty() || lists.iter().any(|l| !matches!(l, Value::Cons(_))) {
|
||||
self.state = State::Ret(Value::Nil);
|
||||
} else {
|
||||
let args = lists.iter().map(Value::car).collect();
|
||||
self.stack.push(Frame::Mapcar { f: f.clone(), lists, acc: Vec::new() });
|
||||
self.state = State::Call(f, args);
|
||||
}
|
||||
}
|
||||
Flow::Catch(f, args) => {
|
||||
self.stack.push(Frame::Catch);
|
||||
self.state = State::Call(f, args);
|
||||
}
|
||||
Flow::WaitThen(need, f, args) => {
|
||||
self.stack.push(Frame::Resume { f, args });
|
||||
self.state = State::Ret(Value::Nil);
|
||||
self.need = Some(need);
|
||||
}
|
||||
Flow::Wait(need) => {
|
||||
// The value the session supplies replaces this call's.
|
||||
self.state = State::Ret(Value::Nil);
|
||||
self.need = Some(need);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A variable's value (nil when unbound).
|
||||
pub fn lookup(env: &Env, s: &Sym) -> Value {
|
||||
env.globals.get(s).cloned().unwrap_or(Value::Nil)
|
||||
}
|
||||
|
||||
fn restore(env: &mut Env, saved: Vec<(Sym, Option<Value>)>) {
|
||||
for (s, v) in saved.into_iter().rev() {
|
||||
match v {
|
||||
Some(v) => {
|
||||
env.globals.insert(s, v);
|
||||
}
|
||||
None => {
|
||||
env.globals.remove(&s);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A function from `((params / locals) body…)`.
|
||||
fn make_func(name: Option<Sym>, def: Value) -> Result<Value, LispError> {
|
||||
let arglist = def.car();
|
||||
if !arglist.is_list() {
|
||||
return Err(LispError::bad_arg("listp", &arglist));
|
||||
}
|
||||
let (mut params, mut locals) = (Vec::new(), Vec::new());
|
||||
let mut after_slash = false;
|
||||
for a in arglist.iter() {
|
||||
let Value::Sym(s) = a else { return Err(LispError::bad_arg("symbolp", &a)) };
|
||||
if s.name() == "/" {
|
||||
after_slash = true;
|
||||
} else if after_slash {
|
||||
locals.push(s);
|
||||
} else {
|
||||
params.push(s);
|
||||
}
|
||||
}
|
||||
Ok(Value::Func(std::sync::Arc::new(Func { name, params, locals, body: def.cdr() })))
|
||||
}
|
||||
|
||||
impl Ctx<'_> {
|
||||
/// Call `f` to completion here (a comparison for `vl-sort`, a test for `vl-remove-if`…).
|
||||
/// It may not wait for input.
|
||||
pub fn call_sync(&mut self, f: &Value, args: Vec<Value>) -> Result<Value, LispError> {
|
||||
if self.depth >= MAX_SYNC_DEPTH {
|
||||
return Err(LispError::new("internal stack limit reached"));
|
||||
}
|
||||
if let Value::Subr(b) = f
|
||||
&& let BuiltinKind::Pure(func) = b.kind
|
||||
{
|
||||
let v = func(&args)?;
|
||||
super::value::charge_result(&v)?;
|
||||
return Ok(v);
|
||||
}
|
||||
let mut m = Machine::calling(f.clone(), args);
|
||||
let mut sub = Ctx { s: &mut *self.s, env: &mut *self.env, depth: self.depth + 1 };
|
||||
match m.run(&mut sub, u64::MAX) {
|
||||
Outcome::Done(v) => Ok(v),
|
||||
Outcome::Error(e) => Err(e),
|
||||
Outcome::Wait(_) => Err(LispError::new("this function can't ask for input here")),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
//! User input from LISP: `getpoint`, `getcorner`, `getdist`, `getangle`, `getorient`,
|
||||
//! `getreal`, `getint`, `getstring`, `getkword`, `entsel`, `nentsel`, and `initget`, which sets
|
||||
//! what the next one accepts (bits: 1 no Enter, 2 no zero, 4 no negatives, 128 any text; and
|
||||
//! keywords).
|
||||
|
||||
use cadcraft_geom::Vec2;
|
||||
|
||||
use super::eval::{Ctx, Flow, Need};
|
||||
use super::value::Value;
|
||||
use super::{LispError, builtins::args_err};
|
||||
use crate::{Accept, Input, Prompt, Session};
|
||||
|
||||
/// `initget` settings.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Initget {
|
||||
pub bits: i32,
|
||||
pub keywords: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum GetKind {
|
||||
Point,
|
||||
Corner,
|
||||
Dist,
|
||||
/// `getangle` (relative to ANGBASE) or `getorient` (absolute).
|
||||
Angle {
|
||||
orient: bool,
|
||||
},
|
||||
Real,
|
||||
Int,
|
||||
/// `getstring`; `spaces`: a space is part of the text (non-nil first argument).
|
||||
String {
|
||||
spaces: bool,
|
||||
},
|
||||
Kword,
|
||||
/// `entsel` / `nentsel`: an object picked.
|
||||
EntSel,
|
||||
}
|
||||
|
||||
/// A `get…` call waiting for the user.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GetRequest {
|
||||
pub kind: GetKind,
|
||||
pub message: String,
|
||||
pub base: Option<[f64; 3]>,
|
||||
pub initget: Initget,
|
||||
/// `getdist`/`getangle` without a base point: the first point given, the second to come.
|
||||
pub first: Option<[f64; 3]>,
|
||||
}
|
||||
|
||||
impl GetRequest {
|
||||
/// The prompt the command line shows.
|
||||
pub fn prompt(&self) -> Prompt {
|
||||
let any = self.initget.bits & 128 != 0 || !self.initget.keywords.is_empty();
|
||||
let accept = match self.kind {
|
||||
GetKind::Point | GetKind::Corner | GetKind::EntSel => Accept { text: any, ..Accept::POINT },
|
||||
GetKind::Dist | GetKind::Angle { .. } => Accept { text: any, ..Accept::POINT_OR_NUMBER },
|
||||
GetKind::Real | GetKind::Int => Accept { text: any, ..Accept::NUMBER },
|
||||
GetKind::String { .. } | GetKind::Kword => Accept::TEXT,
|
||||
};
|
||||
let message = match (&self.first, self.message.is_empty()) {
|
||||
(Some(_), _) => "Specify second point: ".to_string(),
|
||||
(None, true) => default_message(self.kind).to_string(),
|
||||
(None, false) => self.message.clone(),
|
||||
};
|
||||
let base = self.first.or(self.base).map(|p| Vec2::new(p[0], p[1]));
|
||||
let mut p = Prompt::new(message, accept).base_opt(base);
|
||||
p.keywords = self.initget.keywords.clone();
|
||||
p.raw = true;
|
||||
p
|
||||
}
|
||||
|
||||
/// What an input gives: a value, a reason to ask again, or (for a distance or an angle
|
||||
/// without a base point) the first of two points.
|
||||
pub fn answer(&self, s: &mut Session, input: Input) -> Answer {
|
||||
match self.answer_value(s, input) {
|
||||
Ok(v) => Answer::Value(v),
|
||||
Err(Again::Second(p)) => Answer::Second(p),
|
||||
Err(Again::Msg(m)) => Answer::Again(m),
|
||||
}
|
||||
}
|
||||
|
||||
fn answer_value(&self, s: &mut Session, input: Input) -> Result<Value, Again> {
|
||||
let ig = &self.initget;
|
||||
let no_null = ig.bits & 1 != 0;
|
||||
match input {
|
||||
Input::Enter => {
|
||||
if matches!(self.kind, GetKind::String { .. }) {
|
||||
return Ok(Value::str(""));
|
||||
}
|
||||
if no_null {
|
||||
return Err(match self.kind {
|
||||
GetKind::Int => "Requires an integer value.",
|
||||
GetKind::Real | GetKind::Dist | GetKind::Angle { .. } => "Requires numeric value.",
|
||||
GetKind::Kword => "Invalid option keyword.",
|
||||
_ => "Invalid point.",
|
||||
}
|
||||
.into());
|
||||
}
|
||||
Ok(Value::Nil)
|
||||
}
|
||||
Input::Keyword(k) => Ok(Value::str(&k)),
|
||||
Input::Point(p) => self.point(s, [p.x, p.y, 0.0]),
|
||||
// A deferred tangent/perpendicular snap is the point it was picked at here.
|
||||
Input::Deferred(d) => self.point(s, [d.at.x, d.at.y, 0.0]),
|
||||
Input::Pick(hs) => match (self.kind, hs.first()) {
|
||||
(GetKind::EntSel, Some(h)) => Ok(Value::list(vec![Value::Ename(*h), Value::point(&[s.last_point.x, s.last_point.y, 0.0])])),
|
||||
_ => Err("Invalid point.".into()),
|
||||
},
|
||||
Input::Text(t) => self.text(s, &t),
|
||||
Input::Cancel => Err("*Cancel*".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn point(&self, s: &mut Session, p: [f64; 3]) -> Result<Value, Again> {
|
||||
match self.kind {
|
||||
GetKind::Point | GetKind::Corner => Ok(Value::point(&p)),
|
||||
GetKind::Dist | GetKind::Angle { .. } => {
|
||||
let Some(base) = self.first.or(self.base) else {
|
||||
// The first of two points: ask for the second.
|
||||
return Err(Again::Second(p));
|
||||
};
|
||||
let (dx, dy) = (p[0] - base[0], p[1] - base[1]);
|
||||
if let GetKind::Angle { orient } = self.kind {
|
||||
let mut a = dy.atan2(dx);
|
||||
if !orient {
|
||||
a -= s.lisp_angbase();
|
||||
}
|
||||
return self.check_number(a.rem_euclid(std::f64::consts::TAU), false);
|
||||
}
|
||||
let d = if self.initget.bits & 64 != 0 { (dx * dx + dy * dy).sqrt() } else { (dx * dx + dy * dy + (p[2] - base[2]).powi(2)).sqrt() };
|
||||
self.check_number(d, false)
|
||||
}
|
||||
GetKind::EntSel => {
|
||||
let ap = s.settings.pickbox.max(1.0) * s.pixel_size();
|
||||
let space = s.state().map(|st| st.edit_space()).map_err(|e| Again::Msg(e.to_string()))?;
|
||||
let d = s.doc().map_err(|e| Again::Msg(e.to_string()))?;
|
||||
match crate::select::pick(d, &space, Vec2::new(p[0], p[1]), ap) {
|
||||
Some(h) => Ok(Value::list(vec![Value::Ename(h), Value::point(&p)])),
|
||||
None => Err("Missed, try again.".into()),
|
||||
}
|
||||
}
|
||||
_ => Err(match self.kind {
|
||||
GetKind::Int => "Requires an integer value.",
|
||||
GetKind::Kword => "Invalid option keyword.",
|
||||
GetKind::String { .. } => "Requires a string.",
|
||||
_ => "Requires numeric value.",
|
||||
}
|
||||
.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn text(&self, s: &mut Session, t: &str) -> Result<Value, Again> {
|
||||
let ig = &self.initget;
|
||||
if let Some(k) = keyword(&ig.keywords, t) {
|
||||
return Ok(Value::str(&k));
|
||||
}
|
||||
let any = ig.bits & 128 != 0;
|
||||
match self.kind {
|
||||
GetKind::String { spaces } => {
|
||||
let t = if spaces { t } else { t.split(' ').next().unwrap_or("") };
|
||||
Ok(Value::str(t))
|
||||
}
|
||||
GetKind::Kword => Err("Invalid option keyword.".into()),
|
||||
GetKind::Int => match t.trim().parse::<i32>() {
|
||||
Ok(i) if (-32768..=32767).contains(&i) => self.check_number(f64::from(i), true),
|
||||
Ok(_) => Err("Value must be between -32768 and 32767.".into()),
|
||||
Err(_) if any => Ok(Value::str(t)),
|
||||
Err(_) => Err("Requires an integer value.".into()),
|
||||
},
|
||||
GetKind::Real => match t.trim().parse::<f64>() {
|
||||
Ok(v) if v.is_finite() => self.check_number(v, false),
|
||||
_ if any => Ok(Value::str(t)),
|
||||
_ => Err("Requires numeric value.".into()),
|
||||
},
|
||||
GetKind::Dist => match crate::units::parse_distance(t) {
|
||||
Some(v) => self.check_number(v, false),
|
||||
None if any => Ok(Value::str(t)),
|
||||
None => Err("Requires numeric distance or two points.".into()),
|
||||
},
|
||||
GetKind::Angle { orient } => match s.angle_settings().direction(t) {
|
||||
Some(a) => {
|
||||
let a = if orient { a } else { a - s.lisp_angbase() };
|
||||
self.check_number(a.rem_euclid(std::f64::consts::TAU), false)
|
||||
}
|
||||
None if any => Ok(Value::str(t)),
|
||||
None => Err("Requires valid numeric angle or second point.".into()),
|
||||
},
|
||||
GetKind::Point | GetKind::Corner | GetKind::EntSel => {
|
||||
if any {
|
||||
Ok(Value::str(t))
|
||||
} else {
|
||||
Err("Invalid point.".into())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn check_number(&self, v: f64, int: bool) -> Result<Value, Again> {
|
||||
let bits = self.initget.bits;
|
||||
if bits & 2 != 0 && v == 0.0 {
|
||||
return Err("Value must be nonzero.".into());
|
||||
}
|
||||
if bits & 4 != 0 && v < 0.0 {
|
||||
return Err("Value must be positive.".into());
|
||||
}
|
||||
Ok(if int { Value::Int(v as i32) } else { Value::Real(v) })
|
||||
}
|
||||
}
|
||||
|
||||
/// What an input to a `get…` prompt gives.
|
||||
pub enum Answer {
|
||||
Value(Value),
|
||||
/// Not an answer here: the message says why, and the prompt asks again.
|
||||
Again(String),
|
||||
/// `getdist`/`getangle` without a base point got its first point: ask for the second.
|
||||
Second([f64; 3]),
|
||||
}
|
||||
|
||||
enum Again {
|
||||
Msg(String),
|
||||
Second([f64; 3]),
|
||||
}
|
||||
|
||||
impl From<String> for Again {
|
||||
fn from(m: String) -> Again {
|
||||
Again::Msg(m)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for Again {
|
||||
fn from(m: &str) -> Again {
|
||||
Again::Msg(m.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn default_message(kind: GetKind) -> &'static str {
|
||||
match kind {
|
||||
GetKind::EntSel => "Select object: ",
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
|
||||
/// A keyword typed, matched as the command line matches them: the whole word or its capitals.
|
||||
fn keyword(keywords: &[String], t: &str) -> Option<String> {
|
||||
if keywords.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut p = Prompt::new("", Accept::TEXT);
|
||||
p.keywords = keywords.to_vec();
|
||||
p.match_keyword(t).or_else(|| t.strip_prefix('_').and_then(|r| p.match_keyword(r)))
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// ANGBASE (radians).
|
||||
fn lisp_angbase(&self) -> f64 {
|
||||
crate::sysvars::get(self, "ANGBASE").and_then(|v| v.as_f64()).unwrap_or(0.0)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- the builtins ----------------
|
||||
|
||||
/// `(initget [bits] [keywords])`.
|
||||
pub fn initget(ctx: &mut Ctx<'_>, args: Vec<Value>) -> Result<Flow, LispError> {
|
||||
let mut ig = Initget::default();
|
||||
for a in &args {
|
||||
match a {
|
||||
Value::Int(b) => ig.bits = *b,
|
||||
Value::Str(k) => ig.keywords = k.split_whitespace().map(|w| w.split(',').next().unwrap_or(w).to_string()).collect(),
|
||||
Value::Nil => {}
|
||||
other => return Err(LispError::bad_arg("stringp", other)),
|
||||
}
|
||||
}
|
||||
ctx.env.initget = Some(ig);
|
||||
Ok(Flow::Value(Value::Nil))
|
||||
}
|
||||
|
||||
/// Optional point then optional message, in that order (`getpoint`, `getdist`, `getangle`…).
|
||||
fn point_and_message(args: &[Value], name: &str) -> Result<(Option<[f64; 3]>, String), LispError> {
|
||||
let (mut base, mut msg) = (None, String::new());
|
||||
for a in args {
|
||||
match a {
|
||||
Value::Str(s) => msg = s.to_string(),
|
||||
Value::Cons(_) => base = Some(a.as_point().ok_or_else(|| LispError::bad_arg("2D/3D point", a))?),
|
||||
Value::Nil => {}
|
||||
other => return Err(LispError::bad_arg(if name == "getcorner" { "2D/3D point" } else { "stringp" }, other)),
|
||||
}
|
||||
}
|
||||
if args.len() > 2 {
|
||||
return Err(args_err(false));
|
||||
}
|
||||
Ok((base, msg))
|
||||
}
|
||||
|
||||
fn wait(ctx: &mut Ctx<'_>, kind: GetKind, message: String, base: Option<[f64; 3]>) -> Result<Flow, LispError> {
|
||||
let initget = ctx.env.initget.take().unwrap_or_default();
|
||||
// Text printed so far shows before the prompt.
|
||||
let printed = std::mem::take(&mut ctx.env.out);
|
||||
if !printed.is_empty() {
|
||||
for l in printed.split('\n').filter(|l| !l.is_empty()) {
|
||||
ctx.s.echo(l.to_string());
|
||||
ctx.env.printed_at = Some(ctx.s.log.len());
|
||||
}
|
||||
}
|
||||
Ok(Flow::Wait(Need::Get(GetRequest { kind, message, base, initget, first: None })))
|
||||
}
|
||||
|
||||
pub fn getpoint(ctx: &mut Ctx<'_>, args: Vec<Value>) -> Result<Flow, LispError> {
|
||||
let (base, msg) = point_and_message(&args, "getpoint")?;
|
||||
wait(ctx, GetKind::Point, msg, base)
|
||||
}
|
||||
|
||||
pub fn getcorner(ctx: &mut Ctx<'_>, args: Vec<Value>) -> Result<Flow, LispError> {
|
||||
let (base, msg) = point_and_message(&args, "getcorner")?;
|
||||
if base.is_none() {
|
||||
return Err(args_err(true));
|
||||
}
|
||||
wait(ctx, GetKind::Corner, msg, base)
|
||||
}
|
||||
|
||||
pub fn getdist(ctx: &mut Ctx<'_>, args: Vec<Value>) -> Result<Flow, LispError> {
|
||||
let (base, msg) = point_and_message(&args, "getdist")?;
|
||||
wait(ctx, GetKind::Dist, msg, base)
|
||||
}
|
||||
|
||||
pub fn getangle(ctx: &mut Ctx<'_>, args: Vec<Value>) -> Result<Flow, LispError> {
|
||||
let (base, msg) = point_and_message(&args, "getangle")?;
|
||||
wait(ctx, GetKind::Angle { orient: false }, msg, base)
|
||||
}
|
||||
|
||||
pub fn getorient(ctx: &mut Ctx<'_>, args: Vec<Value>) -> Result<Flow, LispError> {
|
||||
let (base, msg) = point_and_message(&args, "getorient")?;
|
||||
wait(ctx, GetKind::Angle { orient: true }, msg, base)
|
||||
}
|
||||
|
||||
fn message_only(args: &[Value]) -> Result<String, LispError> {
|
||||
match args {
|
||||
[] | [Value::Nil] => Ok(String::new()),
|
||||
[Value::Str(s)] => Ok(s.to_string()),
|
||||
[other] => Err(LispError::bad_arg("stringp", other)),
|
||||
_ => Err(args_err(false)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn getreal(ctx: &mut Ctx<'_>, args: Vec<Value>) -> Result<Flow, LispError> {
|
||||
let m = message_only(&args)?;
|
||||
wait(ctx, GetKind::Real, m, None)
|
||||
}
|
||||
|
||||
pub fn getint(ctx: &mut Ctx<'_>, args: Vec<Value>) -> Result<Flow, LispError> {
|
||||
let m = message_only(&args)?;
|
||||
wait(ctx, GetKind::Int, m, None)
|
||||
}
|
||||
|
||||
pub fn getkword(ctx: &mut Ctx<'_>, args: Vec<Value>) -> Result<Flow, LispError> {
|
||||
let m = message_only(&args)?;
|
||||
wait(ctx, GetKind::Kword, m, None)
|
||||
}
|
||||
|
||||
/// `(getstring [cr] [msg])`.
|
||||
pub fn getstring(ctx: &mut Ctx<'_>, args: Vec<Value>) -> Result<Flow, LispError> {
|
||||
let (spaces, msg) = match args.as_slice() {
|
||||
[] => (false, String::new()),
|
||||
[Value::Str(m)] => (false, m.to_string()),
|
||||
[cr] => (!cr.is_nil(), String::new()),
|
||||
[cr, Value::Str(m)] => (!cr.is_nil(), m.to_string()),
|
||||
[_, Value::Nil] => (!args[0].is_nil(), String::new()),
|
||||
[_, other] => return Err(LispError::bad_arg("stringp", other)),
|
||||
_ => return Err(args_err(false)),
|
||||
};
|
||||
wait(ctx, GetKind::String { spaces }, msg, None)
|
||||
}
|
||||
|
||||
/// `(entsel [msg])` and `(nentsel [msg])`.
|
||||
pub fn entsel(ctx: &mut Ctx<'_>, args: Vec<Value>) -> Result<Flow, LispError> {
|
||||
let m = message_only(&args)?;
|
||||
wait(ctx, GetKind::EntSel, m, None)
|
||||
}
|
||||
|
||||
/// A second point after the first of `getdist`/`getangle` without a base.
|
||||
pub fn with_first(req: &GetRequest, p: [f64; 3]) -> GetRequest {
|
||||
GetRequest { first: Some(p), ..req.clone() }
|
||||
}
|
||||
@@ -0,0 +1,613 @@
|
||||
//! AutoLISP: load `.lsp` files, evaluate expressions typed at the command line, and run
|
||||
//! commands defined with `(defun c:NAME …)`.
|
||||
//!
|
||||
//! The language runs on an explicit-stack machine ([`eval`]) that can stop for input
|
||||
//! (`getpoint`, `pause`) and carry on later, so the interface keeps drawing while a routine
|
||||
//! waits. Each drawing has its own symbols, as in AutoCAD. A run is one undo step.
|
||||
|
||||
pub mod builtins;
|
||||
mod drawing;
|
||||
pub mod eval;
|
||||
pub mod input;
|
||||
pub mod read;
|
||||
mod startup;
|
||||
pub mod value;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_doc::{Drawing, Handle};
|
||||
|
||||
use crate::{Input, Prompt, Session};
|
||||
use eval::{Ctx, Machine, Need, Outcome};
|
||||
use value::{Sym, Value};
|
||||
|
||||
/// A LISP error: the message `*error*` receives (`bad argument type: numberp: nil`…).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct LispError {
|
||||
pub msg: String,
|
||||
/// `exit`/`quit`, or a cancelled input: not caught by `vl-catch-all-apply`.
|
||||
pub quit: bool,
|
||||
}
|
||||
|
||||
impl LispError {
|
||||
pub fn new(msg: impl Into<String>) -> LispError {
|
||||
LispError { msg: msg.into(), quit: false }
|
||||
}
|
||||
pub fn bad_arg(pred: &str, v: &Value) -> LispError {
|
||||
LispError::new(format!("bad argument type: {pred}: {}", value::to_text(v, true)))
|
||||
}
|
||||
pub fn quit(msg: impl Into<String>) -> LispError {
|
||||
LispError { msg: msg.into(), quit: true }
|
||||
}
|
||||
}
|
||||
|
||||
/// One drawing's LISP environment.
|
||||
pub struct Env {
|
||||
pub globals: HashMap<Sym, Value>,
|
||||
/// Printed text not yet ended by a newline.
|
||||
pub out: String,
|
||||
/// `initget` settings for the next `get…` call.
|
||||
pub initget: Option<input::Initget>,
|
||||
pub next_pickset: u32,
|
||||
pub files: HashMap<u32, builtins::LispFile>,
|
||||
pub next_file: u32,
|
||||
/// Entities `entdel` deleted (it brings them back when called again), with their space.
|
||||
pub deleted: HashMap<Handle, (cadcraft_doc::Space, Arc<cadcraft_doc::Entity>)>,
|
||||
/// Every entity's handle in creation order, for the drawing it was taken from (`entnext`).
|
||||
pub order: Option<(usize, Arc<Vec<Handle>>)>,
|
||||
/// `tblnext`'s place in each table.
|
||||
pub tblnext: HashMap<String, usize>,
|
||||
/// The command history's length after LISP last printed a line: when something else wrote since
|
||||
/// (a prompt, a command's echo), a leading newline only ends that line.
|
||||
pub printed_at: Option<usize>,
|
||||
}
|
||||
|
||||
impl Default for Env {
|
||||
fn default() -> Self {
|
||||
let mut e = Env {
|
||||
globals: HashMap::new(),
|
||||
out: String::new(),
|
||||
initget: None,
|
||||
next_pickset: 1,
|
||||
files: HashMap::new(),
|
||||
next_file: 1,
|
||||
deleted: HashMap::new(),
|
||||
order: None,
|
||||
tblnext: HashMap::new(),
|
||||
printed_at: None,
|
||||
};
|
||||
builtins::install(&mut e);
|
||||
e
|
||||
}
|
||||
}
|
||||
|
||||
impl Env {
|
||||
/// Set a variable (nil unbinds it).
|
||||
pub fn set(&mut self, s: &Sym, v: Value) {
|
||||
if v.is_nil() {
|
||||
self.globals.remove(s);
|
||||
} else {
|
||||
self.globals.insert(s.clone(), v);
|
||||
}
|
||||
}
|
||||
pub fn get(&self, name: &str) -> Value {
|
||||
eval::lookup(self, &Sym::new(name))
|
||||
}
|
||||
/// Text printed by `princ` and friends.
|
||||
pub fn print(&mut self, text: &str) {
|
||||
self.out.push_str(text);
|
||||
}
|
||||
}
|
||||
|
||||
/// The session's LISP: each drawing's environment and the routine running, if any.
|
||||
#[derive(Default)]
|
||||
pub struct LispState {
|
||||
envs: HashMap<u64, Env>,
|
||||
run: Option<Box<Run>>,
|
||||
/// A routine runs, from its start to its end (also while its machine is out of `run`).
|
||||
active: bool,
|
||||
/// Files loaded by APPLOAD, by name, for `(load name)` where there is no file system.
|
||||
files: HashMap<String, String>,
|
||||
/// Where startup files are read from (see `startup.rs`).
|
||||
support_dir: Option<std::path::PathBuf>,
|
||||
/// Drawings that had their startup files.
|
||||
autoloaded: std::collections::HashSet<u64>,
|
||||
/// `acad.lsp` was loaded this session.
|
||||
acad_loaded: bool,
|
||||
/// Memory caps (lowered by tests).
|
||||
pub(crate) limits: Limits,
|
||||
}
|
||||
|
||||
/// What a run may use (see [`value`]'s memory notes).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct Limits {
|
||||
/// Live cons cells (all sessions).
|
||||
pub cells: usize,
|
||||
/// Bytes of strings, printed text and files read per run.
|
||||
pub string_bytes: u64,
|
||||
}
|
||||
|
||||
impl Default for Limits {
|
||||
fn default() -> Self {
|
||||
Limits { cells: value::MAX_CELLS, string_bytes: value::STRING_BUDGET }
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for LispState {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "LispState {{ drawings: {}, running: {} }}", self.envs.len(), self.run.is_some())
|
||||
}
|
||||
}
|
||||
|
||||
/// A routine on its way: the machine, what it waits for, and its undo step.
|
||||
struct Run {
|
||||
machine: Machine,
|
||||
/// The drawing it runs in.
|
||||
uid: u64,
|
||||
waiting: Option<Need>,
|
||||
/// A command-line expression: its value is printed at the end.
|
||||
print: bool,
|
||||
/// Undo label: the command (`c:` function) or "LISP".
|
||||
label: String,
|
||||
/// The drawing and selection before the run (one undo step for the whole run).
|
||||
before: Option<(Arc<Drawing>, Vec<Handle>)>,
|
||||
/// Inputs given up front (`lisp {code, inputs}`): what `get…` calls answer.
|
||||
inputs: std::collections::VecDeque<Value>,
|
||||
/// Run to the end without waiting (programmatic calls): `get…` without an input gets nil.
|
||||
blocking: bool,
|
||||
/// Steps taken, for the cap on blocking runs.
|
||||
steps: u64,
|
||||
/// The `*error*` handler is the one running.
|
||||
handler: bool,
|
||||
/// Bytes of strings and printed text the run has made ([`value::charge_bytes`]).
|
||||
string_bytes: u64,
|
||||
/// Cons cells alive when the run started: cells the drawing's variables already hold don't
|
||||
/// stop a run (it can still set them to nil) unless it adds more.
|
||||
cells_at_start: usize,
|
||||
}
|
||||
|
||||
/// Steps per slice before the interface gets a frame.
|
||||
const SLICE: u64 = 400_000;
|
||||
/// Steps a blocking run (a programmatic call) may take before it is stopped.
|
||||
const BLOCKING_CAP: u64 = 500_000_000;
|
||||
|
||||
impl Session {
|
||||
/// The LISP environment of the active drawing.
|
||||
fn lisp_env_take(&mut self) -> Env {
|
||||
let uid = self.state().map(|s| s.uid).unwrap_or(0);
|
||||
self.lisp.envs.remove(&uid).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn lisp_env_put(&mut self, uid: u64, env: Env) {
|
||||
self.lisp.envs.insert(uid, env);
|
||||
}
|
||||
|
||||
/// A routine is running (possibly waiting for input).
|
||||
pub fn lisp_running(&self) -> bool {
|
||||
self.lisp.active
|
||||
}
|
||||
|
||||
/// A prompt waits for input: a command's, or a LISP routine's `get…` function. Clicks and
|
||||
/// typing answer it; with none, the interface is idle (clicks select, Delete erases).
|
||||
pub fn prompting(&self) -> bool {
|
||||
self.running.is_some() || self.lisp_prompt().is_some()
|
||||
}
|
||||
|
||||
/// A routine is stopped, waiting for input or for a frame.
|
||||
pub(crate) fn lisp_waiting(&self) -> bool {
|
||||
self.lisp.run.is_some()
|
||||
}
|
||||
|
||||
/// The value of a LISP variable in the active drawing (nil when unbound).
|
||||
pub fn lisp_var(&self, name: &str) -> Value {
|
||||
let uid = self.state().map(|s| s.uid).unwrap_or(0);
|
||||
self.lisp.envs.get(&uid).map(|e| e.get(name)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Evaluate LISP source (a command-line expression or a file's contents). `print`: show the
|
||||
/// value on the command line. `blocking`: run to the end, answering `get…` calls from
|
||||
/// `inputs` (nil when they run out), as programmatic calls must; otherwise the routine may
|
||||
/// wait for the user.
|
||||
pub fn lisp_eval(
|
||||
&mut self,
|
||||
src: &str,
|
||||
print: bool,
|
||||
blocking: bool,
|
||||
inputs: Vec<Value>,
|
||||
) -> crate::Result<Option<std::result::Result<Value, String>>> {
|
||||
let forms = read::read_all(src).map_err(|e| crate::EngineError::Other(format!("; error: {}", e.msg)))?;
|
||||
self.lisp_start(Machine::forms(forms), print, blocking, inputs, "LISP".into())
|
||||
}
|
||||
|
||||
/// Call a LISP function (`c:` commands typed at the command line).
|
||||
fn lisp_call(&mut self, f: Value, label: String) -> crate::Result<Option<std::result::Result<Value, String>>> {
|
||||
self.lisp_start(Machine::calling(f, Vec::new()), false, false, Vec::new(), label)
|
||||
}
|
||||
|
||||
fn lisp_start(
|
||||
&mut self,
|
||||
machine: Machine,
|
||||
print: bool,
|
||||
blocking: bool,
|
||||
inputs: Vec<Value>,
|
||||
label: String,
|
||||
) -> crate::Result<Option<std::result::Result<Value, String>>> {
|
||||
if self.lisp.active {
|
||||
return Err(crate::EngineError::Other("a LISP routine is already running".into()));
|
||||
}
|
||||
self.lisp.active = true;
|
||||
let st = self.state()?;
|
||||
let before = Some((st.doc.clone(), st.selection.clone()));
|
||||
let uid = st.uid;
|
||||
self.lisp.run = Some(Box::new(Run {
|
||||
machine,
|
||||
uid,
|
||||
waiting: None,
|
||||
print,
|
||||
label,
|
||||
before,
|
||||
inputs: inputs.into(),
|
||||
blocking,
|
||||
steps: 0,
|
||||
handler: false,
|
||||
string_bytes: 0,
|
||||
cells_at_start: value::live_cells(),
|
||||
}));
|
||||
self.lisp_drive();
|
||||
if blocking {
|
||||
// A blocking run that waits for a command's input (`pause`) can't get it: cancel.
|
||||
if self.lisp.run.is_some() {
|
||||
self.lisp_abort("Function cancelled");
|
||||
}
|
||||
}
|
||||
Ok(self.lisp_last.take())
|
||||
}
|
||||
|
||||
/// Call once per frame: loads the startup files into a drawing seen for the first time, and
|
||||
/// carries on a routine that gave the interface a frame.
|
||||
pub fn lisp_tick(&mut self) {
|
||||
// Called every frame, outside any command: a panic in it ends the routine with an error
|
||||
// instead of taking down the app.
|
||||
if let Err(p) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.lisp_tick_unguarded())) {
|
||||
self.lisp_internal_error(&crate::guard::message(&*p));
|
||||
}
|
||||
}
|
||||
|
||||
fn lisp_tick_unguarded(&mut self) {
|
||||
// A drawing seen for the first time gets its startup files.
|
||||
self.lisp_autoload();
|
||||
if self.lisp.run.as_ref().is_some_and(|r| matches!(r.waiting, Some(Need::Yield))) {
|
||||
if let Some(r) = self.lisp.run.as_mut() {
|
||||
r.waiting = None;
|
||||
}
|
||||
self.lisp_drive();
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the routine needs ticks ([`Session::lisp_tick`]) to go on.
|
||||
pub fn lisp_busy(&self) -> bool {
|
||||
self.lisp.run.as_ref().is_some_and(|r| matches!(r.waiting, Some(Need::Yield)))
|
||||
}
|
||||
|
||||
/// Run the machine until it finishes or waits.
|
||||
fn lisp_drive(&mut self) {
|
||||
loop {
|
||||
let Some(mut run) = self.lisp.run.take() else { return };
|
||||
if run.waiting.is_some() {
|
||||
self.lisp.run = Some(run);
|
||||
return;
|
||||
}
|
||||
// The routine belongs to its drawing; another one became active: stop it.
|
||||
if self.state().map(|s| s.uid).ok() != Some(run.uid) {
|
||||
self.lisp.run = Some(run);
|
||||
self.lisp_abort("Function cancelled");
|
||||
return;
|
||||
}
|
||||
let mut env = self.lisp_env_take();
|
||||
value::set_string_budget(run.string_bytes, self.lisp.limits.string_bytes);
|
||||
value::set_cell_limit(self.lisp.limits.cells.max(run.cells_at_start));
|
||||
let outcome = {
|
||||
let machine = &mut run.machine;
|
||||
let mut ctx = Ctx { s: self, env: &mut env, depth: 0 };
|
||||
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| machine.run(&mut ctx, SLICE)))
|
||||
};
|
||||
run.string_bytes = value::string_bytes_used();
|
||||
let outcome = match outcome {
|
||||
Ok(o) => o,
|
||||
Err(p) => {
|
||||
// A panic inside a built-in: put the drawing back as the routine found it.
|
||||
if let Some((doc, sel)) = &run.before
|
||||
&& let Some(st) = self.docs.iter_mut().find(|d| d.uid == run.uid)
|
||||
{
|
||||
st.doc = doc.clone();
|
||||
st.selection = sel.clone();
|
||||
}
|
||||
run.handler = true;
|
||||
Outcome::Error(LispError::quit(format!("internal error: {}", crate::guard::message(&*p))))
|
||||
}
|
||||
};
|
||||
let uid = run.uid;
|
||||
self.lisp_flush(&mut env, false);
|
||||
self.lisp_env_put(uid, env);
|
||||
match outcome {
|
||||
Outcome::Done(v) => {
|
||||
self.lisp.run = Some(run);
|
||||
self.lisp_end(Ok(v));
|
||||
return;
|
||||
}
|
||||
Outcome::Error(e) => {
|
||||
self.lisp.run = Some(run);
|
||||
self.lisp_error(e);
|
||||
return;
|
||||
}
|
||||
Outcome::Wait(Need::Yield) => {
|
||||
run.steps = run.steps.saturating_add(SLICE);
|
||||
if run.blocking {
|
||||
if run.steps > BLOCKING_CAP {
|
||||
self.lisp.run = Some(run);
|
||||
self.lisp_error(LispError::quit(format!("routine stopped after {BLOCKING_CAP} steps")));
|
||||
return;
|
||||
}
|
||||
self.lisp.run = Some(run);
|
||||
continue;
|
||||
}
|
||||
run.waiting = Some(Need::Yield);
|
||||
self.lisp.run = Some(run);
|
||||
return;
|
||||
}
|
||||
Outcome::Wait(Need::Pause) => {
|
||||
// The command `command` left running takes the next input; with none
|
||||
// running there is nothing to wait for.
|
||||
if self.running.is_none() || run.blocking {
|
||||
run.machine.resume(Value::Nil);
|
||||
if run.blocking && self.running.is_some() {
|
||||
self.cancel();
|
||||
}
|
||||
self.lisp.run = Some(run);
|
||||
continue;
|
||||
}
|
||||
run.waiting = Some(Need::Pause);
|
||||
self.lisp.run = Some(run);
|
||||
return;
|
||||
}
|
||||
Outcome::Wait(Need::Get(req)) => {
|
||||
if let Some(v) = run.inputs.pop_front() {
|
||||
run.machine.resume(v);
|
||||
self.lisp.run = Some(run);
|
||||
continue;
|
||||
}
|
||||
if run.blocking {
|
||||
run.machine.resume(Value::Nil);
|
||||
self.lisp.run = Some(run);
|
||||
continue;
|
||||
}
|
||||
run.waiting = Some(Need::Get(req));
|
||||
self.lisp.run = Some(run);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Move printed text to the command history: complete lines, and with `all` the rest too.
|
||||
fn lisp_flush(&mut self, env: &mut Env, all: bool) {
|
||||
while let Some(i) = env.out.find('\n') {
|
||||
let line: String = env.out.drain(..=i).collect();
|
||||
let line = line.trim_end_matches(['\n', '\r']).to_string();
|
||||
// `(princ "\nText")` after a prompt or a command's echo ends that line instead of
|
||||
// leaving an empty one; after LISP's own line it does print an empty line.
|
||||
if line.is_empty() && env.printed_at != Some(self.log.len()) {
|
||||
env.printed_at = Some(self.log.len());
|
||||
continue;
|
||||
}
|
||||
self.echo(line);
|
||||
env.printed_at = Some(self.log.len());
|
||||
}
|
||||
if all && !env.out.is_empty() {
|
||||
let line = std::mem::take(&mut env.out);
|
||||
self.echo(line);
|
||||
env.printed_at = Some(self.log.len());
|
||||
}
|
||||
}
|
||||
|
||||
/// The routine finished: print its value (command-line expressions), record its undo step.
|
||||
fn lisp_end(&mut self, result: std::result::Result<Value, String>) {
|
||||
let Some(run) = self.lisp.run.take() else { return };
|
||||
self.lisp.active = false;
|
||||
let mut env = self.lisp.envs.remove(&run.uid).unwrap_or_default();
|
||||
self.lisp_flush(&mut env, true);
|
||||
self.lisp.envs.insert(run.uid, env);
|
||||
if let (Ok(v), true) = (&result, run.print) {
|
||||
let text = value::to_text(v, true);
|
||||
if !builtins::is_null_symbol(v) {
|
||||
self.echo(text);
|
||||
}
|
||||
}
|
||||
if let Some((doc, sel)) = run.before
|
||||
&& let Some(st) = self.docs.iter_mut().find(|d| d.uid == run.uid)
|
||||
&& !Arc::ptr_eq(&doc, &st.doc)
|
||||
{
|
||||
st.push_undo(crate::Snapshot { label: run.label.clone(), doc, selection: sel });
|
||||
st.revision += 1;
|
||||
}
|
||||
self.lisp_last = Some(result);
|
||||
}
|
||||
|
||||
/// A LISP error: run `*error*` when the routine defined one, else print it.
|
||||
fn lisp_error(&mut self, e: LispError) {
|
||||
let Some(mut run) = self.lisp.run.take() else { return };
|
||||
// The command a failed routine left running is cancelled.
|
||||
if self.running.is_some() {
|
||||
self.cancel();
|
||||
}
|
||||
let handler = self.lisp.envs.get(&run.uid).map(|env| env.get("*ERROR*")).unwrap_or_default();
|
||||
if !run.handler && matches!(handler, Value::Func(_) | Value::Subr(_)) {
|
||||
run.handler = true;
|
||||
run.waiting = None;
|
||||
run.print = false;
|
||||
run.machine = Machine::calling(handler, vec![Value::str(&e.msg)]);
|
||||
self.lisp.run = Some(run);
|
||||
self.lisp_drive();
|
||||
// The routine failed whatever its handler returned.
|
||||
if let Some(Ok(_)) = &self.lisp_last {
|
||||
self.lisp_last = Some(Err(e.msg));
|
||||
}
|
||||
return;
|
||||
}
|
||||
self.lisp.run = Some(run);
|
||||
let mut env = self.lisp_env_take();
|
||||
self.lisp_flush(&mut env, true);
|
||||
let uid = self.state().map(|s| s.uid).unwrap_or(0);
|
||||
self.lisp_env_put(uid, env);
|
||||
self.echo(format!("; error: {}", e.msg));
|
||||
self.lisp_end(Err(e.msg));
|
||||
}
|
||||
|
||||
/// A panic escaped the LISP machinery outside a routine's steps: end the routine, if any,
|
||||
/// with an error, so the session stays usable.
|
||||
fn lisp_internal_error(&mut self, msg: &str) {
|
||||
let msg = format!("internal error: {msg}");
|
||||
if self.lisp.run.is_some() {
|
||||
if let Some(r) = self.lisp.run.as_mut() {
|
||||
// Don't run the routine's own `*error*` after an internal failure.
|
||||
r.handler = true;
|
||||
}
|
||||
self.lisp_error(LispError::quit(msg));
|
||||
} else if self.lisp.active {
|
||||
self.lisp.active = false;
|
||||
self.echo(format!("; error: {msg}"));
|
||||
self.lisp_last = Some(Err(msg));
|
||||
} else {
|
||||
self.echo(format!("; error: {msg}"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop the routine (Esc while it waits, its drawing closed) with `msg` as its error.
|
||||
pub fn lisp_abort(&mut self, msg: &str) {
|
||||
if self.lisp.run.is_some() {
|
||||
self.lisp_error(LispError::quit(msg));
|
||||
}
|
||||
}
|
||||
|
||||
/// A routine waits for the user's answer to a `get…` function: its prompt.
|
||||
pub(crate) fn lisp_prompt(&self) -> Option<Prompt> {
|
||||
match self.lisp.run.as_ref()?.waiting.as_ref()? {
|
||||
Need::Get(req) => Some(req.prompt()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The rubber band of a waiting `get…` function: a rectangle from `getcorner`'s corner, a
|
||||
/// line from the base point of the others. `None` when no routine waits for input.
|
||||
pub(crate) fn lisp_preview(&self, cursor: cadcraft_geom::Vec2) -> Option<Vec<cadcraft_doc::EntityKind>> {
|
||||
let Need::Get(req) = self.lisp.run.as_ref()?.waiting.as_ref()? else { return None };
|
||||
let line =
|
||||
|a: cadcraft_geom::Vec2, b: cadcraft_geom::Vec2| cadcraft_doc::EntityKind::Line(cadcraft_doc::Line { a: a.to3(0.0), b: b.to3(0.0) });
|
||||
let Some(b) = req.first.or(req.base).map(|p| cadcraft_geom::Vec2::new(p[0], p[1])) else { return Some(Vec::new()) };
|
||||
Some(match req.kind {
|
||||
input::GetKind::Corner => {
|
||||
let (c1, c2) = (cadcraft_geom::Vec2::new(cursor.x, b.y), cadcraft_geom::Vec2::new(b.x, cursor.y));
|
||||
vec![line(b, c1), line(c1, cursor), line(cursor, c2), line(c2, b)]
|
||||
}
|
||||
input::GetKind::Point | input::GetKind::Dist | input::GetKind::Angle { .. } => vec![line(b, cursor)],
|
||||
_ => Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Give an input to a waiting routine. `true` when the routine took it.
|
||||
pub(crate) fn lisp_input(&mut self, input: Input) -> bool {
|
||||
let Some(req) = self.lisp.run.as_ref().and_then(|r| match &r.waiting {
|
||||
Some(Need::Get(req)) => Some(req.clone()),
|
||||
_ => None,
|
||||
}) else {
|
||||
return false;
|
||||
};
|
||||
if input == Input::Cancel {
|
||||
self.echo("*Cancel*");
|
||||
self.lisp_abort("Function cancelled");
|
||||
return true;
|
||||
}
|
||||
match req.answer(self, input) {
|
||||
input::Answer::Value(v) => {
|
||||
if let Some(r) = self.lisp.run.as_mut() {
|
||||
r.waiting = None;
|
||||
r.machine.resume(v);
|
||||
}
|
||||
self.lisp_drive();
|
||||
}
|
||||
// Not an answer this prompt takes: say why and ask again.
|
||||
input::Answer::Again(msg) => self.echo(msg),
|
||||
// The first of two points: the prompt asks for the second.
|
||||
input::Answer::Second(p) => {
|
||||
if let Some(r) = self.lisp.run.as_mut() {
|
||||
r.waiting = Some(Need::Get(input::with_first(&req, p)));
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// After the running command took an input: a routine paused for it carries on.
|
||||
pub(crate) fn lisp_after_command_input(&mut self) {
|
||||
if self.lisp.run.as_ref().is_some_and(|r| matches!(r.waiting, Some(Need::Pause))) {
|
||||
if let Some(r) = self.lisp.run.as_mut() {
|
||||
r.waiting = None;
|
||||
r.machine.resume(Value::Nil);
|
||||
}
|
||||
self.lisp_drive();
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether commands' undo steps go into a running routine's (one step per routine).
|
||||
pub(crate) fn lisp_groups_undo(&self) -> bool {
|
||||
self.lisp.active
|
||||
}
|
||||
|
||||
/// A `c:` function for a command name typed at the command line.
|
||||
pub(crate) fn lisp_command(&self, name: &str) -> Option<Value> {
|
||||
if self.lisp.active {
|
||||
return None;
|
||||
}
|
||||
let uid = self.state().ok()?.uid;
|
||||
let f = self.lisp.envs.get(&uid)?.get(&format!("C:{name}"));
|
||||
matches!(f, Value::Func(_) | Value::Subr(_)).then_some(f)
|
||||
}
|
||||
|
||||
/// Run a `c:` command.
|
||||
pub(crate) fn lisp_run_command(&mut self, name: &str, f: Value) -> crate::Result<()> {
|
||||
self.lisp_call(f, name.to_ascii_uppercase()).map(|_| ())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) use drawing::from_json as drawing_from_json;
|
||||
pub(crate) use drawing::load_source;
|
||||
|
||||
impl Session {
|
||||
/// A file APPLOAD loaded, by its name (with or without `.lsp`, any case).
|
||||
pub(crate) fn lisp_file(&self, name: &str) -> Option<String> {
|
||||
let key = file_key(name);
|
||||
self.lisp.files.get(&key).cloned()
|
||||
}
|
||||
|
||||
/// Load a LISP file (APPLOAD, a dropped `.lsp` file): its forms run in the active drawing,
|
||||
/// and `(load name)` finds it later even where there is no file system. `blocking`: run to
|
||||
/// the end (programmatic calls).
|
||||
pub fn lisp_load(&mut self, name: &str, text: &str, blocking: bool) -> crate::Result<Option<std::result::Result<Value, String>>> {
|
||||
self.lisp.files.insert(file_key(name), text.to_string());
|
||||
let r = self.lisp_eval(text, false, blocking, Vec::new())?;
|
||||
if !matches!(r, Some(Err(_))) {
|
||||
let shown = std::path::Path::new(name).file_name().and_then(|n| n.to_str()).unwrap_or(name).to_string();
|
||||
self.echo(format!("{shown} successfully loaded."));
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
}
|
||||
|
||||
fn file_key(name: &str) -> String {
|
||||
let base = std::path::Path::new(&name.replace('\\', "/")).file_name().and_then(|n| n.to_str()).unwrap_or(name).to_ascii_lowercase();
|
||||
base.strip_suffix(".lsp").map(str::to_string).unwrap_or(base)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,249 @@
|
||||
//! The reader: AutoLISP source text to values. Lists, dotted pairs, `'x` quoting, strings with
|
||||
//! backslash escapes, integers (a literal beyond 32 bits reads as a real), reals, symbols
|
||||
//! (upper-cased), `;` line comments and `;| … |;` block comments.
|
||||
//!
|
||||
//! It keeps its own stack instead of recursing, so deeply nested input can't overflow it.
|
||||
|
||||
use super::LispError;
|
||||
use super::value::{MAX_DEPTH, Value};
|
||||
|
||||
enum Open {
|
||||
List { items: Vec<Value>, tail: Option<Value>, dot: bool },
|
||||
Quote,
|
||||
}
|
||||
|
||||
/// Every top-level form in `src`.
|
||||
pub fn read_all(src: &str) -> Result<Vec<Value>, LispError> {
|
||||
let mut r = Reader { chars: src.chars().collect(), pos: 0 };
|
||||
let mut out = Vec::new();
|
||||
while let Some(v) = r.datum()? {
|
||||
out.push(v);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The first form in `src` (`read`), nil when there is none.
|
||||
pub fn read_one(src: &str) -> Result<Value, LispError> {
|
||||
let mut r = Reader { chars: src.chars().collect(), pos: 0 };
|
||||
Ok(r.datum()?.unwrap_or(Value::Nil))
|
||||
}
|
||||
|
||||
struct Reader {
|
||||
chars: Vec<char>,
|
||||
pos: usize,
|
||||
}
|
||||
|
||||
impl Reader {
|
||||
fn peek(&self) -> Option<char> {
|
||||
self.chars.get(self.pos).copied()
|
||||
}
|
||||
|
||||
fn skip_space(&mut self) {
|
||||
while let Some(c) = self.peek() {
|
||||
if c.is_whitespace() {
|
||||
self.pos += 1;
|
||||
} else if c == ';' {
|
||||
if self.chars.get(self.pos + 1) == Some(&'|') {
|
||||
// Block comment up to |;
|
||||
self.pos += 2;
|
||||
while self.pos < self.chars.len() && !(self.chars[self.pos] == '|' && self.chars.get(self.pos + 1) == Some(&';')) {
|
||||
self.pos += 1;
|
||||
}
|
||||
self.pos = (self.pos + 2).min(self.chars.len());
|
||||
} else {
|
||||
while let Some(c) = self.peek() {
|
||||
self.pos += 1;
|
||||
if c == '\n' {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The next complete datum; `None` at the end of the input.
|
||||
fn datum(&mut self) -> Result<Option<Value>, LispError> {
|
||||
let mut stack: Vec<Open> = Vec::new();
|
||||
loop {
|
||||
self.skip_space();
|
||||
let Some(c) = self.peek() else {
|
||||
if stack.iter().any(|o| matches!(o, Open::List { .. })) {
|
||||
return Err(LispError::new("malformed list on input"));
|
||||
}
|
||||
if !stack.is_empty() {
|
||||
return Err(LispError::new("malformed quote on input"));
|
||||
}
|
||||
return Ok(None);
|
||||
};
|
||||
let done = match c {
|
||||
'(' => {
|
||||
self.pos += 1;
|
||||
if stack.len() >= MAX_DEPTH {
|
||||
return Err(LispError::new("too deeply nested input"));
|
||||
}
|
||||
stack.push(Open::List { items: Vec::new(), tail: None, dot: false });
|
||||
continue;
|
||||
}
|
||||
')' => {
|
||||
self.pos += 1;
|
||||
match stack.pop() {
|
||||
Some(Open::List { items, tail, dot }) => {
|
||||
if dot && tail.is_none() {
|
||||
return Err(LispError::new("misplaced dot on input"));
|
||||
}
|
||||
Value::list_with_tail(items, tail.unwrap_or(Value::Nil))
|
||||
}
|
||||
Some(Open::Quote) => return Err(LispError::new("malformed quote on input")),
|
||||
// A stray closing parenthesis at top level is ignored, as AutoCAD does.
|
||||
None => continue,
|
||||
}
|
||||
}
|
||||
'\'' => {
|
||||
self.pos += 1;
|
||||
if stack.len() >= MAX_DEPTH {
|
||||
return Err(LispError::new("too deeply nested input"));
|
||||
}
|
||||
stack.push(Open::Quote);
|
||||
continue;
|
||||
}
|
||||
'"' => {
|
||||
self.pos += 1;
|
||||
self.string()?
|
||||
}
|
||||
_ => {
|
||||
let tok = self.token();
|
||||
if tok == "." && matches!(stack.last(), Some(Open::List { items, dot: false, .. }) if !items.is_empty()) {
|
||||
if let Some(Open::List { dot, .. }) = stack.last_mut() {
|
||||
*dot = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
atom(&tok)
|
||||
}
|
||||
};
|
||||
// A finished datum: wrap pending quotes, then hand it to the enclosing list.
|
||||
let mut v = done;
|
||||
loop {
|
||||
match stack.last_mut() {
|
||||
Some(Open::Quote) => {
|
||||
stack.pop();
|
||||
v = Value::list(vec![Value::sym("QUOTE"), v]);
|
||||
}
|
||||
Some(Open::List { items, tail, dot }) => {
|
||||
if *dot {
|
||||
if tail.is_some() {
|
||||
return Err(LispError::new("misplaced dot on input"));
|
||||
}
|
||||
*tail = Some(v);
|
||||
} else {
|
||||
items.push(v);
|
||||
}
|
||||
break;
|
||||
}
|
||||
None => return Ok(Some(v)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn string(&mut self) -> Result<Value, LispError> {
|
||||
let mut s = String::new();
|
||||
loop {
|
||||
let Some(c) = self.peek() else { return Err(LispError::new("malformed string on input")) };
|
||||
self.pos += 1;
|
||||
match c {
|
||||
'"' => return Ok(Value::str(&s)),
|
||||
'\\' => {
|
||||
let Some(e) = self.peek() else { return Err(LispError::new("malformed string on input")) };
|
||||
self.pos += 1;
|
||||
match e {
|
||||
'n' => s.push('\n'),
|
||||
'r' => s.push('\r'),
|
||||
't' => s.push('\t'),
|
||||
'e' => s.push('\u{1b}'),
|
||||
'0'..='7' => {
|
||||
// \nnn: an octal character code.
|
||||
let mut code = e.to_digit(8).unwrap_or(0);
|
||||
for _ in 0..2 {
|
||||
match self.peek().and_then(|d| d.to_digit(8)) {
|
||||
Some(d) => {
|
||||
code = code * 8 + d;
|
||||
self.pos += 1;
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
s.extend(char::from_u32(code));
|
||||
}
|
||||
other => s.push(other),
|
||||
}
|
||||
}
|
||||
c => s.push(c),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn token(&mut self) -> String {
|
||||
let start = self.pos;
|
||||
while let Some(c) = self.peek() {
|
||||
if c.is_whitespace() || matches!(c, '(' | ')' | '\'' | '"' | ';') {
|
||||
break;
|
||||
}
|
||||
self.pos += 1;
|
||||
}
|
||||
self.chars[start..self.pos].iter().collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// A number or a symbol.
|
||||
fn atom(tok: &str) -> Value {
|
||||
if let Ok(i) = tok.parse::<i64>() {
|
||||
return match i32::try_from(i) {
|
||||
Ok(i) => Value::Int(i),
|
||||
Err(_) => Value::Real(i as f64),
|
||||
};
|
||||
}
|
||||
let numeric = tok.chars().next().is_some_and(|c| c.is_ascii_digit() || matches!(c, '.' | '-' | '+'))
|
||||
&& tok.chars().any(|c| c.is_ascii_digit())
|
||||
&& tok.chars().all(|c| c.is_ascii_digit() || matches!(c, '.' | '-' | '+' | 'e' | 'E'));
|
||||
if numeric && let Ok(r) = tok.parse::<f64>() {
|
||||
return Value::Real(r);
|
||||
}
|
||||
if tok.eq_ignore_ascii_case("nil") {
|
||||
return Value::Nil;
|
||||
}
|
||||
Value::sym(tok)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::value::to_text;
|
||||
use super::*;
|
||||
|
||||
fn show(src: &str) -> Vec<String> {
|
||||
read_all(src).unwrap().iter().map(|v| to_text(v, true)).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_forms_atoms_and_comments() {
|
||||
assert_eq!(show("(defun c:hi () (princ \"hi\\n\")) ; c\n;| block\n |; 'x"), ["(DEFUN C:HI nil (PRINC \"hi\\n\"))", "'X"]);
|
||||
assert_eq!(show("(8 . \"0\") (1 2 . 3) (a .b)"), ["(8 . \"0\")", "(1 2 . 3)", "(A .B)"]);
|
||||
assert_eq!(show("1 -2 3.5 .5 1e3 4294967296 nil T"), ["1", "-2", "3.5", "0.5", "1000.0", "4.29497e+009", "nil", "T"]);
|
||||
assert_eq!(show("\"a\\\\b\\\"c\\101\""), ["\"a\\\\b\\\"cA\""]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_and_hostile_input_is_an_error_not_a_crash() {
|
||||
assert!(read_all("(1 2").is_err());
|
||||
assert!(read_all("\"open").is_err());
|
||||
assert!(read_all("(1 . )").is_err());
|
||||
assert!(read_all("'").is_err());
|
||||
assert!(read_all(&"(".repeat(100_000)).is_err());
|
||||
assert!(read_all(&"'".repeat(100_000)).is_err());
|
||||
// A stray ) at top level is skipped.
|
||||
assert_eq!(show(") 1"), ["1"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
//! LISP loaded at startup, from CADCraft's support folder (the app sets it: `<settings>/support`):
|
||||
//!
|
||||
//! - `acad.lsp` once per session, in the first drawing (every drawing with ACADLSPASDOC on);
|
||||
//! - `acaddoc.lsp` in every drawing opened or created;
|
||||
//! - then `S::STARTUP`, when they define it.
|
||||
//!
|
||||
//! Never from a drawing's own folder: an `acaddoc.lsp` that came along with a downloaded drawing
|
||||
//! would run on opening it, the way LISP viruses spread.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::value::Value;
|
||||
use crate::Session;
|
||||
|
||||
/// The startup files, in load order: (name, once per session unless ACADLSPASDOC).
|
||||
const FILES: [(&str, bool); 2] = [("acad.lsp", true), ("acaddoc.lsp", false)];
|
||||
|
||||
impl Session {
|
||||
/// The folder startup LISP files are read from (`None`: no startup files, the default; the
|
||||
/// app sets it).
|
||||
pub fn set_lisp_support_dir(&mut self, dir: Option<PathBuf>) {
|
||||
self.lisp.support_dir = dir;
|
||||
}
|
||||
|
||||
/// The folder startup LISP files are read from.
|
||||
pub fn lisp_support_dir(&self) -> Option<&std::path::Path> {
|
||||
self.lisp.support_dir.as_deref()
|
||||
}
|
||||
|
||||
/// Load the startup files into the active drawing, once (from [`Session::lisp_tick`]).
|
||||
pub(crate) fn lisp_autoload(&mut self) {
|
||||
let Some(dir) = self.lisp.support_dir.clone() else { return };
|
||||
if self.lisp.active {
|
||||
return;
|
||||
}
|
||||
let Ok(uid) = self.state().map(|s| s.uid) else { return };
|
||||
if !self.lisp.autoloaded.insert(uid) {
|
||||
return;
|
||||
}
|
||||
let per_drawing = self.settings.acadlspasdoc;
|
||||
for (name, once) in FILES {
|
||||
if once && self.lisp.acad_loaded && !per_drawing {
|
||||
continue;
|
||||
}
|
||||
let Some(text) = super::builtins::read_text_file(&dir.join(name).to_string_lossy()) else { continue };
|
||||
if once {
|
||||
self.lisp.acad_loaded = true;
|
||||
}
|
||||
self.lisp.files.insert(name.trim_end_matches(".lsp").to_string(), text.clone());
|
||||
// Startup files don't ask for input: they run to the end. Errors are shown.
|
||||
if let Err(e) = self.lisp_eval(&text, false, true, Vec::new()) {
|
||||
self.echo(format!("{name}: {e}"));
|
||||
}
|
||||
}
|
||||
if matches!(self.lisp_var("S::STARTUP"), Value::Func(_) | Value::Subr(_)) {
|
||||
let _ = self.lisp_eval("(S::STARTUP)", false, true, Vec::new());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,553 @@
|
||||
#![allow(clippy::unwrap_used)]
|
||||
|
||||
use super::value::{Value, to_text};
|
||||
use crate::Session;
|
||||
use crate::cmd::file::{EntityCodec, set_entity_codec};
|
||||
|
||||
fn session() -> Session {
|
||||
set_entity_codec(EntityCodec { groups: cadcraft_io::entity_groups, entity: cadcraft_io::entity_from_groups });
|
||||
Session::new()
|
||||
}
|
||||
|
||||
/// Evaluate `code` to the end; its value as `prin1` shows it, or the error message.
|
||||
fn eval(s: &mut Session, code: &str) -> Result<String, String> {
|
||||
eval_with(s, code, Vec::new())
|
||||
}
|
||||
|
||||
fn eval_with(s: &mut Session, code: &str, inputs: Vec<Value>) -> Result<String, String> {
|
||||
match s.lisp_eval(code, false, true, inputs) {
|
||||
Ok(Some(Ok(v))) => Ok(to_text(&v, true)),
|
||||
Ok(Some(Err(e))) => Err(e),
|
||||
Ok(None) => Err("did not finish".into()),
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn ok(s: &mut Session, code: &str) -> String {
|
||||
match eval(s, code) {
|
||||
Ok(v) => v,
|
||||
Err(e) => panic!("{code}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arithmetic_follows_autolisp() {
|
||||
let mut s = session();
|
||||
assert_eq!(ok(&mut s, "(+ 1 2 3)"), "6");
|
||||
assert_eq!(ok(&mut s, "(+ 1 2.0)"), "3.0");
|
||||
assert_eq!(ok(&mut s, "(/ 7 2)"), "3");
|
||||
assert_eq!(ok(&mut s, "(/ 7 2.0)"), "3.5");
|
||||
assert_eq!(ok(&mut s, "(- 5)"), "-5");
|
||||
assert_eq!(ok(&mut s, "(rem 7 3)"), "1");
|
||||
assert_eq!(ok(&mut s, "(1+ 41)"), "42");
|
||||
assert_eq!(ok(&mut s, "(fix 3.7)"), "3");
|
||||
assert_eq!(ok(&mut s, "(float 3)"), "3.0");
|
||||
assert_eq!(ok(&mut s, "(expt 2 10)"), "1024");
|
||||
assert_eq!(ok(&mut s, "(sqrt 16)"), "4.0");
|
||||
assert_eq!(ok(&mut s, "(max 1 5 3)"), "5");
|
||||
assert_eq!(ok(&mut s, "(min 1 5.0 3)"), "1.0");
|
||||
assert_eq!(ok(&mut s, "(abs -4)"), "4");
|
||||
assert_eq!(ok(&mut s, "(logand 12 10)"), "8");
|
||||
assert_eq!(ok(&mut s, "(lsh 1 4)"), "16");
|
||||
assert_eq!(ok(&mut s, "(+ 2147483647 1)"), "-2147483648", "32-bit integers wrap");
|
||||
assert_eq!(ok(&mut s, "(< 1 2 3)"), "T");
|
||||
assert_eq!(ok(&mut s, "(= 1 1.0)"), "T");
|
||||
assert_eq!(ok(&mut s, "(= \"a\" \"a\")"), "T");
|
||||
assert_eq!(eval(&mut s, "(/ 1 0)"), Err("divide by zero".into()));
|
||||
assert_eq!(eval(&mut s, "(+ 1 nil)"), Err("bad argument type: numberp: nil".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lists_and_strings() {
|
||||
let mut s = session();
|
||||
assert_eq!(ok(&mut s, "(cons 1 '(2 3))"), "(1 2 3)");
|
||||
assert_eq!(ok(&mut s, "(cons 8 \"0\")"), "(8 . \"0\")");
|
||||
assert_eq!(ok(&mut s, "(cadr '(1 2 3))"), "2");
|
||||
assert_eq!(ok(&mut s, "(cdr (assoc 40 '((10 1.0 2.0 0.0) (40 . 2.5))))"), "2.5");
|
||||
assert_eq!(ok(&mut s, "(subst '(40 . 5.0) '(40 . 2.5) '((8 . \"0\") (40 . 2.5)))"), "((8 . \"0\") (40 . 5.0))");
|
||||
assert_eq!(ok(&mut s, "(append '(1) '(2 3) nil '(4))"), "(1 2 3 4)");
|
||||
assert_eq!(ok(&mut s, "(reverse '(1 2 3))"), "(3 2 1)");
|
||||
assert_eq!(ok(&mut s, "(nth 1 '(a b c))"), "B");
|
||||
assert_eq!(ok(&mut s, "(member 2 '(1 2 3))"), "(2 3)");
|
||||
assert_eq!(ok(&mut s, "(length '(1 2 3))"), "3");
|
||||
assert_eq!(ok(&mut s, "(last '(1 2 3))"), "3");
|
||||
assert_eq!(ok(&mut s, "(mapcar '+ '(1 2 3) '(10 20 30))"), "(11 22 33)");
|
||||
assert_eq!(ok(&mut s, "(mapcar '(lambda (x) (* x x)) '(1 2 3))"), "(1 4 9)");
|
||||
assert_eq!(ok(&mut s, "(apply 'max '(3 9 2))"), "9");
|
||||
assert_eq!(ok(&mut s, "(vl-sort '(3 1 2) '<)"), "(1 2 3)");
|
||||
assert_eq!(ok(&mut s, "(vl-sort '(3 1 2) (function (lambda (a b) (> a b))))"), "(3 2 1)");
|
||||
assert_eq!(ok(&mut s, "(vl-remove-if 'minusp '(1 -2 3))"), "(1 3)");
|
||||
assert_eq!(ok(&mut s, "(strcat \"a\" \"b\" \"c\")"), "\"abc\"");
|
||||
assert_eq!(ok(&mut s, "(substr \"Hello\" 2 3)"), "\"ell\"");
|
||||
assert_eq!(ok(&mut s, "(strcase \"abc\")"), "\"ABC\"");
|
||||
assert_eq!(ok(&mut s, "(strlen \"abc\" \"de\")"), "5");
|
||||
assert_eq!(ok(&mut s, "(itoa 42)"), "\"42\"");
|
||||
assert_eq!(ok(&mut s, "(atoi \"12abc\")"), "12");
|
||||
assert_eq!(ok(&mut s, "(atof \"2.5mm\")"), "2.5");
|
||||
assert_eq!(ok(&mut s, "(atof \"x\")"), "0.0");
|
||||
assert_eq!(ok(&mut s, "(wcmatch \"Name\" \"N*\")"), "T");
|
||||
assert_eq!(ok(&mut s, "(wcmatch \"A12\" \"@##\")"), "T");
|
||||
assert_eq!(ok(&mut s, "(wcmatch \"abc\" \"x*,a?c\")"), "T");
|
||||
assert_eq!(ok(&mut s, "(wcmatch \"abc\" \"~a*\")"), "nil");
|
||||
assert_eq!(ok(&mut s, "(wcmatch \"b\" \"[a-c]\")"), "T");
|
||||
assert_eq!(ok(&mut s, "(vl-string-subst \"X\" \"b\" \"abcb\")"), "\"aXcb\"");
|
||||
assert_eq!(ok(&mut s, "(vl-string-search \"c\" \"abc\")"), "2");
|
||||
assert_eq!(ok(&mut s, "(read \"(1 2)\")"), "(1 2)");
|
||||
assert_eq!(ok(&mut s, "(type \"x\")"), "STR");
|
||||
assert_eq!(ok(&mut s, "(type 'x)"), "SYM");
|
||||
assert_eq!(ok(&mut s, "(equal '(1 2.0) '(1 2.0000001) 1e-3)"), "T");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn geometry() {
|
||||
let mut s = session();
|
||||
assert_eq!(ok(&mut s, "(polar '(0 0) 0 5)"), "(5.0 0.0)");
|
||||
assert_eq!(ok(&mut s, "(distance '(0 0) '(3 4))"), "5.0");
|
||||
assert_eq!(ok(&mut s, "(angle '(0 0) '(0 1))"), "1.5708");
|
||||
assert_eq!(ok(&mut s, "(inters '(0 0) '(10 10) '(0 10) '(10 0))"), "(5.0 5.0)");
|
||||
assert_eq!(ok(&mut s, "(inters '(0 0) '(1 1) '(0 10) '(10 0))"), "nil");
|
||||
assert_eq!(ok(&mut s, "(inters '(0 0) '(1 1) '(0 10) '(10 0) nil)"), "(5.0 5.0)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn functions_scope_and_control() {
|
||||
let mut s = session();
|
||||
ok(&mut s, "(defun sq (x) (* x x))");
|
||||
assert_eq!(ok(&mut s, "(sq 7)"), "49");
|
||||
ok(&mut s, "(defun fact (n) (if (< n 2) 1 (* n (fact (1- n)))))");
|
||||
assert_eq!(ok(&mut s, "(fact 10)"), "3628800");
|
||||
// Dynamic scope: arguments and locals hide globals while the function runs.
|
||||
ok(&mut s, "(setq x 1 y 2)");
|
||||
ok(&mut s, "(defun f (x / y) (setq y 5) (g))");
|
||||
ok(&mut s, "(defun g () (list x y))");
|
||||
assert_eq!(ok(&mut s, "(f 9)"), "(9 5)");
|
||||
assert_eq!(ok(&mut s, "(list x y)"), "(1 2)", "restored after the call");
|
||||
assert_eq!(ok(&mut s, "(progn (setq i 0 acc nil) (while (< i 3) (setq acc (cons i acc) i (1+ i))) acc)"), "(2 1 0)");
|
||||
assert_eq!(ok(&mut s, "(progn (setq n 0) (repeat 4 (setq n (+ n 2))) n)"), "8");
|
||||
assert_eq!(ok(&mut s, "(progn (setq tot 0) (foreach v '(1 2 3) (setq tot (+ tot v))) tot)"), "6");
|
||||
assert_eq!(ok(&mut s, "(cond ((= 1 2) 'a) ((= 1 1) 'b) (t 'c))"), "B");
|
||||
assert_eq!(ok(&mut s, "(and 1 2)"), "T");
|
||||
assert_eq!(ok(&mut s, "(or nil nil)"), "nil");
|
||||
assert_eq!(ok(&mut s, "((lambda (a b) (+ a b)) 1 2)"), "3");
|
||||
assert_eq!(ok(&mut s, "(eval '(+ 1 2))"), "3");
|
||||
assert_eq!(eval(&mut s, "(sq)"), Err("too few arguments".into()));
|
||||
assert_eq!(eval(&mut s, "(sq 1 2)"), Err("too many arguments".into()));
|
||||
assert_eq!(eval(&mut s, "(nosuchfunction 1)"), Err("no function definition: NOSUCHFUNCTION".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors_unwind_bindings_and_reach_the_handler() {
|
||||
let mut s = session();
|
||||
ok(&mut s, "(setq v 'outer)");
|
||||
ok(&mut s, "(defun boom (v) (/ 1 0))");
|
||||
assert!(eval(&mut s, "(boom 'inner)").is_err());
|
||||
assert_eq!(ok(&mut s, "v"), "OUTER", "the error restored the binding");
|
||||
// *error* runs with the message.
|
||||
ok(&mut s, "(defun *error* (msg) (setq caught msg))");
|
||||
assert!(eval(&mut s, "(boom 1)").is_err());
|
||||
assert_eq!(ok(&mut s, "caught"), "\"divide by zero\"");
|
||||
ok(&mut s, "(setq *error* nil)");
|
||||
// vl-catch-all-apply turns an error into a value.
|
||||
assert_eq!(ok(&mut s, "(vl-catch-all-error-message (vl-catch-all-apply '/ '(1 0)))"), "\"divide by zero\"");
|
||||
assert_eq!(ok(&mut s, "(vl-catch-all-apply '+ '(1 2))"), "3");
|
||||
assert!(eval(&mut s, "(exit)").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runaway_routines_are_errors_not_crashes() {
|
||||
let mut s = session();
|
||||
ok(&mut s, "(defun deep (n) (+ 1 (deep (1+ n))))");
|
||||
assert_eq!(eval(&mut s, "(deep 0)"), Err("internal stack limit reached".into()));
|
||||
// A deeply nested source text.
|
||||
let nested = format!("{}1{}", "(+ ".repeat(50_000), ")".repeat(50_000));
|
||||
assert!(eval(&mut s, &nested).is_err());
|
||||
// Hostile values.
|
||||
assert!(eval(&mut s, "(substr \"abc\" 0)").is_err());
|
||||
assert!(eval(&mut s, "(nth -1 '(1))").is_ok());
|
||||
assert!(eval(&mut s, "(chr -5)").is_ok());
|
||||
assert!(eval(&mut s, "(expt 2.0 99999)").is_err());
|
||||
assert!(eval(&mut s, "(gcd 0 0)").is_err());
|
||||
assert!(eval(&mut s, "(wcmatch \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaab\" \"*a*a*a*a*a*a*a*a*a*c\")").is_ok());
|
||||
assert!(eval(&mut s, "(fix 1e300)").is_ok());
|
||||
assert!(eval(&mut s, "(lsh 1 99)").is_ok());
|
||||
// An endless loop in a blocking run stops at the step cap.
|
||||
let r = eval(&mut s, "(setq n 0) (while (< n 3000000) (setq n (1+ n))) n");
|
||||
assert_eq!(r, Ok("3000000".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn printing() {
|
||||
let mut s = session();
|
||||
let before = s.log.len();
|
||||
ok(&mut s, "(princ \"Hello\") (princ \" world\\n\") (prin1 \"q\") (terpri) (print 3) (princ)");
|
||||
let out: Vec<_> = s.log[before..].to_vec();
|
||||
assert_eq!(out, ["Hello world", "\"q\"", "", "3 "]);
|
||||
assert_eq!(ok(&mut s, "(vl-prin1-to-string '(1 \"a\"))"), "\"(1 \\\"a\\\")\"");
|
||||
assert_eq!(ok(&mut s, "(rtos 12.5 2 2)"), "\"12.50\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_draws_and_one_routine_is_one_undo_step() {
|
||||
let mut s = session();
|
||||
let n = |s: &Session| s.doc().unwrap().model.handles().len();
|
||||
let undo_before = s.state().unwrap().undo.len();
|
||||
ok(&mut s, "(command \"_.LINE\" '(0 0) '(10 0) \"\") (command \"_.CIRCLE\" '(5 5) 2)");
|
||||
assert_eq!(n(&s), 2);
|
||||
assert_eq!(s.state().unwrap().undo.len(), undo_before + 1, "the whole routine undoes at once");
|
||||
// A command left open carries on in the next call.
|
||||
ok(&mut s, "(command \"_.LINE\" '(0 10) '(10 10)) (command '(10 20) \"\")");
|
||||
assert_eq!(n(&s), 4);
|
||||
assert!(s.running.is_none());
|
||||
// (defun c:…) makes a command.
|
||||
ok(&mut s, "(defun c:twolines () (command \"_.LINE\" '(0 0) '(0 5) '(5 5) \"\") (princ))");
|
||||
s.cmdline("TWOLINES").unwrap();
|
||||
assert_eq!(n(&s), 6);
|
||||
// An expression typed at Command: shows its value.
|
||||
s.cmdline("(+ 40 2)").unwrap();
|
||||
assert_eq!(s.log.last().map(String::as_str), Some("42"));
|
||||
s.cmdline("(setq dist 7.5)").unwrap();
|
||||
s.cmdline("!dist").unwrap();
|
||||
assert_eq!(s.log.last().map(String::as_str), Some("7.5"));
|
||||
// An expression at a command's prompt answers it.
|
||||
s.cmdline("CIRCLE 0,0 (* 2 dist)").unwrap();
|
||||
let r = s.doc().unwrap().model.iter().find_map(|e| match &e.kind {
|
||||
cadcraft_doc::EntityKind::Circle(c) if c.center.x == 0.0 && c.center.y == 0.0 => Some(c.radius),
|
||||
_ => None,
|
||||
});
|
||||
assert_eq!(r, Some(15.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entity_lists_round_trip() {
|
||||
let mut s = session();
|
||||
let made = ok(&mut s, "(entmake '((0 . \"CIRCLE\") (8 . \"WALLS\") (10 1.0 2.0 0.0) (40 . 3.0)))");
|
||||
assert!(made.contains("CIRCLE"));
|
||||
assert!(s.doc().unwrap().layer("WALLS").is_some(), "entmake creates the layer");
|
||||
assert_eq!(ok(&mut s, "(cdr (assoc 0 (entget (entlast))))"), "\"CIRCLE\"");
|
||||
assert_eq!(ok(&mut s, "(cdr (assoc 8 (entget (entlast))))"), "\"WALLS\"");
|
||||
assert_eq!(ok(&mut s, "(cdr (assoc 10 (entget (entlast))))"), "(1.0 2.0 0.0)");
|
||||
assert_eq!(ok(&mut s, "(cdr (assoc 410 (entget (entlast))))"), "\"Model\"");
|
||||
// entmod changes it in place.
|
||||
ok(&mut s, "(setq d (entget (entlast))) (entmod (subst (cons 40 9.0) (assoc 40 d) d))");
|
||||
assert_eq!(ok(&mut s, "(cdr (assoc 40 (entget (entlast))))"), "9.0");
|
||||
// A list without a layer uses the current one.
|
||||
ok(&mut s, "(setvar \"CLAYER\" \"WALLS\") (entmake '((0 . \"LINE\") (10 0 0 0) (11 5 0 0)))");
|
||||
assert_eq!(ok(&mut s, "(cdr (assoc 8 (entget (entlast))))"), "\"WALLS\"");
|
||||
// entnext walks in creation order; entdel deletes and brings back.
|
||||
assert_eq!(ok(&mut s, "(cdr (assoc 0 (entget (entnext))))"), "\"CIRCLE\"");
|
||||
assert_eq!(ok(&mut s, "(entnext (entlast))"), "nil");
|
||||
ok(&mut s, "(setq e (entlast)) (entdel e)");
|
||||
assert_eq!(ok(&mut s, "(entget e)"), "nil");
|
||||
ok(&mut s, "(entdel e)");
|
||||
assert_eq!(ok(&mut s, "(cdr (assoc 0 (entget e)))"), "\"LINE\"");
|
||||
assert_eq!(ok(&mut s, "(entmake '((0 . \"NOSUCHTHING\")))"), "nil");
|
||||
assert_eq!(ok(&mut s, "(handent \"ZZZZZZ\")"), "nil");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_sets_and_filters() {
|
||||
let mut s = session();
|
||||
ok(&mut s, "(command \"_.CIRCLE\" '(0 0) 1) (command \"_.CIRCLE\" '(10 0) 2) (command \"_.LINE\" '(0 5) '(20 5) \"\")");
|
||||
assert_eq!(ok(&mut s, "(sslength (ssget \"_X\" '((0 . \"CIRCLE\"))))"), "2");
|
||||
assert_eq!(ok(&mut s, "(sslength (ssget \"X\" '((0 . \"CIRCLE,LINE\"))))"), "3");
|
||||
assert_eq!(ok(&mut s, "(sslength (ssget \"X\" '((-4 . \"<OR\") (0 . \"LINE\") (40 . 2.0) (-4 . \"OR>\"))))"), "2");
|
||||
assert_eq!(ok(&mut s, "(sslength (ssget \"X\" '((0 . \"CIRCLE\") (-4 . \">\") (40 . 1.5))))"), "1");
|
||||
assert_eq!(ok(&mut s, "(ssget \"X\" '((0 . \"ARC\")))"), "nil");
|
||||
assert_eq!(ok(&mut s, "(sslength (ssget \"_W\" '(-5 -5) '(5 5)))"), "1");
|
||||
assert_eq!(ok(&mut s, "(sslength (ssget \"_C\" '(-5 -5) '(5 6)))"), "2");
|
||||
assert_eq!(ok(&mut s, "(setq ss (ssadd)) (ssadd (entlast) ss) (sslength ss)"), "1");
|
||||
assert_eq!(ok(&mut s, "(ssdel (entlast) ss) (sslength ss)"), "0");
|
||||
assert_eq!(ok(&mut s, "(ssname (ssget \"X\") 99)"), "nil");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tables_and_variables() {
|
||||
let mut s = session();
|
||||
ok(&mut s, "(command \"_.-LAYER\" \"_M\" \"DOORS\" \"_C\" \"1\" \"\" \"\")");
|
||||
assert_eq!(ok(&mut s, "(cdr (assoc 2 (tblsearch \"LAYER\" \"doors\")))"), "\"DOORS\"");
|
||||
assert_eq!(ok(&mut s, "(tblsearch \"LAYER\" \"nope\")"), "nil");
|
||||
assert_eq!(ok(&mut s, "(cdr (assoc 0 (tblnext \"LAYER\" t)))"), "\"LAYER\"");
|
||||
assert_eq!(ok(&mut s, "(getvar \"CLAYER\")"), "\"DOORS\"");
|
||||
assert_eq!(ok(&mut s, "(setvar \"OSMODE\" 0)"), "0");
|
||||
assert_eq!(ok(&mut s, "(getvar \"OSMODE\")"), "0");
|
||||
assert_eq!(ok(&mut s, "(getvar \"NOSUCHVAR\")"), "nil");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_functions_take_supplied_answers() {
|
||||
let mut s = session();
|
||||
let r = eval_with(
|
||||
&mut s,
|
||||
"(list (getpoint \"\\nPick: \") (getreal) (getstring) (getint))",
|
||||
vec![Value::point(&[1.0, 2.0]), Value::Real(2.5), Value::str("hi"), Value::Int(3)],
|
||||
);
|
||||
assert_eq!(r, Ok("((1.0 2.0) 2.5 \"hi\" 3)".into()));
|
||||
// No answer in a programmatic run: nil.
|
||||
assert_eq!(eval(&mut s, "(getpoint)"), Ok("nil".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interactive_routine_waits_for_the_user() {
|
||||
let mut s = session();
|
||||
s.cmdline("(defun c:pt () (setq p (getpoint \"\\nWhere: \")) (command \"_.CIRCLE\" p 1) (princ))").unwrap();
|
||||
s.cmdline("PT").unwrap();
|
||||
assert!(s.lisp_running());
|
||||
assert_eq!(s.prompt_text(), "Where: ");
|
||||
s.input(crate::Input::Point(cadcraft_geom::Vec2::new(3.0, 4.0))).unwrap();
|
||||
assert!(!s.lisp_running());
|
||||
assert!(s.doc().unwrap().model.iter().any(|e| matches!(&e.kind, cadcraft_doc::EntityKind::Circle(c) if c.center.x == 3.0 && c.center.y == 4.0)));
|
||||
// Typed answers and keywords.
|
||||
s.cmdline("(progn (initget 1 \"Yes No\") (setq ans (getkword \"\\nSure? [Yes/No]: \")))").unwrap();
|
||||
s.cmdline("n").unwrap();
|
||||
assert_eq!(to_text(&s.lisp_var("ANS"), true), "\"No\"");
|
||||
s.cmdline("(setq d (getdist '(0 0)))").unwrap();
|
||||
s.cmdline("3,4").unwrap();
|
||||
assert_eq!(to_text(&s.lisp_var("D"), true), "5.0");
|
||||
// Esc cancels the routine.
|
||||
s.cmdline("(setq z (getint))").unwrap();
|
||||
s.cancel();
|
||||
assert!(!s.lisp_running());
|
||||
// `pause` lets the user answer one prompt of a command.
|
||||
s.cmdline("(command \"_.CIRCLE\" pause 2)").unwrap();
|
||||
assert!(s.lisp_running());
|
||||
s.input(crate::Input::Point(cadcraft_geom::Vec2::new(50.0, 50.0))).unwrap();
|
||||
assert!(!s.lisp_running());
|
||||
assert!(s.doc().unwrap().model.iter().any(|e| matches!(&e.kind, cadcraft_doc::EntityKind::Circle(c) if c.center.x == 50.0 && c.radius == 2.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appload_and_load() {
|
||||
let mut s = session();
|
||||
let r =
|
||||
s.execute("appload", &serde_json::json!({"name": "tools.lsp", "text": "(defun c:hi () (princ \"hi\") (princ)) (setq loaded 1)"})).unwrap();
|
||||
assert!(r.get("error").is_none(), "{r}");
|
||||
assert!(s.log.iter().any(|l| l == "tools.lsp successfully loaded."));
|
||||
s.cmdline("HI").unwrap();
|
||||
assert_eq!(s.log.last().map(String::as_str), Some("hi"));
|
||||
// (load) finds a file APPLOAD loaded, by name.
|
||||
assert_eq!(ok(&mut s, "(setq loaded 0) (load \"tools\") loaded"), "1");
|
||||
assert!(eval(&mut s, "(load \"no-such-file-anywhere\")").is_err());
|
||||
assert_eq!(ok(&mut s, "(load \"no-such-file-anywhere\" 'missing)"), "MISSING");
|
||||
let r = s.execute("lisp", &serde_json::json!({"code": "(princ \"x\") (* 6 7)"})).unwrap();
|
||||
assert_eq!(r["value"], "42");
|
||||
assert_eq!(r["output"][0], "x");
|
||||
let r = s.execute("lisp", &serde_json::json!({"code": "(car 1)"})).unwrap();
|
||||
assert!(r["error"].as_str().is_some_and(|e| e.contains("bad argument type")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_lisp_never_panics() {
|
||||
let mut s = session();
|
||||
for code in [
|
||||
"(",
|
||||
")",
|
||||
"'",
|
||||
"\"",
|
||||
"(1 . 2 3)",
|
||||
"(quote)",
|
||||
"(setq)",
|
||||
"(setq 1 2)",
|
||||
"(if)",
|
||||
"(defun)",
|
||||
"(defun f)",
|
||||
"(lambda)",
|
||||
"(repeat 1.5)",
|
||||
"(repeat -3 1)",
|
||||
"(foreach 1 2)",
|
||||
"(mapcar)",
|
||||
"(mapcar 'car 1)",
|
||||
"(apply 'car 1)",
|
||||
"(entget 1)",
|
||||
"(entmake '(1 2))",
|
||||
"(entmod nil)",
|
||||
"(ssget \"Q\")",
|
||||
"(ssget \"X\" '((-4 . \"<OR\")))",
|
||||
"(ssname 1 2)",
|
||||
"(command 1.5 nil '(a b))",
|
||||
"(tblsearch 1 2)",
|
||||
"(rtos 1 9)",
|
||||
"(getvar 1)",
|
||||
"(setvar \"OSMODE\" \"x\")",
|
||||
"(open \"\" \"z\")",
|
||||
"(read-line 5)",
|
||||
"(close 5)",
|
||||
"(vl-sort 1 2)",
|
||||
"(eval (read \"(\"))",
|
||||
"(substr \"\" 1 -1)",
|
||||
"(nth 99999999 '(1))",
|
||||
"(polar 1 2 3)",
|
||||
"(inters '(0 0) '(0 0) '(0 0) '(0 0))",
|
||||
"(chr 1114112)",
|
||||
] {
|
||||
let _ = eval(&mut s, code);
|
||||
}
|
||||
assert!(!s.lisp_running());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_waiting_routine_is_a_prompt_with_a_rubber_band() {
|
||||
let mut s = session();
|
||||
assert!(!s.prompting());
|
||||
s.cmdline("(setq c (getcorner '(0 0) \"\nOther corner: \"))").unwrap();
|
||||
// No command runs, yet a prompt waits: clicks must answer it (not select).
|
||||
assert!(s.running.is_none() && s.prompting());
|
||||
let current = s.current_prompt().unwrap();
|
||||
assert!(current.raw && current.display() == "Other corner: ");
|
||||
// getcorner drags a rectangle from its corner.
|
||||
let band = s.preview(cadcraft_geom::Vec2::new(3.0, 2.0));
|
||||
assert_eq!(band.len(), 4);
|
||||
s.input(crate::Input::Point(cadcraft_geom::Vec2::new(3.0, 2.0))).unwrap();
|
||||
assert!(!s.prompting());
|
||||
// getpoint with a base point drags a line; without one, nothing.
|
||||
s.cmdline("(getpoint '(1 1))").unwrap();
|
||||
assert_eq!(s.preview(cadcraft_geom::Vec2::new(4.0, 5.0)).len(), 1);
|
||||
s.cancel();
|
||||
s.cmdline("(getpoint)").unwrap();
|
||||
assert!(s.preview(cadcraft_geom::Vec2::new(4.0, 5.0)).is_empty());
|
||||
s.cancel();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_leading_newline_ends_the_command_line_instead_of_adding_an_empty_one() {
|
||||
let mut s = session();
|
||||
let from = s.log.len();
|
||||
s.cmdline("(progn (princ \"\nHello\") (princ \"\n\nAfter a blank\") (princ))").unwrap();
|
||||
assert_eq!(&s.log[from..], ["Hello", "", "After a blank"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_files_load_from_the_support_folder() {
|
||||
let dir = std::env::temp_dir().join(format!("cadcraft-lisp-startup-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("acad.lsp"), "(defun c:fromacad () (princ \"acad\") (princ))").unwrap();
|
||||
std::fs::write(
|
||||
dir.join("acaddoc.lsp"),
|
||||
"(setq docvar 1) (autoload \"lazy\" '(\"LAZY\")) (defun S::STARTUP () (setq started (1+ (if started started 0))))",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(dir.join("lazy.lsp"), "(defun c:lazy () (setq lazy-ran 1) (princ \"lazy ran\") (princ))").unwrap();
|
||||
// A drawing's own folder is never read: an acaddoc.lsp next to it must not run.
|
||||
let drawings = dir.join("drawings");
|
||||
std::fs::create_dir_all(&drawings).unwrap();
|
||||
std::fs::write(drawings.join("acaddoc.lsp"), "(setq evil 1)").unwrap();
|
||||
|
||||
let mut s = session();
|
||||
s.set_lisp_support_dir(Some(dir.clone()));
|
||||
s.lisp_tick();
|
||||
let v = |s: &Session, n: &str| to_text(&s.lisp_var(n), true);
|
||||
assert_eq!(v(&s, "DOCVAR"), "1");
|
||||
assert_eq!(v(&s, "STARTED"), "1", "S::STARTUP ran once");
|
||||
assert!(matches!(s.lisp_var("C:FROMACAD"), Value::Func(_)));
|
||||
s.lisp_tick();
|
||||
assert_eq!(v(&s, "STARTED"), "1", "a drawing gets its startup files once");
|
||||
// autoload: the command loads its file on first use.
|
||||
s.cmdline("LAZY").unwrap();
|
||||
assert_eq!(v(&s, "LAZY-RAN"), "1");
|
||||
assert_eq!(s.log.last().map(String::as_str), Some("lazy ran"));
|
||||
|
||||
// A new drawing: acaddoc.lsp again, acad.lsp only once per session...
|
||||
s.new_drawing(false);
|
||||
s.lisp_tick();
|
||||
assert_eq!(v(&s, "DOCVAR"), "1");
|
||||
assert!(s.lisp_var("C:FROMACAD").is_nil());
|
||||
// ...unless ACADLSPASDOC is on.
|
||||
crate::sysvars::set(&mut s, "ACADLSPASDOC", &serde_json::json!(1)).unwrap();
|
||||
s.new_drawing(false);
|
||||
s.lisp_tick();
|
||||
assert!(matches!(s.lisp_var("C:FROMACAD"), Value::Func(_)));
|
||||
|
||||
// A drawing opened from a folder with its own acaddoc.lsp: only the support folder's runs.
|
||||
let path = drawings.join("plan.dxf").to_string_lossy().into_owned();
|
||||
s.open_drawing(cadcraft_doc::Drawing::new_metric(), "plan.dxf", Some(path));
|
||||
s.lisp_tick();
|
||||
assert_eq!(v(&s, "DOCVAR"), "1");
|
||||
assert!(s.lisp_var("EVIL").is_nil());
|
||||
|
||||
// No support folder: nothing loads.
|
||||
let mut s = session();
|
||||
s.lisp_tick();
|
||||
assert!(s.lisp_var("DOCVAR").is_nil());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deeply_nested_lists_drop_without_overflowing_the_stack() {
|
||||
let _heavy = super::value::HEAVY_CELLS.lock().unwrap_or_else(|e| e.into_inner());
|
||||
// Nested in the car a million deep: dropping it must not recurse.
|
||||
let mut v = Value::Nil;
|
||||
for _ in 0..1_000_000 {
|
||||
v = Value::list(vec![v]);
|
||||
}
|
||||
drop(v);
|
||||
// Functions nested in function bodies too.
|
||||
let mut f = Value::Nil;
|
||||
for _ in 0..200_000 {
|
||||
f = Value::Func(std::sync::Arc::new(super::value::Func { name: None, params: Vec::new(), locals: Vec::new(), body: Value::list(vec![f]) }));
|
||||
}
|
||||
drop(f);
|
||||
// The same built by a routine and dropped when the variable is cleared.
|
||||
let mut s = session();
|
||||
assert_eq!(ok(&mut s, "(setq x nil) (repeat 100000 (setq x (list x))) (setq x nil) 1"), "1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn too_many_list_cells_stop_the_routine() {
|
||||
let _heavy = super::value::HEAVY_CELLS.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut s = session();
|
||||
s.lisp.limits.cells = 50_000;
|
||||
let e = eval(&mut s, "(setq l nil) (repeat 2000000 (setq l (cons 1 l)))").unwrap_err();
|
||||
assert!(e.contains("too many list cells"), "{e}");
|
||||
// The variable still holds what was made, but the drawing's LISP stays usable.
|
||||
assert_eq!(ok(&mut s, "(setq l nil) (+ 1 2)"), "3");
|
||||
// A doubling list is stopped as well.
|
||||
let e = eval(&mut s, "(setq l '(1)) (repeat 100 (setq l (append l l)))").unwrap_err();
|
||||
assert!(e.contains("too many list cells"), "{e}");
|
||||
assert_eq!(ok(&mut s, "(setq l nil) (length '(1 2 3))"), "3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strings_are_capped() {
|
||||
let _heavy = super::value::HEAVY_CELLS.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut s = session();
|
||||
// A string doubled until it would not fit in memory.
|
||||
let e = eval(&mut s, "(setq s \"x\") (repeat 100 (setq s (strcat s s)))").unwrap_err();
|
||||
assert!(e.contains("string longer than"), "{e}");
|
||||
// Printing a list that shares its parts (exponentially long text) is cut.
|
||||
let t = ok(&mut s, "(setq x '(1 2)) (repeat 40 (setq x (list x x))) (strlen (vl-prin1-to-string x))");
|
||||
assert!(t.parse::<usize>().unwrap() <= super::value::MAX_STRING + 64, "{t}");
|
||||
ok(&mut s, "(setq x nil s nil)");
|
||||
// Many strings, each short, add up to the run's budget.
|
||||
s.lisp.limits.string_bytes = 1_000_000;
|
||||
let e = eval(&mut s, "(setq a \"0123456789\") (repeat 20000 (setq b (strcat a a a a a a a a a a)))").unwrap_err();
|
||||
assert!(e.contains("too much text"), "{e}");
|
||||
let e = eval(&mut s, "(repeat 200000 (princ \"0123456789\"))").unwrap_err();
|
||||
assert!(e.contains("too much text"), "{e}");
|
||||
// The budget is per run: the next one starts afresh.
|
||||
assert_eq!(ok(&mut s, "(strcat \"a\" \"b\")"), "\"ab\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_panic_inside_a_routine_ends_it_with_an_error() {
|
||||
let mut s = session();
|
||||
let n = |s: &Session| s.doc().unwrap().model.handles().len();
|
||||
let before = n(&s);
|
||||
let e = eval(&mut s, "(entmake '((0 . \"LINE\") (10 0 0 0) (11 5 0 0))) (cadcraft-test-panic)").unwrap_err();
|
||||
assert!(e.contains("internal error"), "{e}");
|
||||
assert_eq!(n(&s), before, "the drawing is put back");
|
||||
assert_eq!(ok(&mut s, "(+ 1 2)"), "3");
|
||||
|
||||
// A routine carried on by the per-frame tick.
|
||||
let r = s.lisp_eval("(repeat 500000 (setq i 1)) (cadcraft-test-panic)", false, false, Vec::new()).unwrap();
|
||||
assert!(r.is_none(), "the routine gave the interface a frame");
|
||||
assert!(s.lisp_busy());
|
||||
for _ in 0..100 {
|
||||
if !s.lisp_busy() {
|
||||
break;
|
||||
}
|
||||
s.lisp_tick();
|
||||
}
|
||||
assert!(!s.lisp_running());
|
||||
assert!(s.log.iter().any(|l| l.contains("internal error")), "{:?}", s.log);
|
||||
assert_eq!(ok(&mut s, "(+ 1 2)"), "3");
|
||||
}
|
||||
@@ -0,0 +1,562 @@
|
||||
//! AutoLISP values: integers (32-bit, wrapping as AutoLISP's do), reals, strings, symbols,
|
||||
//! lists built of cons cells, entity names, selection sets, files and functions.
|
||||
//!
|
||||
//! Values are shared (`Arc`) so a session stays `Send`. Cons cells (and functions) are dropped
|
||||
//! iteratively: a list a million long, or nested a million deep, would otherwise overflow the
|
||||
//! stack when it goes away.
|
||||
//!
|
||||
//! Memory is bounded so a runaway routine fails with a LISP error instead of the process running
|
||||
//! out of memory (which no panic guard can catch): live cons cells are counted ([`live_cells`],
|
||||
//! checked by the evaluator at every step), no string a built-in makes may be longer than
|
||||
//! [`MAX_STRING`], and the strings and printed text a run makes share a byte budget
|
||||
//! ([`charge_bytes`]).
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use cadcraft_doc::Handle;
|
||||
|
||||
use super::LispError;
|
||||
|
||||
/// A symbol: its name in upper case (AutoLISP symbols are case-insensitive).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub struct Sym(pub Arc<str>);
|
||||
|
||||
impl Sym {
|
||||
pub fn new(name: &str) -> Sym {
|
||||
Sym(Arc::from(name.to_ascii_uppercase().as_str()))
|
||||
}
|
||||
pub fn name(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// A cons cell.
|
||||
#[derive(Debug)]
|
||||
pub struct Cons {
|
||||
pub car: Value,
|
||||
pub cdr: Value,
|
||||
}
|
||||
|
||||
/// Most cons cells alive at once (about 64 bytes each): far above real routines (a list of
|
||||
/// every entity of a large drawing, its `entget` lists), well below memory trouble.
|
||||
pub const MAX_CELLS: usize = 5_000_000;
|
||||
/// Longest string a built-in may make, in bytes.
|
||||
pub const MAX_STRING: usize = 8 << 20;
|
||||
/// Bytes of new strings, printed text and files read one run may make in all.
|
||||
pub const STRING_BUDGET: u64 = 2 << 30;
|
||||
|
||||
/// Cons cells alive in the process.
|
||||
static LIVE_CELLS: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
/// Tests that make or cap many cells run one at a time: the count is shared by the process.
|
||||
#[cfg(test)]
|
||||
pub(crate) static HEAVY_CELLS: Mutex<()> = Mutex::new(());
|
||||
|
||||
/// Cons cells alive now (all sessions).
|
||||
pub fn live_cells() -> usize {
|
||||
LIVE_CELLS.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// The running slice's string budget: bytes made so far and the most allowed.
|
||||
static STRING_BYTES: Cell<(u64, u64)> = const { Cell::new((0, STRING_BUDGET)) };
|
||||
/// The running slice's cap on live cons cells.
|
||||
static CELL_LIMIT: Cell<usize> = const { Cell::new(MAX_CELLS) };
|
||||
}
|
||||
|
||||
/// Start a slice of a run on this thread: it fails when more than `limit` cells are alive.
|
||||
pub fn set_cell_limit(limit: usize) {
|
||||
CELL_LIMIT.with(|c| c.set(limit));
|
||||
}
|
||||
|
||||
/// Too many cons cells are alive: the error that stops the run.
|
||||
pub fn check_cells() -> Result<(), LispError> {
|
||||
if live_cells() > CELL_LIMIT.with(Cell::get) { Err(LispError::quit("out of memory: too many list cells")) } else { Ok(()) }
|
||||
}
|
||||
|
||||
/// Start a slice of a run on this thread: it has made `used` bytes of its `budget`.
|
||||
pub fn set_string_budget(used: u64, budget: u64) {
|
||||
STRING_BYTES.with(|c| c.set((used, budget)));
|
||||
}
|
||||
|
||||
/// The bytes the run has made so far on this thread.
|
||||
pub fn string_bytes_used() -> u64 {
|
||||
STRING_BYTES.with(|c| c.get().0)
|
||||
}
|
||||
|
||||
/// Count `n` new bytes (a string, printed text, a file read) against the run's budget.
|
||||
pub fn charge_bytes(n: usize) -> Result<(), LispError> {
|
||||
STRING_BYTES.with(|c| {
|
||||
let (used, budget) = c.get();
|
||||
let used = used.saturating_add(u64::try_from(n).unwrap_or(u64::MAX));
|
||||
c.set((used, budget));
|
||||
if used > budget { Err(LispError::quit("out of memory: the routine made too much text")) } else { Ok(()) }
|
||||
})
|
||||
}
|
||||
|
||||
/// A string of `len` bytes may be made.
|
||||
pub fn check_string_len(len: usize) -> Result<(), LispError> {
|
||||
if len > MAX_STRING { Err(LispError::quit(format!("out of memory: string longer than {MAX_STRING} bytes"))) } else { Ok(()) }
|
||||
}
|
||||
|
||||
/// A built-in's result: a new string (one nothing else holds) is checked and charged.
|
||||
pub fn charge_result(v: &Value) -> Result<(), LispError> {
|
||||
if let Value::Str(s) = v
|
||||
&& Arc::strong_count(s) == 1
|
||||
{
|
||||
check_string_len(s.len())?;
|
||||
charge_bytes(s.len())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl Drop for Cons {
|
||||
fn drop(&mut self) {
|
||||
// Cells are only made by `Value::cons`, which counts them; never wrap below zero.
|
||||
if LIVE_CELLS.fetch_sub(1, Ordering::Relaxed) == 0 {
|
||||
LIVE_CELLS.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
// Unlink everything this cell alone holds (the rest of the list, nested lists in its
|
||||
// car, function bodies) one cell at a time, so nothing recurses on the stack.
|
||||
let mut work = Vec::new();
|
||||
keep_for_drop(&mut work, std::mem::take(&mut self.car));
|
||||
keep_for_drop(&mut work, std::mem::take(&mut self.cdr));
|
||||
while let Some(v) = work.pop() {
|
||||
match v {
|
||||
Value::Cons(c) => {
|
||||
if let Ok(mut cell) = Arc::try_unwrap(c) {
|
||||
keep_for_drop(&mut work, std::mem::take(&mut cell.car));
|
||||
keep_for_drop(&mut work, std::mem::take(&mut cell.cdr));
|
||||
}
|
||||
}
|
||||
Value::Func(f) => {
|
||||
if let Ok(mut func) = Arc::try_unwrap(f) {
|
||||
keep_for_drop(&mut work, std::mem::take(&mut func.body));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Values that can hold others go on the work list; atoms are dropped here.
|
||||
fn keep_for_drop(work: &mut Vec<Value>, v: Value) {
|
||||
if matches!(v, Value::Cons(_) | Value::Func(_)) {
|
||||
work.push(v);
|
||||
}
|
||||
}
|
||||
|
||||
/// A selection set: the entities it holds, in order. `ssadd` and `ssdel` change it in place.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PickSet {
|
||||
pub id: u32,
|
||||
pub items: Arc<Mutex<Vec<Handle>>>,
|
||||
}
|
||||
|
||||
/// A function defined in LISP (`defun`, `lambda`).
|
||||
#[derive(Debug)]
|
||||
pub struct Func {
|
||||
pub name: Option<Sym>,
|
||||
pub params: Vec<Sym>,
|
||||
/// Local variables (after the `/` in the argument list): bound to nil during a call.
|
||||
pub locals: Vec<Sym>,
|
||||
/// The body forms, as a list.
|
||||
pub body: Value,
|
||||
}
|
||||
|
||||
/// A built-in function. `special` forms get their arguments unevaluated.
|
||||
pub struct Builtin {
|
||||
pub name: &'static str,
|
||||
pub kind: BuiltinKind,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Builtin {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "<Subr: {}>", self.name)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum BuiltinKind {
|
||||
/// Evaluated arguments to a value.
|
||||
Pure(fn(&[Value]) -> Result<Value, LispError>),
|
||||
/// Evaluated arguments, with the interpreter (and its session) at hand.
|
||||
Ctx(fn(&mut super::eval::Ctx<'_>, Vec<Value>) -> Result<super::eval::Flow, LispError>),
|
||||
/// A special form, handled by the evaluator itself.
|
||||
Special(super::eval::Special),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub enum Value {
|
||||
#[default]
|
||||
Nil,
|
||||
Int(i32),
|
||||
Real(f64),
|
||||
Str(Arc<str>),
|
||||
Sym(Sym),
|
||||
Cons(Arc<Cons>),
|
||||
Ename(Handle),
|
||||
PickSet(PickSet),
|
||||
File(u32),
|
||||
Subr(&'static Builtin),
|
||||
Func(Arc<Func>),
|
||||
}
|
||||
|
||||
impl Value {
|
||||
pub fn t() -> Value {
|
||||
Value::Sym(Sym::new("T"))
|
||||
}
|
||||
pub fn bool(b: bool) -> Value {
|
||||
if b { Value::t() } else { Value::Nil }
|
||||
}
|
||||
pub fn str(s: &str) -> Value {
|
||||
Value::Str(Arc::from(s))
|
||||
}
|
||||
pub fn sym(s: &str) -> Value {
|
||||
Value::Sym(Sym::new(s))
|
||||
}
|
||||
pub fn cons(car: Value, cdr: Value) -> Value {
|
||||
LIVE_CELLS.fetch_add(1, Ordering::Relaxed);
|
||||
Value::Cons(Arc::new(Cons { car, cdr }))
|
||||
}
|
||||
pub fn is_nil(&self) -> bool {
|
||||
matches!(self, Value::Nil)
|
||||
}
|
||||
/// A proper list of `items`.
|
||||
pub fn list(items: impl IntoIterator<Item = Value, IntoIter: DoubleEndedIterator>) -> Value {
|
||||
items.into_iter().rev().fold(Value::Nil, |acc, v| Value::cons(v, acc))
|
||||
}
|
||||
/// `items` followed by `tail` (a dotted list when `tail` is an atom).
|
||||
pub fn list_with_tail(items: Vec<Value>, tail: Value) -> Value {
|
||||
items.into_iter().rev().fold(tail, |acc, v| Value::cons(v, acc))
|
||||
}
|
||||
/// A point as a list of reals.
|
||||
pub fn point(p: &[f64]) -> Value {
|
||||
Value::list(p.iter().map(|v| Value::Real(*v)).collect::<Vec<_>>())
|
||||
}
|
||||
pub fn car(&self) -> Value {
|
||||
match self {
|
||||
Value::Cons(c) => c.car.clone(),
|
||||
_ => Value::Nil,
|
||||
}
|
||||
}
|
||||
pub fn cdr(&self) -> Value {
|
||||
match self {
|
||||
Value::Cons(c) => c.cdr.clone(),
|
||||
_ => Value::Nil,
|
||||
}
|
||||
}
|
||||
/// The elements of a list (a dotted tail is left out).
|
||||
pub fn iter(&self) -> ListIter {
|
||||
ListIter { next: self.clone() }
|
||||
}
|
||||
pub fn to_vec(&self) -> Vec<Value> {
|
||||
self.iter().collect()
|
||||
}
|
||||
/// A proper list (nil or cons cells ending in nil).
|
||||
pub fn is_list(&self) -> bool {
|
||||
let mut v = self.clone();
|
||||
loop {
|
||||
match v {
|
||||
Value::Nil => return true,
|
||||
Value::Cons(c) => v = c.cdr.clone(),
|
||||
_ => return false,
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn as_number(&self) -> Option<f64> {
|
||||
match self {
|
||||
Value::Int(i) => Some(f64::from(*i)),
|
||||
Value::Real(r) => Some(*r),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub fn as_sym(&self) -> Option<&Sym> {
|
||||
match self {
|
||||
Value::Sym(s) => Some(s),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// The type name `type` returns (nil for nil).
|
||||
pub fn type_name(&self) -> Option<&'static str> {
|
||||
Some(match self {
|
||||
Value::Nil => return None,
|
||||
Value::Int(_) => "INT",
|
||||
Value::Real(_) => "REAL",
|
||||
Value::Str(_) => "STR",
|
||||
Value::Sym(_) => "SYM",
|
||||
Value::Cons(_) => "LIST",
|
||||
Value::Ename(_) => "ENAME",
|
||||
Value::PickSet(_) => "PICKSET",
|
||||
Value::File(_) => "FILE",
|
||||
Value::Subr(_) => "SUBR",
|
||||
Value::Func(_) => "USUBR",
|
||||
})
|
||||
}
|
||||
/// A 2D or 3D point given as a list of two or three numbers.
|
||||
pub fn as_point(&self) -> Option<[f64; 3]> {
|
||||
let mut it = self.iter();
|
||||
let x = it.next()?.as_number()?;
|
||||
let y = it.next()?.as_number()?;
|
||||
let z = match it.next() {
|
||||
Some(v) => v.as_number()?,
|
||||
None => 0.0,
|
||||
};
|
||||
(it.next().is_none() && self.is_list()).then_some([x, y, z])
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ListIter {
|
||||
next: Value,
|
||||
}
|
||||
|
||||
impl Iterator for ListIter {
|
||||
type Item = Value;
|
||||
fn next(&mut self) -> Option<Value> {
|
||||
match std::mem::take(&mut self.next) {
|
||||
Value::Cons(c) => {
|
||||
self.next = c.cdr.clone();
|
||||
Some(c.car.clone())
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Deepest nesting `equal`, printing and reading follow before giving up (hostile input).
|
||||
pub const MAX_DEPTH: usize = 2_000;
|
||||
|
||||
/// `eq`: the same atom or the same cons cell.
|
||||
pub fn eq(a: &Value, b: &Value) -> bool {
|
||||
match (a, b) {
|
||||
(Value::Cons(x), Value::Cons(y)) => Arc::ptr_eq(x, y),
|
||||
(Value::Str(x), Value::Str(y)) => Arc::ptr_eq(x, y) || x == y,
|
||||
(Value::PickSet(x), Value::PickSet(y)) => x.id == y.id,
|
||||
(Value::Func(x), Value::Func(y)) => Arc::ptr_eq(x, y),
|
||||
(Value::Subr(x), Value::Subr(y)) => std::ptr::eq(*x, *y),
|
||||
_ => atom_equal(a, b, 0.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// `equal`: the same structure, numbers within `fuzz`.
|
||||
pub fn equal(a: &Value, b: &Value, fuzz: f64) -> bool {
|
||||
equal_depth(a, b, fuzz, 0)
|
||||
}
|
||||
|
||||
fn equal_depth(a: &Value, b: &Value, fuzz: f64, depth: usize) -> bool {
|
||||
if depth > MAX_DEPTH {
|
||||
return false;
|
||||
}
|
||||
let (mut a, mut b) = (a.clone(), b.clone());
|
||||
loop {
|
||||
match (&a, &b) {
|
||||
(Value::Cons(x), Value::Cons(y)) => {
|
||||
if !equal_depth(&x.car, &y.car, fuzz, depth + 1) {
|
||||
return false;
|
||||
}
|
||||
let (na, nb) = (x.cdr.clone(), y.cdr.clone());
|
||||
a = na;
|
||||
b = nb;
|
||||
}
|
||||
_ => return atom_equal(&a, &b, fuzz),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn atom_equal(a: &Value, b: &Value, fuzz: f64) -> bool {
|
||||
match (a, b) {
|
||||
(Value::Nil, Value::Nil) => true,
|
||||
(Value::Int(x), Value::Int(y)) => x == y,
|
||||
(Value::Str(x), Value::Str(y)) => x == y,
|
||||
(Value::Sym(x), Value::Sym(y)) => x == y,
|
||||
(Value::Ename(x), Value::Ename(y)) => x == y,
|
||||
(Value::File(x), Value::File(y)) => x == y,
|
||||
(Value::PickSet(x), Value::PickSet(y)) => x.id == y.id,
|
||||
(Value::Subr(x), Value::Subr(y)) => std::ptr::eq(*x, *y),
|
||||
(Value::Func(x), Value::Func(y)) => Arc::ptr_eq(x, y),
|
||||
(x, y) => match (x.as_number(), y.as_number()) {
|
||||
(Some(p), Some(q)) if matches!(x, Value::Real(_)) || matches!(y, Value::Real(_)) || fuzz > 0.0 => (p - q).abs() <= fuzz,
|
||||
_ => false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// A real as AutoLISP prints it: six significant digits, always with a decimal point.
|
||||
pub fn real_text(r: f64) -> String {
|
||||
if !r.is_finite() {
|
||||
return if r.is_nan() {
|
||||
"-1.#IND".into()
|
||||
} else if r > 0.0 {
|
||||
"1.#INF".into()
|
||||
} else {
|
||||
"-1.#INF".into()
|
||||
};
|
||||
}
|
||||
if r == 0.0 {
|
||||
return "0.0".into();
|
||||
}
|
||||
let exp = r.abs().log10().floor() as i32;
|
||||
// As C's %g with six digits: exponent form outside 1e-5 ..< 1e6.
|
||||
if !(-5..6).contains(&exp) {
|
||||
// Exponent form: d.ddddde+NNN
|
||||
let m = r / 10f64.powi(exp);
|
||||
let mut s = trim_fraction(&format!("{m:.5}"));
|
||||
if !s.contains('.') {
|
||||
s.push_str(".0");
|
||||
}
|
||||
return format!("{s}e{}{:03}", if exp < 0 { '-' } else { '+' }, exp.abs());
|
||||
}
|
||||
let decimals = (5 - exp).max(0) as usize;
|
||||
let mut s = trim_fraction(&format!("{r:.decimals$}"));
|
||||
if !s.contains('.') {
|
||||
s.push_str(".0");
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn trim_fraction(s: &str) -> String {
|
||||
if s.contains('.') {
|
||||
let t = s.trim_end_matches('0');
|
||||
t.strip_suffix('.').map_or(t.to_string(), |u| format!("{u}.0"))
|
||||
} else {
|
||||
s.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// `prin1`/`print` text (`escape`: strings quoted with their escapes) or `princ` text.
|
||||
pub fn to_text(v: &Value, escape: bool) -> String {
|
||||
let mut out = String::new();
|
||||
write_value(&mut out, v, escape, 0);
|
||||
if out.len() > MAX_STRING {
|
||||
let mut end = MAX_STRING;
|
||||
while !out.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
out.truncate(end);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn write_value(out: &mut String, v: &Value, escape: bool, depth: usize) {
|
||||
// Text past the string cap is cut (a list sharing its parts can print exponentially long).
|
||||
if out.len() > MAX_STRING {
|
||||
return;
|
||||
}
|
||||
if depth > MAX_DEPTH {
|
||||
out.push_str("...");
|
||||
return;
|
||||
}
|
||||
match v {
|
||||
Value::Nil => out.push_str("nil"),
|
||||
Value::Int(i) => out.push_str(&i.to_string()),
|
||||
Value::Real(r) => out.push_str(&real_text(*r)),
|
||||
Value::Str(s) => {
|
||||
if escape {
|
||||
out.push('"');
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'"' => out.push_str("\\\""),
|
||||
'\\' => out.push_str("\\\\"),
|
||||
'\n' => out.push_str("\\n"),
|
||||
'\t' => out.push_str("\\t"),
|
||||
'\r' => out.push_str("\\r"),
|
||||
'\u{1b}' => out.push_str("\\e"),
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out.push('"');
|
||||
} else {
|
||||
out.push_str(s);
|
||||
}
|
||||
}
|
||||
Value::Sym(s) => out.push_str(s.name()),
|
||||
Value::Cons(_) => {
|
||||
// (quote x) prints as 'x.
|
||||
if let (Value::Sym(s), Value::Cons(rest)) = (v.car(), v.cdr())
|
||||
&& s.name() == "QUOTE"
|
||||
&& rest.cdr.is_nil()
|
||||
{
|
||||
out.push('\'');
|
||||
write_value(out, &rest.car, escape, depth + 1);
|
||||
return;
|
||||
}
|
||||
out.push('(');
|
||||
let mut cur = v.clone();
|
||||
let mut first = true;
|
||||
let mut count = 0usize;
|
||||
loop {
|
||||
match cur {
|
||||
Value::Cons(c) => {
|
||||
if !first {
|
||||
out.push(' ');
|
||||
}
|
||||
first = false;
|
||||
write_value(out, &c.car, escape, depth + 1);
|
||||
cur = c.cdr.clone();
|
||||
count += 1;
|
||||
if count > 1_000_000 || out.len() > MAX_STRING {
|
||||
out.push_str(" ...");
|
||||
break;
|
||||
}
|
||||
}
|
||||
Value::Nil => break,
|
||||
other => {
|
||||
out.push_str(" . ");
|
||||
write_value(out, &other, escape, depth + 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push(')');
|
||||
}
|
||||
Value::Ename(h) => out.push_str(&format!("<Entity name: {:x}>", h.0)),
|
||||
Value::PickSet(p) => out.push_str(&format!("<Selection set: {:x}>", p.id)),
|
||||
Value::File(f) => out.push_str(&format!("#<FILE {f:x}>")),
|
||||
Value::Subr(b) => out.push_str(&format!("#<SUBR @{} {}>", "0", b.name)),
|
||||
Value::Func(f) => out.push_str(&format!("#<USUBR {}>", f.name.as_ref().map_or("-lambda-", |s| s.name()))),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reals_print_with_six_significant_digits() {
|
||||
assert_eq!(real_text(1.0), "1.0");
|
||||
assert_eq!(real_text(std::f64::consts::PI), "3.14159");
|
||||
assert_eq!(real_text(1.23456789), "1.23457");
|
||||
assert_eq!(real_text(0.1), "0.1");
|
||||
assert_eq!(real_text(12.5), "12.5");
|
||||
assert_eq!(real_text(-2.0), "-2.0");
|
||||
assert_eq!(real_text(100000.0), "100000.0");
|
||||
assert_eq!(real_text(123456.7), "123457.0");
|
||||
assert_eq!(real_text(0.0001), "0.0001");
|
||||
assert_eq!(real_text(1_000_000.0), "1.0e+006");
|
||||
assert_eq!(real_text(0.000001), "1.0e-006");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_list_drops_without_overflowing_the_stack() {
|
||||
let _heavy = HEAVY_CELLS.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut l = Value::Nil;
|
||||
for i in 0..2_000_000 {
|
||||
l = Value::cons(Value::Int(i), l);
|
||||
}
|
||||
drop(l);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn printing_and_equality() {
|
||||
let l = Value::list(vec![Value::Int(1), Value::str("a\"b"), Value::cons(Value::Int(8), Value::str("0"))]);
|
||||
assert_eq!(to_text(&l, true), "(1 \"a\\\"b\" (8 . \"0\"))");
|
||||
assert_eq!(to_text(&l, false), "(1 a\"b (8 . 0))");
|
||||
assert!(equal(&l, &l.clone(), 0.0));
|
||||
assert!(equal(&Value::Real(1.0), &Value::Real(1.0000001), 1e-3));
|
||||
assert!(!equal(&Value::Real(1.0), &Value::Real(1.1), 1e-3));
|
||||
assert!(eq(&Value::sym("a"), &Value::sym("A")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,568 @@
|
||||
//! The model behind the in-place MTEXT editor: MTEXT contents as plain text with a format per
|
||||
//! character and the properties of each paragraph, so a text box can show and change bold,
|
||||
//! italic, underline, font, height, colour, indents and tabs and hand back MTEXT contents.
|
||||
//!
|
||||
//! Codes the editor has no control for (stacked fractions `\S`, `\W`, `\T`, `\Q`, `\A`, column
|
||||
//! breaks `\N`, non-breaking spaces …) are kept as opaque *atoms*: one placeholder character in
|
||||
//! the text ([`ATOM_BASE`] + n) that [`Formatted::to_contents`] writes back as the original
|
||||
//! code, so opening and closing the editor never loses them.
|
||||
|
||||
use cadcraft_fonts::ParaProps;
|
||||
pub use cadcraft_fonts::{ListStyle, MTextColor, ParaAlign, TabKind};
|
||||
|
||||
/// Largest paragraph indent or tab stop, in drawing units.
|
||||
const MAX_INDENT: f64 = 1e6;
|
||||
|
||||
/// First placeholder character of the opaque codes (Unicode private use area).
|
||||
pub const ATOM_BASE: u32 = 0xE000;
|
||||
/// Most atoms one text keeps; further codes are dropped.
|
||||
const MAX_ATOMS: usize = 1000;
|
||||
/// Longest text the editor takes (characters).
|
||||
pub const MAX_CHARS: usize = 100_000;
|
||||
/// Most paragraphs.
|
||||
const MAX_PARAS: usize = 20_000;
|
||||
|
||||
/// Formatting of one character.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct CharFmt {
|
||||
pub bold: bool,
|
||||
pub italic: bool,
|
||||
pub underline: bool,
|
||||
pub overline: bool,
|
||||
/// Font family set inline (`\f`); `None`: the text style's.
|
||||
pub font: Option<String>,
|
||||
/// Absolute character height set inline (`\H`); `None`: the text's.
|
||||
pub height: Option<f64>,
|
||||
/// Colour set inline (`\C`, `\c`); `None`: the entity's (ByLayer/ByBlock).
|
||||
pub color: Option<MTextColor>,
|
||||
}
|
||||
|
||||
/// A character format switch of the toolbar.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Flag {
|
||||
Bold,
|
||||
Italic,
|
||||
Underline,
|
||||
Overline,
|
||||
}
|
||||
|
||||
impl CharFmt {
|
||||
fn flag(&self, f: Flag) -> bool {
|
||||
match f {
|
||||
Flag::Bold => self.bold,
|
||||
Flag::Italic => self.italic,
|
||||
Flag::Underline => self.underline,
|
||||
Flag::Overline => self.overline,
|
||||
}
|
||||
}
|
||||
|
||||
fn set_flag(&mut self, f: Flag, on: bool) {
|
||||
match f {
|
||||
Flag::Bold => self.bold = on,
|
||||
Flag::Italic => self.italic = on,
|
||||
Flag::Underline => self.underline = on,
|
||||
Flag::Overline => self.overline = on,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// MTEXT contents as editable text.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Formatted {
|
||||
/// The text, paragraphs separated by `\n`; atoms are placeholders.
|
||||
text: String,
|
||||
/// One format per character of `text`.
|
||||
fmt: Vec<CharFmt>,
|
||||
/// One per paragraph (`\n` count + 1).
|
||||
paras: Vec<ParaProps>,
|
||||
/// The original code of each atom.
|
||||
atoms: Vec<String>,
|
||||
/// The font family bold and italic runs are written with when no font was set inline
|
||||
/// (`\fFamily|b1;`): the text style's.
|
||||
pub base_font: String,
|
||||
}
|
||||
|
||||
impl Default for Formatted {
|
||||
fn default() -> Self {
|
||||
Formatted { text: String::new(), fmt: Vec::new(), paras: vec![ParaProps::default()], atoms: Vec::new(), base_font: "Arial".into() }
|
||||
}
|
||||
}
|
||||
|
||||
fn arg_end(chars: &[char], from: usize) -> usize {
|
||||
let mut j = from;
|
||||
while chars.get(j).is_some_and(|c| *c != ';') {
|
||||
j += 1;
|
||||
}
|
||||
j
|
||||
}
|
||||
|
||||
impl Formatted {
|
||||
/// Parse MTEXT contents. Never fails: what isn't understood is kept as atoms or dropped.
|
||||
pub fn from_contents(contents: &str) -> Formatted {
|
||||
let mut f = Formatted::default();
|
||||
let chars: Vec<char> = contents.chars().collect();
|
||||
let mut stack: Vec<CharFmt> = Vec::new();
|
||||
let mut cur = CharFmt::default();
|
||||
let mut props = ParaProps::default();
|
||||
f.paras = vec![props.clone()];
|
||||
let mut i = 0;
|
||||
while let Some(&c) = chars.get(i) {
|
||||
if f.fmt.len() >= MAX_CHARS {
|
||||
break;
|
||||
}
|
||||
match c {
|
||||
'{' => {
|
||||
if stack.len() < 64 {
|
||||
stack.push(cur.clone());
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
'}' => {
|
||||
if let Some(p) = stack.pop() {
|
||||
cur = p;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
'\r' => i += 1,
|
||||
'\n' => {
|
||||
if f.paras.len() < MAX_PARAS {
|
||||
f.push_char('\n', &cur);
|
||||
f.paras.push(props.clone());
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
'\\' => {
|
||||
let code = chars.get(i + 1).copied();
|
||||
i += 2;
|
||||
match code {
|
||||
None => {}
|
||||
Some('P') => {
|
||||
if f.paras.len() < MAX_PARAS {
|
||||
f.push_char('\n', &cur);
|
||||
f.paras.push(props.clone());
|
||||
}
|
||||
}
|
||||
Some(c @ ('\\' | '{' | '}')) => f.push_char(c, &cur),
|
||||
Some('L') => cur.underline = true,
|
||||
Some('l') => cur.underline = false,
|
||||
Some('O') => cur.overline = true,
|
||||
Some('o') => cur.overline = false,
|
||||
Some(k @ ('f' | 'F' | 'H' | 'C' | 'c' | 'p')) => {
|
||||
let end = arg_end(&chars, i);
|
||||
let a: String = chars.get(i..end).unwrap_or_default().iter().collect();
|
||||
i = end + 1;
|
||||
match k {
|
||||
'f' | 'F' => apply_font(&mut cur, &a),
|
||||
'H' => cur.height = parse_height(&a, cur.height),
|
||||
'C' => {
|
||||
cur.color = a.trim().parse::<u16>().ok().and_then(|v| (v > 0 && v < 256).then_some(MTextColor::Aci(v)));
|
||||
}
|
||||
'c' => {
|
||||
if let Ok(v) = a.trim().parse::<u32>() {
|
||||
cur.color = Some(MTextColor::Rgb(v & 0xFF_FFFF));
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
props.apply(&a);
|
||||
if let Some(p) = f.paras.last_mut() {
|
||||
*p = props.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(k) => {
|
||||
// Any other code is kept verbatim: the ones with an argument up to
|
||||
// their `;`, the others alone.
|
||||
let mut code = format!("\\{k}");
|
||||
if !matches!(k, '~' | 'K' | 'k' | 'N') {
|
||||
let end = arg_end(&chars, i);
|
||||
code.extend(chars.get(i..=end.min(chars.len().saturating_sub(1))).unwrap_or_default().iter());
|
||||
i = end + 1;
|
||||
}
|
||||
f.push_atom(code, &cur);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
f.push_char(c, &cur);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
f
|
||||
}
|
||||
|
||||
fn push_char(&mut self, c: char, fmt: &CharFmt) {
|
||||
self.text.push(c);
|
||||
self.fmt.push(fmt.clone());
|
||||
}
|
||||
|
||||
fn push_atom(&mut self, code: String, fmt: &CharFmt) {
|
||||
if self.atoms.len() >= MAX_ATOMS {
|
||||
return;
|
||||
}
|
||||
let ch = char::from_u32(ATOM_BASE + self.atoms.len() as u32).unwrap_or('\u{FFFD}');
|
||||
self.atoms.push(code);
|
||||
self.push_char(ch, fmt);
|
||||
}
|
||||
|
||||
/// The text as the editor shows it.
|
||||
pub fn text(&self) -> &str {
|
||||
&self.text
|
||||
}
|
||||
|
||||
/// Number of characters.
|
||||
pub fn len(&self) -> usize {
|
||||
self.fmt.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.fmt.is_empty()
|
||||
}
|
||||
|
||||
/// Number of paragraphs.
|
||||
pub fn paragraph_count(&self) -> usize {
|
||||
self.paras.len()
|
||||
}
|
||||
|
||||
/// Format of character `i` (the last one past the end, the default for no text).
|
||||
pub fn fmt_at(&self, i: usize) -> CharFmt {
|
||||
self.fmt.get(i).or(self.fmt.last()).cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The paragraph character `i` is in.
|
||||
pub fn paragraph_of(&self, i: usize) -> usize {
|
||||
self.text.chars().take(i).filter(|c| *c == '\n').count().min(self.paras.len().saturating_sub(1))
|
||||
}
|
||||
|
||||
/// Properties of paragraph `p`.
|
||||
pub fn para(&self, p: usize) -> ParaProps {
|
||||
self.paras.get(p).or(self.paras.last()).cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The edited text replaced by `new` (what a text box hands back after a keystroke): the
|
||||
/// characters that stay keep their format, new ones take `typing`, and paragraphs follow
|
||||
/// the line breaks added and removed. Text beyond [`MAX_CHARS`] is refused (`false`).
|
||||
pub fn set_text(&mut self, new: &str, typing: &CharFmt) -> bool {
|
||||
if new == self.text {
|
||||
return true;
|
||||
}
|
||||
let old: Vec<char> = self.text.chars().collect();
|
||||
let new_c: Vec<char> = new.chars().collect();
|
||||
if new_c.len() > MAX_CHARS || new_c.iter().filter(|c| **c == '\n').count() >= MAX_PARAS {
|
||||
return false;
|
||||
}
|
||||
let pre = old.iter().zip(&new_c).take_while(|(a, b)| a == b).count();
|
||||
let suf = old.iter().rev().zip(new_c.iter().rev()).take(old.len().min(new_c.len()) - pre).take_while(|(a, b)| a == b).count();
|
||||
let removed = old.get(pre..old.len() - suf).unwrap_or_default();
|
||||
let inserted = new_c.get(pre..new_c.len() - suf).unwrap_or_default();
|
||||
// Typing after a character takes its format unless the toolbar changed it.
|
||||
let first_para = old.iter().take(pre).filter(|c| **c == '\n').count();
|
||||
let gone = removed.iter().filter(|c| **c == '\n').count();
|
||||
let added = inserted.iter().filter(|c| **c == '\n').count();
|
||||
let end = (first_para + 1 + gone).min(self.paras.len());
|
||||
self.paras.drain((first_para + 1).min(end)..end);
|
||||
let template = self.para(first_para);
|
||||
for _ in 0..added {
|
||||
self.paras.insert((first_para + 1).min(self.paras.len()), template.clone());
|
||||
}
|
||||
let ins: Vec<CharFmt> = inserted.iter().map(|_| typing.clone()).collect();
|
||||
self.fmt.splice(pre..old.len() - suf, ins);
|
||||
self.text = new.to_string();
|
||||
if self.paras.is_empty() {
|
||||
self.paras.push(ParaProps::default());
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn clamp(&self, r: std::ops::Range<usize>) -> std::ops::Range<usize> {
|
||||
r.start.min(self.fmt.len())..r.end.min(self.fmt.len())
|
||||
}
|
||||
|
||||
/// Apply `f` to the format of the characters in `range`.
|
||||
pub fn edit(&mut self, range: std::ops::Range<usize>, f: impl Fn(&mut CharFmt)) {
|
||||
let r = self.clamp(range);
|
||||
if let Some(s) = self.fmt.get_mut(r) {
|
||||
s.iter_mut().for_each(f);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether every character of `range` has `flag`.
|
||||
pub fn all_have(&self, range: std::ops::Range<usize>, flag: Flag) -> bool {
|
||||
let r = self.clamp(range);
|
||||
!r.is_empty() && self.fmt.get(r).is_some_and(|s| s.iter().all(|c| c.flag(flag)))
|
||||
}
|
||||
|
||||
/// Toggle `flag` on `range`: set unless every character has it already.
|
||||
pub fn toggle(&mut self, range: std::ops::Range<usize>, flag: Flag) {
|
||||
let on = !self.all_have(range.clone(), flag);
|
||||
self.edit(range, |c| c.set_flag(flag, on));
|
||||
}
|
||||
|
||||
/// The paragraphs `range` touches (a caret touches the one it is in).
|
||||
fn paras_of(&self, range: std::ops::Range<usize>) -> std::ops::Range<usize> {
|
||||
let a = self.paragraph_of(range.start);
|
||||
let b = self.paragraph_of(range.end.max(range.start));
|
||||
a..b + 1
|
||||
}
|
||||
|
||||
/// Change the properties of the paragraphs `range` touches.
|
||||
pub fn edit_paras(&mut self, range: std::ops::Range<usize>, f: impl Fn(&mut ParaProps)) {
|
||||
let ps = self.paras_of(range);
|
||||
if let Some(s) = self.paras.get_mut(ps) {
|
||||
s.iter_mut().for_each(f);
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the indents (in drawing units, as `\p` codes have them) of the paragraphs `range` touches: `first` is relative
|
||||
/// to `left`.
|
||||
pub fn set_indents(&mut self, range: std::ops::Range<usize>, first: f64, left: f64) {
|
||||
if first.is_finite() && left.is_finite() {
|
||||
let left = left.clamp(0.0, MAX_INDENT);
|
||||
self.edit_paras(range, |p| {
|
||||
p.first = first.clamp(-left, MAX_INDENT);
|
||||
p.left = left;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a tab stop at `pos` drawing units to the paragraphs `range` touches.
|
||||
pub fn add_tab(&mut self, range: std::ops::Range<usize>, pos: f64, kind: TabKind) {
|
||||
if !pos.is_finite() || !(0.0..=MAX_INDENT).contains(&pos) {
|
||||
return;
|
||||
}
|
||||
self.edit_paras(range, |p| {
|
||||
if p.tabs.len() < 64 && !p.tabs.iter().any(|(t, _)| (t - pos).abs() < 1e-6) {
|
||||
let at = p.tabs.partition_point(|(t, _)| *t < pos);
|
||||
p.tabs.insert(at, (pos, kind));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Remove the tab stop nearest `pos` (within `tolerance`) from the paragraphs `range` touches.
|
||||
pub fn remove_tab(&mut self, range: std::ops::Range<usize>, pos: f64, tolerance: f64) {
|
||||
self.edit_paras(range, |p| {
|
||||
if let Some(i) = p.tabs.iter().position(|(t, _)| (t - pos).abs() <= tolerance) {
|
||||
p.tabs.remove(i);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Make the paragraphs `range` touches a list: a hanging indent of `indent` drawing units
|
||||
/// with a tab stop there, and each paragraph a label and a tab.
|
||||
pub fn make_list(&mut self, range: std::ops::Range<usize>, style: ListStyle, indent: f64) {
|
||||
let indent = if indent.is_finite() && indent > 0.0 { indent.min(MAX_INDENT) } else { 3.0 };
|
||||
let ps = self.paras_of(range);
|
||||
let lines: Vec<&str> = self.text.split('\n').collect();
|
||||
let mut out = String::new();
|
||||
let mut fmts: Vec<CharFmt> = Vec::new();
|
||||
let mut at = 0usize;
|
||||
for (k, line) in lines.iter().enumerate() {
|
||||
let n = line.chars().count();
|
||||
if k > 0 {
|
||||
out.push('\n');
|
||||
fmts.push(self.fmt.get(at).cloned().unwrap_or_default());
|
||||
at += 1;
|
||||
}
|
||||
if ps.contains(&k) {
|
||||
let label = format!("{}\t", style.label(k - ps.start));
|
||||
let f = self.fmt.get(at).cloned().unwrap_or_else(|| self.fmt_at(at));
|
||||
label.chars().for_each(|_| fmts.push(f.clone()));
|
||||
out.push_str(&label);
|
||||
}
|
||||
out.push_str(line);
|
||||
fmts.extend(self.fmt.get(at..at + n).unwrap_or_default().iter().cloned());
|
||||
at += n;
|
||||
}
|
||||
if fmts.len() > MAX_CHARS {
|
||||
return;
|
||||
}
|
||||
self.text = out;
|
||||
self.fmt = fmts;
|
||||
let props = ParaProps { first: -indent, left: indent, tabs: vec![(indent, TabKind::Left)], ..ParaProps::default() };
|
||||
for p in ps {
|
||||
if let Some(q) = self.paras.get_mut(p) {
|
||||
*q = props.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The MTEXT contents.
|
||||
pub fn to_contents(&self) -> String {
|
||||
let mut out = String::new();
|
||||
let mut emitted = ParaProps::default();
|
||||
let chars: Vec<char> = self.text.chars().collect();
|
||||
let mut i = 0;
|
||||
for (pi, props) in self.paras.iter().enumerate() {
|
||||
if pi > 0 {
|
||||
out.push_str("\\P");
|
||||
}
|
||||
if *props != emitted {
|
||||
out.push_str(&props.code());
|
||||
emitted = props.clone();
|
||||
}
|
||||
let end = chars.iter().skip(i).position(|c| *c == '\n').map_or(chars.len(), |n| i + n);
|
||||
while i < end {
|
||||
let f = self.fmt.get(i).cloned().unwrap_or_default();
|
||||
let mut j = i + 1;
|
||||
while j < end && self.fmt.get(j) == Some(&f) {
|
||||
j += 1;
|
||||
}
|
||||
let run: String = chars.get(i..j).unwrap_or_default().iter().map(|c| self.escape(*c)).collect();
|
||||
if f == CharFmt::default() {
|
||||
out.push_str(&run);
|
||||
} else {
|
||||
out.push('{');
|
||||
out.push_str(&self.codes(&f));
|
||||
out.push_str(&run);
|
||||
out.push('}');
|
||||
}
|
||||
i = j;
|
||||
}
|
||||
i = end + 1;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn escape(&self, c: char) -> String {
|
||||
match c {
|
||||
'\\' | '{' | '}' => format!("\\{c}"),
|
||||
c if (c as u32) >= ATOM_BASE && (c as u32) < ATOM_BASE + MAX_ATOMS as u32 => {
|
||||
self.atoms.get((c as u32 - ATOM_BASE) as usize).cloned().unwrap_or_default()
|
||||
}
|
||||
c => c.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn codes(&self, f: &CharFmt) -> String {
|
||||
let mut s = String::new();
|
||||
if f.underline {
|
||||
s.push_str("\\L");
|
||||
}
|
||||
if f.overline {
|
||||
s.push_str("\\O");
|
||||
}
|
||||
if f.bold || f.italic || f.font.is_some() {
|
||||
let fam = f.font.clone().unwrap_or_else(|| self.base_font.clone());
|
||||
let fam: String = fam.chars().filter(|c| !matches!(c, ';' | '|' | '{' | '}' | '\\')).collect();
|
||||
s.push_str(&format!("\\f{fam}|b{}|i{};", u8::from(f.bold), u8::from(f.italic)));
|
||||
}
|
||||
if let Some(h) = f.height {
|
||||
s.push_str(&format!("\\H{};", trim(h)));
|
||||
}
|
||||
match f.color {
|
||||
Some(MTextColor::Aci(v)) => s.push_str(&format!("\\C{v};")),
|
||||
Some(MTextColor::Rgb(v)) => s.push_str(&format!("\\c{v};")),
|
||||
None => {}
|
||||
}
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
fn trim(v: f64) -> String {
|
||||
let s = format!("{v:.4}");
|
||||
s.trim_end_matches('0').trim_end_matches('.').to_string()
|
||||
}
|
||||
|
||||
fn parse_height(a: &str, cur: Option<f64>) -> Option<f64> {
|
||||
let a = a.trim();
|
||||
match a.strip_suffix(['x', 'X']) {
|
||||
// A relative height needs the current one; without it the code is dropped.
|
||||
Some(r) => r.trim().parse::<f64>().ok().zip(cur).map(|(m, h)| h * m).filter(|h| h.is_finite() && *h > 0.0 && *h < 1e6),
|
||||
None => a.parse::<f64>().ok().filter(|h| h.is_finite() && *h > 0.0 && *h < 1e6).or(cur),
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_font(cur: &mut CharFmt, a: &str) {
|
||||
let mut parts = a.split('|');
|
||||
let name = parts.next().unwrap_or("").trim();
|
||||
if name.is_empty() {
|
||||
return;
|
||||
}
|
||||
cur.font = Some(name.to_string());
|
||||
cur.bold = false;
|
||||
cur.italic = false;
|
||||
for p in parts {
|
||||
match p.as_bytes() {
|
||||
[b'b', v] => cur.bold = *v == b'1',
|
||||
[b'i', v] => cur.italic = *v == b'1',
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn round_trips_formats_and_atoms() {
|
||||
for src in [
|
||||
"plain",
|
||||
"a\\Pb",
|
||||
"{\\fArial|b1|i0;bold} and {\\Lunder}",
|
||||
"x \\S1/2; y",
|
||||
"{\\H2;big}{\\C1;red}{\\c16711680;rgb}",
|
||||
"\\pxi-3,l3,t3;1.\tone\\P2.\ttwo",
|
||||
"esc \\{ \\} \\\\",
|
||||
] {
|
||||
let f = Formatted::from_contents(src);
|
||||
let again = Formatted::from_contents(&f.to_contents());
|
||||
assert_eq!(f, again, "{src}");
|
||||
}
|
||||
assert_eq!(Formatted::from_contents("plain").to_contents(), "plain");
|
||||
assert!(Formatted::from_contents("x \\S1/2; y").to_contents().contains("\\S1/2;"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggles_and_typing_keep_formats() {
|
||||
let mut f = Formatted::from_contents("hello world");
|
||||
f.toggle(0..5, Flag::Bold);
|
||||
assert_eq!(f.to_contents(), "{\\fArial|b1|i0;hello} world");
|
||||
assert!(f.all_have(0..5, Flag::Bold));
|
||||
// Typing inside the bold run takes the typing format; paragraphs follow line breaks.
|
||||
let typing = f.fmt_at(2);
|
||||
assert!(f.set_text("hello big world", &CharFmt::default()));
|
||||
assert!(f.fmt_at(6) == CharFmt::default() && typing.bold);
|
||||
assert!(f.set_text("hello big\nworld", &CharFmt::default()));
|
||||
assert_eq!(f.paragraph_count(), 2);
|
||||
assert_eq!(f.to_contents(), "{\\fArial|b1|i0;hello} big\\Pworld");
|
||||
f.toggle(0..5, Flag::Bold);
|
||||
assert_eq!(f.to_contents(), "hello big\\Pworld");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ruler_and_lists() {
|
||||
let mut f = Formatted::from_contents("one\ntwo");
|
||||
f.set_indents(0..0, -2.0, 4.0);
|
||||
f.add_tab(0..3, 4.0, TabKind::Left);
|
||||
let c = f.to_contents();
|
||||
assert!(c.starts_with("\\pxi-2,l4,t4;one"), "{c}");
|
||||
f.make_list(0..f.len(), ListStyle::Number, 3.0);
|
||||
assert_eq!(f.text(), "1.\tone\n2.\ttwo");
|
||||
assert_eq!(f.to_contents(), "\\pxi-3,l3,t3;1.\tone\\P2.\ttwo");
|
||||
f.remove_tab(0..f.len(), 3.0, 0.1);
|
||||
assert!(!f.to_contents().contains("t3"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_input_never_panics() {
|
||||
for src in
|
||||
["\\", "{{{{", "}}}}", "\\f", "\\fArial|b", "\\H;\\H2x;\\Hnan;", "\\S", "\\p;\\pq;\\pt,,,;", "\\C999999999999;", "\u{E000}\u{E001}"]
|
||||
{
|
||||
let f = Formatted::from_contents(src);
|
||||
let _ = f.to_contents();
|
||||
}
|
||||
let mut f = Formatted::from_contents("ab");
|
||||
let (a, b) = (5, 1);
|
||||
f.edit(a..b, |c| c.bold = true);
|
||||
f.toggle(9..9, Flag::Italic);
|
||||
f.set_indents(7..9, f64::NAN, 1.0);
|
||||
f.add_tab(0..99, f64::INFINITY, TabKind::Left);
|
||||
f.make_list(50..60, ListStyle::Bullet, f64::NAN);
|
||||
assert!(!f.set_text(&"x".repeat(MAX_CHARS + 1), &CharFmt::default()));
|
||||
let _ = f.paragraph_of(usize::MAX);
|
||||
let _ = f.fmt_at(usize::MAX);
|
||||
let _ = Formatted::from_contents(&"{".repeat(100_000));
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,10 @@ pub struct Prompt {
|
||||
/// prompt (a point with no base yet, e.g. the first point of a line). Elsewhere a deferred
|
||||
/// pick arrives as the plain point it was picked at.
|
||||
pub deferred: bool,
|
||||
/// The base is a window's first corner (ZOOM Window): ortho does not bend the opposite
|
||||
/// corner, so the window stays a rectangle.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub corner: bool,
|
||||
/// The grip this prompt stretches (one hot grip at the STRETCH prompt), as (object, grip
|
||||
/// index): Dynamic Input shows its dimensions ([`crate::grip_dims`]).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -49,6 +53,20 @@ pub struct Prompt {
|
||||
/// [`Input::Text`].
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub picker: Option<Picker>,
|
||||
/// Shown as written, without keywords, a default or a colon added: an AutoLISP `get…`
|
||||
/// prompt (the routine writes all of it).
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub raw: bool,
|
||||
}
|
||||
|
||||
/// Parse `@distance<angle` (relative polar), returning `(distance, angle in radians)`.
|
||||
/// Returns `None` for any other form.
|
||||
pub fn parse_polar_text(text: &str, angles: &crate::units::AngleSettings) -> Option<(f64, f64)> {
|
||||
let body = text.trim().strip_prefix('@')?; // relative form only
|
||||
let (d, a) = body.split_once('<')?;
|
||||
let dist = d.trim().parse::<f64>().ok()?;
|
||||
let angle = angles.amount(a.trim())?;
|
||||
(dist.is_finite() && angle.is_finite()).then_some((dist, angle))
|
||||
}
|
||||
|
||||
/// A visual chooser for a prompt ([`Prompt::picker`]).
|
||||
@@ -61,7 +79,7 @@ pub enum Picker {
|
||||
|
||||
impl Prompt {
|
||||
pub fn new(message: impl Into<String>, accept: Accept) -> Self {
|
||||
Prompt { message: message.into(), keywords: Vec::new(), default: None, accept, base: None, deferred: false, grip: None, picker: None }
|
||||
Prompt { message: message.into(), accept, ..Default::default() }
|
||||
}
|
||||
/// The grip being stretched (the `grip` field).
|
||||
pub fn grip(mut self, g: (Handle, usize)) -> Self {
|
||||
@@ -85,6 +103,12 @@ impl Prompt {
|
||||
self.base = p;
|
||||
self
|
||||
}
|
||||
/// The base is a window's first corner (the `corner` field).
|
||||
pub fn corner(mut self, p: Vec2) -> Self {
|
||||
self.base = Some(p);
|
||||
self.corner = true;
|
||||
self
|
||||
}
|
||||
/// Accept deferred tangent/perpendicular snaps (the `deferred` field).
|
||||
pub fn deferred(mut self) -> Self {
|
||||
self.deferred = true;
|
||||
@@ -102,6 +126,9 @@ impl Prompt {
|
||||
}
|
||||
/// "Specify next point or [Undo/Close] <default>:".
|
||||
pub fn display(&self) -> String {
|
||||
if self.raw {
|
||||
return self.message.trim_matches(['\n', '\r']).to_string();
|
||||
}
|
||||
let mut s = self.message.clone();
|
||||
if !self.keywords.is_empty() {
|
||||
// A question offers its answers ("Fix any errors detected? [Yes/No]"); anything else
|
||||
@@ -188,6 +215,11 @@ pub trait Interactive: Send {
|
||||
fn drag(&self, _s: &Session, _cursor: Vec2) -> Option<Drag> {
|
||||
None
|
||||
}
|
||||
/// The multiline text being typed (MTEXT, once its box is known) with empty contents: where
|
||||
/// the UI places its in-place editor and with which height, width and justification.
|
||||
fn editing_mtext(&self) -> Option<cadcraft_doc::MText> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// See [`Interactive::drag`].
|
||||
|
||||
+121
-5
@@ -88,15 +88,59 @@ fn dist_to_polyline(pts: &[Vec2], p: Vec2) -> f64 {
|
||||
pub fn entity_distance(d: &Drawing, e: &Entity, p: Vec2, tol: f64) -> f64 {
|
||||
let polys = hit_polylines(d, e, tol);
|
||||
let mut best = polys.iter().map(|pl| dist_to_polyline(pl, p)).fold(f64::INFINITY, f64::min);
|
||||
let filled = matches!(e.kind, EntityKind::Text(_) | EntityKind::MText(_) | EntityKind::AttDef(_) | EntityKind::Solid(_) | EntityKind::Table(_))
|
||||
|| matches!(e.kind, EntityKind::Tolerance(_))
|
||||
|| matches!(&e.kind, EntityKind::Hatch(h) if h.solid);
|
||||
if filled && entity_bounds(d, e, 0).contains(p) {
|
||||
let filled = matches!(e.kind, EntityKind::Text(_) | EntityKind::MText(_) | EntityKind::AttDef(_) | EntityKind::Table(_))
|
||||
|| matches!(e.kind, EntityKind::Tolerance(_));
|
||||
if (filled && entity_bounds(d, e, 0).contains(p)) || in_text(d, e, p) {
|
||||
best = 0.0;
|
||||
}
|
||||
// A solid hatch fills its loops with odd parity under its island style, as it is drawn, not
|
||||
// its bounding rectangle.
|
||||
if let EntityKind::Hatch(h) = &e.kind
|
||||
&& h.solid
|
||||
&& cadcraft_render::hatch_fill_loops(h, tol).iter().filter(|l| point_in_polygon(l, p)).count() % 2 == 1
|
||||
{
|
||||
best = 0.0;
|
||||
}
|
||||
// A SOLID fills its polygon, not its bounding rectangle (half of which is empty for a triangle).
|
||||
if matches!(e.kind, EntityKind::Solid(_)) && e.kind.prims().iter().any(|prim| matches!(prim, Prim::Fill(poly) if point_in_polygon(poly, p))) {
|
||||
best = 0.0;
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
/// Whether `p` is on the text of a dimension or multileader: inside the box around its words,
|
||||
/// turned with the text, as plain text is hit anywhere inside its box. Their other geometry is
|
||||
/// hit along its lines, so without this the cursor found the object only between the letters'
|
||||
/// edges and the lines.
|
||||
fn in_text(d: &Drawing, e: &Entity, p: Vec2) -> bool {
|
||||
match &e.kind {
|
||||
// A dimension with a block (as AutoCAD saves them) is drawn from that block: its text is
|
||||
// the text there, not the text the dimension's own geometry would place.
|
||||
EntityKind::Dimension(dm) => match dm.block.as_ref().and_then(|n| d.block(n)) {
|
||||
Some(blk) => blk.entities.iter().any(|be| match &be.kind {
|
||||
EntityKind::MText(t) => in_box(cadcraft_render::layout_mtext_entity(d, t).strokes.into_iter().flatten(), t.rotation, p),
|
||||
EntityKind::Text(_) => entity_bounds(d, be, 0).contains(p),
|
||||
_ => false,
|
||||
}),
|
||||
None => {
|
||||
let g = cadcraft_render::dimension_in(d, dm);
|
||||
in_box(g.text.iter().flatten().chain(g.text_glyphs.iter().flatten().flatten()).copied(), g.text_angle, p)
|
||||
}
|
||||
},
|
||||
EntityKind::MLeader(m) => {
|
||||
m.text.as_ref().is_some_and(|t| in_box(cadcraft_render::layout_mtext_entity(d, t).strokes.into_iter().flatten(), t.rotation, p))
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `p` is inside the box around `pts` turned by `angle` (the box of text set at `angle`).
|
||||
fn in_box(pts: impl Iterator<Item = Vec2>, angle: f64, p: Vec2) -> bool {
|
||||
let turn = |q: Vec2| q.rotate(-angle);
|
||||
let b = Bounds2::from_points(pts.filter(|q| q.is_finite()).map(turn));
|
||||
!b.is_empty() && b.contains(turn(p))
|
||||
}
|
||||
|
||||
fn selectable(d: &Drawing, e: &Entity) -> bool {
|
||||
d.is_visible(e)
|
||||
}
|
||||
@@ -165,6 +209,18 @@ pub(crate) fn pick_with(d: &Drawing, space: &Space, ix: &Option<std::sync::Arc<S
|
||||
best.map(|(_, h)| h)
|
||||
}
|
||||
|
||||
/// Whether PREVIEWFILTER keeps `e` from being highlighted under the cursor: bits 1 objects on
|
||||
/// locked layers, 2 xrefs, 4 tables, 8 multiline text, 16 hatches, 32 objects in groups.
|
||||
pub fn preview_filtered(d: &Drawing, e: &Entity, filter: u8) -> bool {
|
||||
let on = |bit: u8| filter & bit != 0;
|
||||
(on(1) && d.layer(&e.common.layer).is_some_and(|l| l.locked))
|
||||
|| (on(2) && matches!(&e.kind, EntityKind::Insert(i) if d.block(&i.block).is_some_and(|b| b.xref_path.is_some())))
|
||||
|| (on(4) && matches!(e.kind, EntityKind::Table(_)))
|
||||
|| (on(8) && matches!(e.kind, EntityKind::MText(_)))
|
||||
|| (on(16) && matches!(e.kind, EntityKind::Hatch(_)))
|
||||
|| (on(32) && d.groups.iter().any(|g| g.members.contains(&e.handle)))
|
||||
}
|
||||
|
||||
fn seg_hits_box(a: Vec2, b: Vec2, bx: &Bounds2) -> bool {
|
||||
if bx.contains(a) || bx.contains(b) {
|
||||
return true;
|
||||
@@ -325,7 +381,7 @@ pub enum SelectKind {
|
||||
}
|
||||
|
||||
/// Most points a fence or polygon collects (typed input is hostile).
|
||||
const MAX_MODE_POINTS: usize = 10_000;
|
||||
pub(crate) const MAX_MODE_POINTS: usize = 10_000;
|
||||
|
||||
/// A typed selection mode in progress and the points given so far.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -453,6 +509,19 @@ mod solid_interior_tests {
|
||||
let inside = Bounds2::new(Vec2::new(3.0, 3.0), Vec2::new(4.0, 4.0));
|
||||
assert!(in_window(&d, &e, None, &inside, true, 0.01));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pick_in_triangular_bbox_but_outside_fill_does_not_select() {
|
||||
let mut d = Drawing::default();
|
||||
d.model.push(face(true, true));
|
||||
let at = |x: f64, y: f64| pick(&d, &Space::Model, Vec2::new(x, y), 0.25);
|
||||
// The empty corner of the bounding rectangle, 5.66 from the hypotenuse.
|
||||
assert_eq!(at(9.0, 9.0), None);
|
||||
// Inside the fill, farther than the aperture from every edge.
|
||||
assert_eq!(at(2.0, 2.0), Some(Handle(256)));
|
||||
// Outside the fill but within the aperture of the hypotenuse.
|
||||
assert_eq!(at(5.1, 5.1), Some(Handle(256)));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -503,4 +572,51 @@ mod hatch_pattern_pick_tests {
|
||||
assert!(pick(&d, &Space::Model, Vec2::new(1.0, 1.0), 0.05).is_some());
|
||||
assert!(pick(&d, &Space::Model, Vec2::new(5.0, 5.0), 0.05).is_none());
|
||||
}
|
||||
|
||||
fn solid_hatch(loops: Vec<HatchLoop>, style: u8) -> Drawing {
|
||||
let mut d = Drawing::new_metric();
|
||||
let hatch = Hatch {
|
||||
pattern: "SOLID".into(),
|
||||
solid: true,
|
||||
loops,
|
||||
scale: 1.0,
|
||||
angle: 0.0,
|
||||
associative: false,
|
||||
style,
|
||||
elevation: 0.0,
|
||||
gradient: None,
|
||||
origin: Vec2::ZERO,
|
||||
background: None,
|
||||
pattern_lines: Vec::new(),
|
||||
};
|
||||
d.add(&Space::Model, Common::default(), EntityKind::Hatch(hatch)).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solid_hatch_is_picked_inside_its_fill_not_its_bounding_rectangle() {
|
||||
let tri = HatchLoop {
|
||||
vertices: [(0.0, 0.0), (10.0, 0.0), (0.0, 10.0)].iter().map(|&(x, y)| PolyVertex::new(Vec2::new(x, y))).collect(),
|
||||
outer: true,
|
||||
sources: Vec::new(),
|
||||
};
|
||||
let d = solid_hatch(vec![tri], 0);
|
||||
// The empty corner of the bounding rectangle, 5.66 from the hypotenuse.
|
||||
assert!(pick(&d, &Space::Model, Vec2::new(9.0, 9.0), 0.25).is_none());
|
||||
assert!(pick(&d, &Space::Model, Vec2::new(2.0, 2.0), 0.25).is_some());
|
||||
// Outside the fill but within the aperture of the hypotenuse.
|
||||
assert!(pick(&d, &Space::Model, Vec2::new(5.1, 5.1), 0.25).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solid_hatch_island_follows_the_island_style() {
|
||||
let loops = || vec![square(0.0, 10.0, true), square(4.0, 6.0, false)];
|
||||
// Normal style leaves the island empty, as it is drawn.
|
||||
let d = solid_hatch(loops(), 0);
|
||||
assert!(pick(&d, &Space::Model, Vec2::new(5.0, 5.0), 0.05).is_none());
|
||||
assert!(pick(&d, &Space::Model, Vec2::new(2.0, 2.0), 0.05).is_some());
|
||||
// Ignore style fills over the island.
|
||||
let d = solid_hatch(loops(), 2);
|
||||
assert!(pick(&d, &Space::Model, Vec2::new(5.0, 5.0), 0.05).is_some());
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ mod viewport;
|
||||
pub use viewport::ViewportPoint;
|
||||
|
||||
use cadcraft_doc::{Drawing, EntityKind, Insert, MAX_BLOCK_DEPTH, Prim, Space};
|
||||
use cadcraft_geom::{Bounds2, Circle, Ellipse, Mat3, PI, Segment, Vec2, intersect_ext};
|
||||
use cadcraft_geom::{Bounds2, Circle, EPS, Ellipse, Mat3, PI, Segment, Vec2, intersect_ext};
|
||||
use serde::Serialize;
|
||||
|
||||
/// OSMODE bits.
|
||||
@@ -96,6 +96,21 @@ pub struct Deferred {
|
||||
pub at: Vec2,
|
||||
}
|
||||
|
||||
/// The point on `curve` whose tangent has direction `dir`, picking the solution
|
||||
/// nearest `hint`. Used to resolve a deferred TAN first point when the next point
|
||||
/// is typed as `@distance<angle`.
|
||||
pub fn tangent_point_in_direction(curve: &SnapCurve, dir: f64, hint: Vec2) -> Option<Vec2> {
|
||||
match curve {
|
||||
SnapCurve::Circle { center, radius } => {
|
||||
let n = dir + std::f64::consts::FRAC_PI_2;
|
||||
let c1 = *center + Vec2::new(n.cos(), n.sin()) * *radius;
|
||||
let c2 = *center - Vec2::new(n.cos(), n.sin()) * *radius;
|
||||
Some(if c1.dist(hint) < c2.dist(hint) { c1 } else { c2 })
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// One end of a line being solved: a known point or a deferred snap.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum LineEnd {
|
||||
@@ -245,7 +260,14 @@ fn osnap_in_with_prims(
|
||||
// meet (lines extended, arcs as full circles: in 2D, the "apparent" crossing of objects
|
||||
// that don't reach each other).
|
||||
if osmode & (mode::INT | mode::APP) != 0 {
|
||||
let segs: Vec<Vec<Segment>> = near_prims.iter().take(200).map(prim_segments).collect();
|
||||
let mut segs: Vec<Vec<Segment>> = near_prims.iter().take(200).map(prim_segments).collect();
|
||||
let probe = finite_intersection_probe(cursor, aperture);
|
||||
for (i, segments) in segs.iter_mut().enumerate() {
|
||||
let preserves_app = osmode & mode::APP != 0 && near_prims.get(i).is_some_and(extendable);
|
||||
if !preserves_app {
|
||||
segments.retain(|segment| segment_may_intersect_probe(segment, probe.as_ref()));
|
||||
}
|
||||
}
|
||||
let real = if osmode & mode::INT != 0 { mode::INT } else { mode::APP };
|
||||
for i in 0..segs.len() {
|
||||
for j in i + 1..segs.len() {
|
||||
@@ -274,6 +296,40 @@ fn osnap_in_with_prims(
|
||||
Some((hit, near_prims))
|
||||
}
|
||||
|
||||
fn finite_intersection_probe(cursor: Vec2, aperture: f64) -> Option<Bounds2> {
|
||||
if !(cursor.is_finite() && aperture.is_finite() && aperture > 0.0) {
|
||||
return None;
|
||||
}
|
||||
let probe = Bounds2::new(cursor, cursor).expand(aperture);
|
||||
if !finite_bounds(&probe) {
|
||||
return None;
|
||||
}
|
||||
// The finite line intersection accepts a 1e-9 parameter slack; retain that slack in the broad phase.
|
||||
let scale = cursor.x.abs().max(cursor.y.abs()).max(aperture).max(1.0);
|
||||
let padded = probe.expand(EPS * scale);
|
||||
finite_bounds(&padded).then_some(padded)
|
||||
}
|
||||
|
||||
fn segment_may_intersect_probe(segment: &Segment, probe: Option<&Bounds2>) -> bool {
|
||||
let Some(probe) = probe else { return true };
|
||||
let bounds = segment.bounds();
|
||||
if !finite_bounds(&bounds) {
|
||||
return true;
|
||||
}
|
||||
let width = bounds.width();
|
||||
let height = bounds.height();
|
||||
if !(width.is_finite() && height.is_finite()) {
|
||||
return true;
|
||||
}
|
||||
let scale = bounds.min.x.abs().max(bounds.min.y.abs()).max(bounds.max.x.abs()).max(bounds.max.y.abs()).max(width).max(height).max(1.0);
|
||||
let padded = bounds.expand(EPS * scale);
|
||||
!finite_bounds(&padded) || padded.intersects(probe)
|
||||
}
|
||||
|
||||
fn finite_bounds(bounds: &Bounds2) -> bool {
|
||||
!bounds.is_empty() && bounds.min.is_finite() && bounds.max.is_finite()
|
||||
}
|
||||
|
||||
/// Whether a primitive has a meaningful extension (a line, an arc, a circle, an xline or ray).
|
||||
pub(crate) fn extendable(p: &Prim) -> bool {
|
||||
matches!(p, Prim::Seg(_) | Prim::Circle(_) | Prim::Infinite { .. })
|
||||
|
||||
@@ -762,6 +762,8 @@ pub struct CursorQuery {
|
||||
pub base: Option<Vec2>,
|
||||
/// The prompt resolves deferred tangent/perpendicular snaps.
|
||||
pub deferred: bool,
|
||||
/// The base is a window's first corner ([`crate::prompt::Prompt::corner`]): no ortho.
|
||||
pub corner: bool,
|
||||
/// The object snap aperture in world units (APERTURE pixels at the view scale).
|
||||
pub aperture: f64,
|
||||
/// How close, in world units, the cursor slides onto a tracking path.
|
||||
@@ -821,6 +823,7 @@ impl crate::Session {
|
||||
}
|
||||
if let Some(b) = q.base
|
||||
&& s.orthomode
|
||||
&& !q.corner
|
||||
{
|
||||
p = super::iso::ortho(&self.settings, b, p);
|
||||
}
|
||||
|
||||
@@ -73,6 +73,8 @@ pub struct SpatialIndex {
|
||||
dimscale: f64,
|
||||
/// Annotation scale factor and ANNOALLVISIBLE: annotative objects measure at that scale.
|
||||
anno: (u64, bool),
|
||||
/// Layers turned off or frozen: block contents on them take no room in their inserts.
|
||||
hidden_layers: Vec<String>,
|
||||
/// (draw index of the chunk's first entity, chunk), in draw order.
|
||||
chunks: Vec<(u32, Arc<ChunkIx>)>,
|
||||
/// Chunk serial → draw offset.
|
||||
@@ -88,6 +90,10 @@ fn anno_key(d: &Drawing) -> (u64, bool) {
|
||||
(d.current_anno_scale().factor().to_bits(), d.anno_all_visible())
|
||||
}
|
||||
|
||||
fn hidden_layers(d: &Drawing) -> impl Iterator<Item = &str> {
|
||||
d.layers.iter().filter(|l| !l.visible()).map(|l| l.name.as_str())
|
||||
}
|
||||
|
||||
impl SpatialIndex {
|
||||
/// Build an index over a store (no caching).
|
||||
pub fn build(d: &Drawing, store: &EntityStore) -> SpatialIndex {
|
||||
@@ -95,7 +101,7 @@ impl SpatialIndex {
|
||||
}
|
||||
|
||||
/// Build, reusing `prev` for chunks the two stores share (only when the bounds context —
|
||||
/// blocks, dimension styles, DIMSCALE — is the same).
|
||||
/// blocks, dimension styles, DIMSCALE, hidden layers — is the same).
|
||||
fn build_from(d: &Drawing, store: &EntityStore, prev: Option<&SpatialIndex>) -> SpatialIndex {
|
||||
let prev = prev.filter(|p| p.same_context(d));
|
||||
let old: std::collections::HashMap<usize, Arc<ChunkIx>> =
|
||||
@@ -146,6 +152,7 @@ impl SpatialIndex {
|
||||
dim_styles: d.dim_styles.clone(),
|
||||
dimscale: d.header.f64("DIMSCALE", 1.0),
|
||||
anno: anno_key(d),
|
||||
hidden_layers: hidden_layers(d).map(str::to_owned).collect(),
|
||||
chunks,
|
||||
offsets,
|
||||
tree,
|
||||
@@ -160,6 +167,7 @@ impl SpatialIndex {
|
||||
&& self.dimscale.to_bits() == d.header.f64("DIMSCALE", 1.0).to_bits()
|
||||
&& self.dim_styles == d.dim_styles
|
||||
&& self.anno == anno_key(d)
|
||||
&& self.hidden_layers.iter().map(String::as_str).eq(hidden_layers(d))
|
||||
}
|
||||
|
||||
fn matches(&self, d: &Drawing, store: &EntityStore) -> bool {
|
||||
@@ -464,6 +472,49 @@ mod tests {
|
||||
assert!(!Arc::ptr_eq(&before, &index(&d, &Space::Model).unwrap()));
|
||||
}
|
||||
|
||||
/// Hiding a layer used inside a block shrinks its inserts' bounds without touching the
|
||||
/// space or the block table: the index must not be reused (issue #716).
|
||||
#[test]
|
||||
fn index_follows_layer_visibility_inside_blocks() {
|
||||
let mut d = Drawing::new_imperial();
|
||||
let mut blk = (**drawing(1, 0).blocks.get("B").unwrap()).clone();
|
||||
let big = Common { layer: "HID".into(), ..Common::default() };
|
||||
blk.entities.push(Entity {
|
||||
handle: cadcraft_doc::Handle(2_000_000),
|
||||
common: big,
|
||||
kind: EntityKind::Circle(cadcraft_doc::Circle { center: v3(0.0, 0.0), radius: 50.0 }),
|
||||
});
|
||||
d.blocks.insert("B".into(), Arc::new(blk));
|
||||
d.ensure_layer("HID");
|
||||
for i in 0..MIN_INDEXED {
|
||||
let x = 1000.0 + i as f64;
|
||||
d.add(&Space::Model, Common::default(), EntityKind::Line(cadcraft_doc::Line { a: v3(x, 0.0), b: v3(x, 1.0) })).unwrap();
|
||||
}
|
||||
let insert = Insert {
|
||||
block: "B".into(),
|
||||
insert: v3(0.0, 0.0),
|
||||
scale: Vec3::new(1.0, 1.0, 1.0),
|
||||
rotation: 0.0,
|
||||
attribs: Vec::new(),
|
||||
cols: 1,
|
||||
rows: 1,
|
||||
col_spacing: 0.0,
|
||||
row_spacing: 0.0,
|
||||
};
|
||||
let h = d.add(&Space::Model, Common::default(), EntityKind::Insert(insert)).unwrap();
|
||||
let small = Bounds2::new(Vec2::new(-2.0, -2.0), Vec2::new(2.0, 2.0));
|
||||
let shown = SpatialIndex::build(&d, &d.model);
|
||||
assert!(select::select_window(&d, &Space::Model, small, false).is_empty());
|
||||
|
||||
d.layer_mut("HID").unwrap().on = false;
|
||||
assert!(!shown.matches(&d, &d.model));
|
||||
let reused = SpatialIndex::build_from(&d, &d.model, Some(&shown));
|
||||
let fresh = SpatialIndex::build(&d, &d.model);
|
||||
let last = u32::try_from(MIN_INDEXED).unwrap();
|
||||
assert_eq!(reused.bounds(last), fresh.bounds(last));
|
||||
assert_eq!(select::select_window(&d, &Space::Model, small, false), vec![h]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_spaces_are_scanned() {
|
||||
let d = drawing(1, MIN_INDEXED - 1);
|
||||
|
||||
@@ -34,12 +34,25 @@ const SESSION_VARS: &[&str] = &[
|
||||
"DYNPICOORDS",
|
||||
"DYNPROMPT",
|
||||
"LWDISPLAY",
|
||||
"PLOTTRANSPARENCYOVERRIDE",
|
||||
"SELECTSIMILARMODE",
|
||||
"OBJECTISOLATIONMODE",
|
||||
"QPMODE",
|
||||
"HIGHLIGHT",
|
||||
"SELECTIONEFFECT",
|
||||
"SELECTIONPREVIEW",
|
||||
"PREVIEWEFFECT",
|
||||
"PREVIEWFILTER",
|
||||
"GRIPS",
|
||||
"GRIPOBJLIMIT",
|
||||
"SELECTIONAREA",
|
||||
"WINDOWAREACOLOR",
|
||||
"CROSSINGAREACOLOR",
|
||||
"SELECTIONAREAOPACITY",
|
||||
"PICKBOX",
|
||||
"APERTURE",
|
||||
"PICKFIRST",
|
||||
"ACADLSPASDOC",
|
||||
"PICKADD",
|
||||
"PICKSTYLE",
|
||||
"GRIPSIZE",
|
||||
@@ -88,13 +101,26 @@ pub fn get(s: &Session, name: &str) -> Option<Value> {
|
||||
"DYNPICOORDS" => json!(i32::from(st.dynpi_absolute)),
|
||||
"DYNPROMPT" => json!(i32::from(st.dynprompt)),
|
||||
"LWDISPLAY" => json!(i32::from(st.lwdisplay)),
|
||||
"PLOTTRANSPARENCYOVERRIDE" => json!(st.plot_transparency_override),
|
||||
"SELECTSIMILARMODE" => json!(st.selectsimilarmode),
|
||||
// Isolation is temporary: hidden objects show again when the drawing is reopened.
|
||||
"OBJECTISOLATIONMODE" => json!(0),
|
||||
"QPMODE" => json!(i32::from(st.qpmode)),
|
||||
"HIGHLIGHT" => json!(i32::from(st.highlight)),
|
||||
"SELECTIONEFFECT" => json!(st.selectioneffect),
|
||||
"SELECTIONPREVIEW" => json!(st.selectionpreview),
|
||||
"PREVIEWEFFECT" => json!(st.previeweffect),
|
||||
"PREVIEWFILTER" => json!(st.previewfilter),
|
||||
"GRIPS" => json!(st.grips),
|
||||
"GRIPOBJLIMIT" => json!(st.gripobjlimit),
|
||||
"SELECTIONAREA" => json!(i32::from(st.selectionarea)),
|
||||
"WINDOWAREACOLOR" => json!(st.windowareacolor),
|
||||
"CROSSINGAREACOLOR" => json!(st.crossingareacolor),
|
||||
"SELECTIONAREAOPACITY" => json!(st.selectionareaopacity),
|
||||
"PICKBOX" => json!(st.pickbox),
|
||||
"APERTURE" => json!(st.aperture),
|
||||
"PICKFIRST" => json!(i32::from(st.pickfirst)),
|
||||
"ACADLSPASDOC" => json!(i32::from(st.acadlspasdoc)),
|
||||
"PICKADD" => json!(i32::from(st.pickadd)),
|
||||
"PICKSTYLE" => json!(st.pickstyle),
|
||||
"GRIPSIZE" => json!(st.gripsize),
|
||||
@@ -114,16 +140,10 @@ pub fn get(s: &Session, name: &str) -> Option<Value> {
|
||||
"CMDNAMES" => json!(s.running.as_ref().map(|r| r.id.to_ascii_uppercase()).unwrap_or_default()),
|
||||
"DWGNAME" => json!(s.state().map(|d| d.title.clone()).unwrap_or_default()),
|
||||
"DBMOD" => json!(s.state().map(|d| i32::from(d.is_dirty())).unwrap_or(0)),
|
||||
"BLOCKEDITOR" => json!(i32::from(s.state().is_ok_and(|d| d.block_edit.is_some()))),
|
||||
// The template NEW/QNEW start from (a profile setting, like FONTALT; "" = none).
|
||||
"QNEWTEMPLATE" => json!(crate::cmd::file::qnew_template()),
|
||||
"CTAB" => json!(
|
||||
s.state()
|
||||
.map(|d| match &d.space {
|
||||
cadcraft_doc::Space::Model => "Model".to_string(),
|
||||
cadcraft_doc::Space::Paper(n) => n.clone(),
|
||||
})
|
||||
.unwrap_or_default()
|
||||
),
|
||||
"CTAB" => json!(s.state().map(|d| d.space.tab_name().to_string()).unwrap_or_default()),
|
||||
// UNDO state of the drawing: Control/Auto/group bits and the number of marks.
|
||||
"UNDOCTL" => json!(s.state().ok()?.undo_ctl.bits()),
|
||||
"UNDOMARKS" => json!(s.state().ok()?.undo_marks()),
|
||||
@@ -164,6 +184,10 @@ fn as_bool(v: &Value) -> Option<bool> {
|
||||
fn as_i(v: &Value) -> Option<i64> {
|
||||
v.as_i64().or_else(|| v.as_bool().map(i64::from)).or_else(|| v.as_str().and_then(|s| s.trim().parse::<i64>().ok()))
|
||||
}
|
||||
/// A whole number from 0 to `max` (a mode or a small bitcode).
|
||||
fn small(v: &Value, max: u8) -> Option<u8> {
|
||||
as_i(v).filter(|n| (0..=i64::from(max)).contains(n)).and_then(|n| u8::try_from(n).ok())
|
||||
}
|
||||
fn as_f64(v: &Value) -> Option<f64> {
|
||||
v.as_f64().or_else(|| v.as_str().and_then(crate::units::parse_distance)).filter(|f| f.is_finite())
|
||||
}
|
||||
@@ -172,7 +196,8 @@ fn as_pt(v: &Value) -> Option<cadcraft_geom::Vec2> {
|
||||
}
|
||||
|
||||
/// Variables `get` reports from session or drawing state that `set` can't change.
|
||||
const READ_ONLY: &[&str] = &["CMDNAMES", "DWGNAME", "DBMOD", "CTAB", "LASTPOINT", "VIEWCTR", "VIEWSIZE", "EXTMIN", "EXTMAX", "UNDOCTL", "UNDOMARKS"];
|
||||
const READ_ONLY: &[&str] =
|
||||
&["CMDNAMES", "DWGNAME", "DBMOD", "BLOCKEDITOR", "CTAB", "LASTPOINT", "VIEWCTR", "VIEWSIZE", "EXTMIN", "EXTMAX", "UNDOCTL", "UNDOMARKS"];
|
||||
|
||||
pub fn is_read_only(name: &str) -> bool {
|
||||
READ_ONLY.iter().any(|r| r.eq_ignore_ascii_case(name.trim())) || table::is_read_only(&name.trim().to_ascii_uppercase())
|
||||
@@ -238,6 +263,9 @@ pub fn set(s: &mut Session, name: &str, v: &Value) -> Result<()> {
|
||||
"DYNPICOORDS" => st.dynpi_absolute = as_bool(v).ok_or_else(bad)?,
|
||||
"DYNPROMPT" => st.dynprompt = as_bool(v).ok_or_else(bad)?,
|
||||
"LWDISPLAY" => st.lwdisplay = as_bool(v).ok_or_else(bad)?,
|
||||
"PLOTTRANSPARENCYOVERRIDE" => {
|
||||
st.plot_transparency_override = as_i(v).filter(|i| (0..=2).contains(i)).and_then(|i| u8::try_from(i).ok()).ok_or_else(bad)?
|
||||
}
|
||||
"SELECTSIMILARMODE" => st.selectsimilarmode = as_i(v).filter(|b| (0..=255).contains(b)).ok_or_else(bad)? as u32,
|
||||
"OBJECTISOLATIONMODE" => match as_i(v) {
|
||||
Some(0) => {}
|
||||
@@ -245,9 +273,21 @@ pub fn set(s: &mut Session, name: &str, v: &Value) -> Result<()> {
|
||||
_ => return Err(bad()),
|
||||
},
|
||||
"QPMODE" => st.qpmode = as_i(v).ok_or_else(bad)? > 0,
|
||||
"HIGHLIGHT" => st.highlight = as_bool(v).ok_or_else(bad)?,
|
||||
"SELECTIONEFFECT" => st.selectioneffect = small(v, 1).ok_or_else(bad)?,
|
||||
"SELECTIONPREVIEW" => st.selectionpreview = small(v, 3).ok_or_else(bad)?,
|
||||
"PREVIEWEFFECT" => st.previeweffect = small(v, 2).ok_or_else(bad)?,
|
||||
"PREVIEWFILTER" => st.previewfilter = small(v, 63).ok_or_else(bad)?,
|
||||
"GRIPS" => st.grips = small(v, 2).ok_or_else(bad)?,
|
||||
"GRIPOBJLIMIT" => st.gripobjlimit = as_i(v).filter(|n| (0..=32_767).contains(n)).and_then(|n| u32::try_from(n).ok()).ok_or_else(bad)?,
|
||||
"SELECTIONAREA" => st.selectionarea = as_bool(v).ok_or_else(bad)?,
|
||||
"WINDOWAREACOLOR" => st.windowareacolor = small(v, 255).filter(|c| *c >= 1).ok_or_else(bad)?,
|
||||
"CROSSINGAREACOLOR" => st.crossingareacolor = small(v, 255).filter(|c| *c >= 1).ok_or_else(bad)?,
|
||||
"SELECTIONAREAOPACITY" => st.selectionareaopacity = small(v, 100).ok_or_else(bad)?,
|
||||
"PICKBOX" => st.pickbox = as_f64(v).ok_or_else(bad)?.clamp(0.0, 50.0),
|
||||
"APERTURE" => st.aperture = as_f64(v).ok_or_else(bad)?.clamp(1.0, 50.0),
|
||||
"PICKFIRST" => st.pickfirst = as_bool(v).ok_or_else(bad)?,
|
||||
"ACADLSPASDOC" => st.acadlspasdoc = as_bool(v).ok_or_else(bad)?,
|
||||
"PICKADD" => st.pickadd = as_bool(v).ok_or_else(bad)?,
|
||||
"PICKSTYLE" => st.pickstyle = as_i(v).filter(|b| (0..=3).contains(b)).and_then(|b| u8::try_from(b).ok()).ok_or_else(bad)?,
|
||||
"GRIPSIZE" => st.gripsize = as_f64(v).ok_or_else(bad)?.clamp(1.0, 255.0),
|
||||
|
||||
@@ -55,6 +55,8 @@ const HEADER: &[(&str, Kind, Def)] = &[
|
||||
("DONUTOD", Kind::Real { min: 0.0, strict: true }, Def::Real(1.0)),
|
||||
("EDGEMODE", Kind::Choice(&[0, 1]), Def::Int(0)),
|
||||
("HPANG", Kind::Angle, Def::Real(0.0)),
|
||||
// 1: HATCH and GRADIENT typed or from a menu open the Hatch and Gradient dialog; 0: the command line.
|
||||
("HPDLGMODE", Kind::Choice(&[0, 1]), Def::Int(1)),
|
||||
("HPNAME", Kind::Pattern, Def::Str("ANSI31")),
|
||||
("HPSCALE", Kind::Real { min: 0.0, strict: true }, Def::Real(1.0)),
|
||||
("INSNAME", Kind::Name, Def::Str("")),
|
||||
|
||||
@@ -76,6 +76,43 @@ fn programmatic_undo_during_a_command_ends_it_first() {
|
||||
assert_eq!(kinds(&s), vec!["Line", "Text"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_during_line_removes_the_last_segment_and_keeps_line_running() {
|
||||
let mut s = Session::new();
|
||||
s.cmdline("line 0,0 10,0 10,10 20,10").unwrap();
|
||||
assert_eq!(kinds(&s), vec!["Line", "Line", "Line"]);
|
||||
// Ctrl+Z / the U command / the menu (Session::start) act as the Undo keyword.
|
||||
s.start("u").unwrap();
|
||||
assert!(s.running.is_some());
|
||||
assert_eq!(kinds(&s), vec!["Line", "Line"]);
|
||||
s.start("u").unwrap();
|
||||
assert!(s.running.is_some());
|
||||
assert_eq!(kinds(&s), vec!["Line"]);
|
||||
// The command goes on from the last remaining point.
|
||||
s.cmdline("10,5").unwrap();
|
||||
assert_eq!(kinds(&s), vec!["Line", "Line"]);
|
||||
s.cmdline("").unwrap();
|
||||
assert!(s.running.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_during_a_command_without_undo_keyword_ends_it_and_undoes() {
|
||||
let mut s = Session::new();
|
||||
s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap();
|
||||
// LINE at its first point offers no Undo: Ctrl+Z ends it, then undoes the earlier line.
|
||||
s.cmdline("line").unwrap();
|
||||
assert!(s.running.is_some());
|
||||
s.start("u").unwrap();
|
||||
assert!(s.running.is_none());
|
||||
assert!(kinds(&s).is_empty());
|
||||
// TEXT offers no Undo keyword either.
|
||||
s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap();
|
||||
s.script("TEXT 0,0 5 0 HELLO\n").unwrap();
|
||||
s.start("u").unwrap();
|
||||
assert!(s.running.is_none());
|
||||
assert_eq!(kinds(&s), vec!["Line"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn polar_and_direct_distance() {
|
||||
let mut s = Session::new();
|
||||
@@ -638,6 +675,8 @@ fn hostile_params_never_panic() {
|
||||
json!({"points": [[1e308, -1e308], ["a"]]}),
|
||||
json!({"handles": ["zz", 99999]}),
|
||||
json!({"radius": -1, "center": [0, 0]}),
|
||||
json!({"gradient": {"name": 5, "color1": [1], "angle": "x", "centered": "y"}}),
|
||||
json!({"points": [[0, 0]], "handles": ["1"], "gradient": "x", "name": "RAINBOW"}),
|
||||
] {
|
||||
let _ = s.execute(c.id, &p);
|
||||
}
|
||||
@@ -859,6 +898,21 @@ fn gradient_pick_point_keeps_island_of_separate_lines() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boundary_point_batch_is_capped() {
|
||||
let mut s = Session::new();
|
||||
let sq = [[0, 0], [20, 0], [20, 20], [0, 20]];
|
||||
for i in 0..4 {
|
||||
s.execute("line", &json!({"points": [sq[i], sq[(i + 1) % 4]]})).unwrap();
|
||||
}
|
||||
let many: Vec<_> = (0..100_000).map(|_| json!([2, 2])).collect();
|
||||
let t = std::time::Instant::now();
|
||||
assert!(s.execute("boundary", &json!({"points": many})).is_err());
|
||||
assert!(t.elapsed().as_secs() < 5);
|
||||
let ok: Vec<_> = (0..8).map(|_| json!([2, 2])).collect();
|
||||
assert!(s.execute("boundary", &json!({"points": ok})).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boundary_pick_point_keeps_island_of_separate_lines() {
|
||||
for lines in [true, false] {
|
||||
@@ -981,6 +1035,27 @@ fn line_first_point_deferred_tangent() {
|
||||
assert!(line_ends(&s)[1].0.near(b, 1e-12));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_deferred_tangent_with_typed_polar() {
|
||||
let mut s = Session::new();
|
||||
s.cmdline("circle 0,0 5").unwrap();
|
||||
s.execute("osnap", &json!({"modes": ["tan"]})).unwrap();
|
||||
s.cmdline("line").unwrap();
|
||||
snap_click(&mut s, Vec2::new(0.1, 5.2)); // deferred TAN, near the top of the circle
|
||||
s.cmdline("@150<218").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
let ends = line_ends(&s);
|
||||
assert_eq!(ends.len(), 1);
|
||||
let (a, b) = ends[0];
|
||||
// The start is the tangent point on the circle.
|
||||
assert!((a.len() - 5.0).abs() < 1e-9, "start not on circle: {a:?}");
|
||||
// The typed direction and distance are exact.
|
||||
let v = b - a;
|
||||
assert!((v.len() - 150.0).abs() < 1e-9, "length was {}", v.len());
|
||||
let angle = v.y.atan2(v.x).to_degrees().rem_euclid(360.0);
|
||||
assert!((angle - 218.0).abs() < 1e-9, "angle was {angle}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_belt_tangent_to_tangent() {
|
||||
let mut s = Session::new();
|
||||
@@ -1184,3 +1259,84 @@ fn block_rotation_property_turns_attribute_text() {
|
||||
assert_eq!(t.value, "hello");
|
||||
assert!((t.height - 2.5).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_variable_name_runs_setvar() {
|
||||
let mut s = Session::new();
|
||||
s.cmdline("pickadd").unwrap();
|
||||
assert!(s.current_prompt().is_some());
|
||||
s.cmdline("0").unwrap();
|
||||
assert!(s.current_prompt().is_none());
|
||||
assert_eq!(crate::sysvars::get(&s, "PICKADD"), Some(json!(0)));
|
||||
// Not a command and not a variable: still an error.
|
||||
assert!(s.cmdline("nosuchthing").is_err());
|
||||
// A dash or dot prefix never means a variable.
|
||||
assert!(s.cmdline("-pickadd").is_err());
|
||||
}
|
||||
|
||||
fn setvar_prompt_open(s: &Session) -> bool {
|
||||
s.running.as_ref().is_some_and(|r| r.id == "setvar")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_variable_name_starts_setvar_unless_it_is_a_command() {
|
||||
let mut s = Session::new();
|
||||
let names: Vec<String> = match crate::sysvars::list(&s) {
|
||||
Value::Object(m) => m.keys().cloned().collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
assert!(names.len() > 50, "expected the full variable list, got {}", names.len());
|
||||
let (mut prompted, mut read_only, mut commands) = (0, 0, 0);
|
||||
for n in &names {
|
||||
s.cancel();
|
||||
let lower = n.to_ascii_lowercase();
|
||||
let is_command = crate::cmd::find_command(&crate::cmd::resolve_alias(&lower)).is_some();
|
||||
let result = s.cmdline(&lower);
|
||||
if is_command {
|
||||
// A real command keeps its name (it may refuse to run without parameters, as a bare
|
||||
// CLAYER does); the variable is reached with SETVAR.
|
||||
assert!(!setvar_prompt_open(&s), "{n} is a command but started SETVAR");
|
||||
commands += 1;
|
||||
} else if let Err(e) = result {
|
||||
panic!("{n}: {e}");
|
||||
} else if setvar_prompt_open(&s) {
|
||||
prompted += 1;
|
||||
} else {
|
||||
// A read-only variable prints its value and ends.
|
||||
assert!(s.current_prompt().is_none(), "{n}: neither SETVAR nor finished");
|
||||
read_only += 1;
|
||||
}
|
||||
}
|
||||
s.cancel();
|
||||
assert!(prompted > 50 && read_only > 0, "prompted {prompted}, read-only {read_only}, commands {commands}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_variable_like_input_never_panics() {
|
||||
let mut s = Session::new();
|
||||
let long = "a".repeat(1 << 20);
|
||||
for t in [
|
||||
"'",
|
||||
"''",
|
||||
"'''pickadd",
|
||||
"\0pickadd",
|
||||
"pickadd\0",
|
||||
" ",
|
||||
"\t",
|
||||
"pick add",
|
||||
"PICKADD\u{0}0",
|
||||
"pickadd",
|
||||
"pickadd;0",
|
||||
"ÿ",
|
||||
&long,
|
||||
"pickadd\n0",
|
||||
"'\u{202e}pickadd",
|
||||
] {
|
||||
s.cancel();
|
||||
let _ = s.cmdline(t);
|
||||
}
|
||||
s.cancel();
|
||||
// The session is still usable afterwards.
|
||||
s.cmdline("pickadd").unwrap();
|
||||
assert!(setvar_prompt_open(&s));
|
||||
}
|
||||
@@ -283,3 +283,56 @@ fn hostile_parameters_are_refused() {
|
||||
assert!(s.execute("scalelistedit", &p).is_err(), "{p}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn annotative_mtext_columns_follow_the_scale() {
|
||||
// #708: at 1:2, two columns 10 wide with a gutter of 1 and a height of 20 on paper draw 20
|
||||
// wide, 2 apart and 40 tall: the box is 42 × 40, not 41 × 20.
|
||||
let mut s = Session::new();
|
||||
setvar(&mut s, "CANNOSCALE", json!("1:2"));
|
||||
let r = s
|
||||
.execute(
|
||||
"mtext",
|
||||
&json!({ "at": [0, 0], "height": 1, "width": 10, "text": "a\\Pb\\Pc", "columns": { "type": "static", "count": 2, "gutter": 1, "height": 20 } }),
|
||||
)
|
||||
.unwrap();
|
||||
let h = Handle::parse_hex(r["handle"].as_str().unwrap()).unwrap();
|
||||
s.execute("properties.set", &json!({ "handles": [h.hex()], "annotative": true })).unwrap();
|
||||
assert_eq!(scales(&s, h), ["1:2"]);
|
||||
let v = |s: &mut Session, name: &str| s.execute("getvar", &json!({ "name": name })).unwrap()["value"].clone();
|
||||
assert_eq!(v(&mut s, "EXTMIN"), json!([0.0, -40.0, 0.0]));
|
||||
assert_eq!(v(&mut s, "EXTMAX"), json!([42.0, 0.0, 0.0]));
|
||||
// Dynamic columns with manual heights, and the text of a multileader, scale the same way.
|
||||
let mut e = entity(&s, h);
|
||||
if let EntityKind::MText(t) = &mut e.kind
|
||||
&& let Some(c) = &mut t.columns
|
||||
{
|
||||
c.heights = vec![3.0, 5.0];
|
||||
}
|
||||
let mtext = match &e.kind {
|
||||
EntityKind::MText(t) => t.clone(),
|
||||
k => panic!("{k:?}"),
|
||||
};
|
||||
let ml = Entity {
|
||||
kind: EntityKind::MLeader(cadcraft_engine::doc::MLeader {
|
||||
leaders: Vec::new(),
|
||||
landing: Default::default(),
|
||||
dogleg: 1.0,
|
||||
text: Some(mtext),
|
||||
style: "Standard".into(),
|
||||
arrow_size: 1.0,
|
||||
spline: false,
|
||||
block: None,
|
||||
}),
|
||||
..e.clone()
|
||||
};
|
||||
for e in [e, ml] {
|
||||
let t = match cadcraft_engine::doc::anno_scaled(&e, 2.0).kind {
|
||||
EntityKind::MText(t) => t,
|
||||
EntityKind::MLeader(m) => m.text.unwrap(),
|
||||
k => panic!("{k:?}"),
|
||||
};
|
||||
let c = t.columns.unwrap();
|
||||
assert_eq!((t.height, t.width, c.count, c.gutter, c.height, c.heights), (2.0, 20.0, 2, 2.0, 40.0, vec![6.0, 10.0]));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
//! A reference's attributes take Lock position from their definitions; Verify and Preset stay
|
||||
//! with the definitions (as AutoCAD saves them).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::doc::{Attrib, AttribModes, Block, Entity, EntityKind, Text};
|
||||
use cadcraft_engine::geom::Vec3;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn references_take_lock_position_from_the_definition() {
|
||||
let mut d = cadcraft_engine::doc::Drawing::new_imperial();
|
||||
let text = Text {
|
||||
insert: Vec3::ZERO,
|
||||
align_pt: None,
|
||||
height: 2.0,
|
||||
value: "X".into(),
|
||||
rotation: 0.0,
|
||||
width_factor: 1.0,
|
||||
oblique: 0.0,
|
||||
style: "Standard".into(),
|
||||
halign: Default::default(),
|
||||
valign: Default::default(),
|
||||
};
|
||||
let modes = AttribModes { verify: true, preset: true, lock_position: true };
|
||||
let def = Attrib {
|
||||
tag: "MARK".into(),
|
||||
text,
|
||||
invisible: false,
|
||||
constant: false,
|
||||
prompt: "Mark".into(),
|
||||
props: Default::default(),
|
||||
multiline: None,
|
||||
modes,
|
||||
};
|
||||
let mut b = Block::new("TAG");
|
||||
let h = d.new_handle();
|
||||
b.entities.push(Entity::new(h, EntityKind::AttDef(def)));
|
||||
d.blocks.insert("TAG".into(), Arc::new(b));
|
||||
let mut s = Session::empty();
|
||||
s.open_drawing(d, "tags", None);
|
||||
let r = s.execute("insert", &json!({ "name": "TAG", "at": [10, 5] })).unwrap();
|
||||
let h = cadcraft_engine::doc::Handle::parse_hex(r["handle"].as_str().unwrap()).unwrap();
|
||||
let Some(EntityKind::Insert(i)) = s.doc().unwrap().entity(h).map(|e| e.kind.clone()) else { panic!("no reference") };
|
||||
assert_eq!(i.attribs[0].modes, AttribModes { verify: false, preset: false, lock_position: true });
|
||||
}
|
||||
@@ -211,6 +211,58 @@ fn autosave_files_are_written_in_the_background_and_removed_on_exit() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_autosave_is_tried_again_without_another_change() {
|
||||
hooks();
|
||||
let root = temp_dir("retry");
|
||||
// The folder can't be created while a file holds its name: the write fails (#717).
|
||||
let out = root.join("out");
|
||||
std::fs::write(&out, b"not a folder").unwrap();
|
||||
let dir = out.to_string_lossy().to_string();
|
||||
let mut s = Session::new();
|
||||
draw(&mut s);
|
||||
let mut a = Autosaver::new("31-e");
|
||||
a.plan(&s, 0.0, 1, &dir);
|
||||
let jobs = a.plan(&s, 60.0, 1, &dir);
|
||||
let path = writes(&jobs).pop().unwrap();
|
||||
a.perform(jobs);
|
||||
|
||||
// Once the write has failed, the unchanged drawing waits SAVETIME again.
|
||||
let mut rearmed = false;
|
||||
for _ in 0..500 {
|
||||
a.reap();
|
||||
assert!(writes(&a.plan(&s, 120.0, 1, &dir)).is_empty());
|
||||
if a.next_due(120.0, 1) == Some(60.0) {
|
||||
rearmed = true;
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
assert!(rearmed, "a failed autosave must be tried again");
|
||||
|
||||
std::fs::remove_file(&out).unwrap();
|
||||
let jobs = a.plan(&s, 180.0, 1, &dir);
|
||||
assert_eq!(writes(&jobs), vec![path.clone()]);
|
||||
a.perform(jobs);
|
||||
for _ in 0..500 {
|
||||
if std::path::Path::new(&path).exists() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
assert!(std::fs::read(&path).unwrap().ends_with(b"EOF\n"));
|
||||
|
||||
// A write that succeeded is not repeated.
|
||||
for _ in 0..50 {
|
||||
a.reap();
|
||||
assert!(writes(&a.plan(&s, 1e4, 1, &dir)).is_empty());
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
assert_eq!(a.next_due(1e4, 1), None);
|
||||
a.shutdown();
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recover_as_copy_opens_an_unsaved_untitled_drawing() {
|
||||
hooks();
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
//! The Block Editor (BEDIT, BSAVE, BSAVEAS, BCLOSE): a block is edited with the ordinary
|
||||
//! commands, its base point at the origin, and saving it updates every reference in the drawing.
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::cmd::file::{IoHooks, set_io};
|
||||
use cadcraft_engine::doc::{EntityKind, Line};
|
||||
use cadcraft_engine::geom::Vec3;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn line(s: &mut Session, a: [f64; 2], b: [f64; 2]) -> Value {
|
||||
s.execute("line", &json!({ "points": [a, b] })).unwrap()["handles"][0].clone()
|
||||
}
|
||||
|
||||
/// A drawing with block "Door" (one line from (10,10) to (12,10), base point (10,10)) and two
|
||||
/// references to it; returns the references.
|
||||
fn door(s: &mut Session) -> (Value, Value) {
|
||||
let l = line(s, [10.0, 10.0], [12.0, 10.0]);
|
||||
let a = s.execute("block", &json!({ "name": "Door", "base": [10, 10], "handles": [l] })).unwrap()["insert"].clone();
|
||||
let b = s.execute("insert", &json!({ "name": "Door", "at": [50, 0] })).unwrap()["handle"].clone();
|
||||
(a, b)
|
||||
}
|
||||
|
||||
fn lines_of(s: &Session, block: &str) -> Vec<Line> {
|
||||
let b = s.doc().unwrap().block(block).unwrap().clone();
|
||||
b.entities.iter().filter_map(|e| if let EntityKind::Line(l) = &e.kind { Some(l.clone()) } else { None }).collect()
|
||||
}
|
||||
|
||||
fn editor(s: &Session) -> Option<String> {
|
||||
s.state().unwrap().block_editor().map(str::to_string)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saving_the_block_updates_every_reference() {
|
||||
let mut s = Session::new();
|
||||
let (a, b) = door(&mut s);
|
||||
let model = s.doc().unwrap().model.clone();
|
||||
|
||||
s.execute("bedit", &json!({ "name": "door" })).unwrap();
|
||||
assert_eq!(editor(&s).as_deref(), Some("Door"));
|
||||
// The editor shows the block's objects with its base point at the origin.
|
||||
let shown: Vec<_> = s.doc().unwrap().model.iter().map(|e| e.kind.clone()).collect();
|
||||
assert!(matches!(&shown[..], [EntityKind::Line(l)] if l.a == Vec3::new(0.0, 0.0, 0.0) && l.b == Vec3::new(2.0, 0.0, 0.0)), "{shown:?}");
|
||||
assert_eq!(s.execute("getvar", &json!({ "name": "BLOCKEDITOR" })).unwrap()["value"], json!(1));
|
||||
|
||||
line(&mut s, [0.0, 0.0], [0.0, 3.0]);
|
||||
assert!(s.state().unwrap().block_unsaved() && s.state().unwrap().is_dirty());
|
||||
let r = s.execute("bclose", &json!({})).unwrap();
|
||||
assert_eq!(r, json!({ "block": "Door", "saved": true }));
|
||||
assert_eq!(editor(&s), None);
|
||||
|
||||
// The new line is in the definition, in block coordinates; the drawing's own objects and both
|
||||
// references are as they were.
|
||||
let ls = lines_of(&s, "Door");
|
||||
assert_eq!(ls.len(), 2);
|
||||
assert_eq!((ls[1].a, ls[1].b), (Vec3::new(10.0, 10.0, 0.0), Vec3::new(10.0, 13.0, 0.0)));
|
||||
let d = s.doc().unwrap();
|
||||
assert!(d.model.same_as(&model) || d.model == model);
|
||||
for h in [&a, &b] {
|
||||
let h = cadcraft_engine::cmd::curves::handle_value(h).unwrap();
|
||||
assert!(matches!(&d.entity(h).unwrap().kind, EntityKind::Insert(i) if i.block == "Door"));
|
||||
}
|
||||
|
||||
// One undo step in the drawing takes the edit back.
|
||||
s.execute("undo", &json!({})).unwrap();
|
||||
assert_eq!(lines_of(&s, "Door").len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_without_saving_leaves_the_drawing_as_it_was() {
|
||||
let mut s = Session::new();
|
||||
door(&mut s);
|
||||
let before = s.doc().unwrap().clone();
|
||||
let undo = s.state().unwrap().undo.len();
|
||||
s.execute("bedit", &json!({ "name": "Door" })).unwrap();
|
||||
s.script("ERASE ALL\n\n").unwrap();
|
||||
assert!(s.doc().unwrap().model.is_empty());
|
||||
s.execute("bclose", &json!({ "save": false })).unwrap();
|
||||
assert_eq!(*s.doc().unwrap(), before);
|
||||
assert_eq!(s.state().unwrap().undo.len(), undo);
|
||||
assert_eq!(lines_of(&s, "Door").len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_objects_keep_their_exact_coordinates() {
|
||||
let mut s = Session::new();
|
||||
// A base point whose coordinates don't survive x - b + b exactly.
|
||||
let l = line(&mut s, [0.1, 0.7], [0.3, 0.9]);
|
||||
s.execute("block", &json!({ "name": "B", "base": [0.7, 0.1], "handles": [l] })).unwrap();
|
||||
let original = lines_of(&s, "B");
|
||||
s.execute("bedit", &json!({ "name": "B" })).unwrap();
|
||||
line(&mut s, [0.0, 0.0], [1.0, 0.0]);
|
||||
s.execute("bsave", &json!({})).unwrap();
|
||||
assert!(!s.state().unwrap().block_unsaved());
|
||||
assert_eq!(editor(&s).as_deref(), Some("B"), "BSAVE keeps the editor open");
|
||||
assert_eq!(lines_of(&s, "B")[0], original[0]);
|
||||
s.execute("bclose", &json!({})).unwrap();
|
||||
let ls = lines_of(&s, "B");
|
||||
assert_eq!(ls[0], original[0]);
|
||||
assert_eq!(ls.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bsaveas_makes_a_new_block_and_keeps_the_old_one() {
|
||||
let mut s = Session::new();
|
||||
door(&mut s);
|
||||
s.execute("bedit", &json!({ "name": "Door" })).unwrap();
|
||||
line(&mut s, [0.0, 0.0], [0.0, 3.0]);
|
||||
let r = s.execute("bsaveas", &json!({ "name": "Door2" })).unwrap();
|
||||
assert_eq!(r["block"], "Door2");
|
||||
assert_eq!(editor(&s).as_deref(), Some("Door2"));
|
||||
assert!(s.execute("bsaveas", &json!({ "name": "Door" })).is_err(), "an existing block is redefined only with replace");
|
||||
assert!(s.execute("bsaveas", &json!({ "name": "bad*name" })).is_err());
|
||||
s.execute("bclose", &json!({})).unwrap();
|
||||
assert_eq!(lines_of(&s, "Door").len(), 1);
|
||||
let new = lines_of(&s, "Door2");
|
||||
assert_eq!(new.len(), 2);
|
||||
assert_eq!(s.doc().unwrap().block("Door2").unwrap().base, s.doc().unwrap().block("Door").unwrap().base);
|
||||
|
||||
// Redefining another block (typed in another case) keeps its name and takes the edited
|
||||
// block's base point, so the objects keep their places relative to it.
|
||||
let l = line(&mut s, [100.0, 100.0], [101.0, 100.0]);
|
||||
s.execute("block", &json!({ "name": "Other", "base": [100, 100], "handles": [l] })).unwrap();
|
||||
s.execute("bedit", &json!({ "name": "Door" })).unwrap();
|
||||
s.execute("bsaveas", &json!({ "name": "OTHER", "replace": true })).unwrap();
|
||||
s.execute("bclose", &json!({})).unwrap();
|
||||
let d = s.doc().unwrap();
|
||||
let other = d.block("Other").unwrap();
|
||||
assert_eq!(other.name, "Other");
|
||||
assert_eq!(other.base, d.block("Door").unwrap().base);
|
||||
assert_eq!(lines_of(&s, "Other"), lines_of(&s, "Door"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_the_block_editor_refuses() {
|
||||
let mut s = Session::new();
|
||||
door(&mut s);
|
||||
assert!(s.execute("bedit", &json!({ "name": "Nope" })).is_err());
|
||||
assert!(s.execute("bedit", &json!({})).is_err(), "no name and no block reference selected");
|
||||
assert!(s.execute("bsave", &json!({})).is_err(), "not open");
|
||||
assert!(s.execute("bclose", &json!({})).is_err(), "not open");
|
||||
|
||||
s.execute("bedit", &json!({ "name": "Door" })).unwrap();
|
||||
assert!(s.execute("bedit", &json!({ "name": "Door" })).is_err(), "already open");
|
||||
let r = s.execute("layout.set", &json!({ "name": "Layout1" }));
|
||||
assert!(r.is_err_and(|e| e.to_string().contains("Block Editor")));
|
||||
assert_eq!(s.layout_space(), cadcraft_engine::doc::Space::Model);
|
||||
|
||||
// A reference to the block itself can't be saved into it.
|
||||
s.execute("insert", &json!({ "name": "Door", "at": [5, 5] })).unwrap();
|
||||
assert!(s.execute("bsave", &json!({})).is_err_and(|e| e.to_string().contains("itself")));
|
||||
assert!(s.execute("bclose", &json!({})).is_err());
|
||||
assert!(editor(&s).is_some(), "a failed save keeps the editor open");
|
||||
s.execute("bclose", &json!({ "save": false })).unwrap();
|
||||
assert_eq!(lines_of(&s, "Door").len(), 1);
|
||||
|
||||
// Anonymous blocks are made by other objects.
|
||||
let mut d = s.doc().unwrap().clone();
|
||||
d.blocks.insert("*U1".into(), std::sync::Arc::new(cadcraft_engine::doc::Block::new("*U1")));
|
||||
s.open_drawing(d, "anon", None);
|
||||
assert!(s.execute("bedit", &json!({ "name": "*U1" })).is_err_and(|e| e.to_string().contains("anonymous")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_params_never_panic() {
|
||||
let mut s = Session::new();
|
||||
door(&mut s);
|
||||
for p in
|
||||
[json!({ "name": 5 }), json!({ "handle": "zz" }), json!({ "handle": -1 }), json!({ "handle": "FFFFFFFFFFFFFFFFFF" }), json!(null), json!([])]
|
||||
{
|
||||
assert!(s.execute("bedit", &p).is_err(), "{p}");
|
||||
}
|
||||
s.execute("bedit", &json!({ "name": "Door" })).unwrap();
|
||||
for p in [json!({ "name": "" }), json!({ "name": " " }), json!({ "name": 7 }), json!({ "name": "x".repeat(300) })] {
|
||||
assert!(s.execute("bsaveas", &p).is_err(), "{p}");
|
||||
}
|
||||
assert!(s.execute("bclose", &json!({ "save": "maybe" })).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_selected_reference_opens_its_block_and_bclose_asks_to_save() {
|
||||
let mut s = Session::new();
|
||||
let (a, _) = door(&mut s);
|
||||
s.set_selection(vec![cadcraft_engine::cmd::curves::handle_value(&a).unwrap()]);
|
||||
s.start("bedit").unwrap();
|
||||
assert!(s.running.is_none());
|
||||
assert_eq!(editor(&s).as_deref(), Some("Door"));
|
||||
|
||||
// Nothing changed: BCLOSE closes at once.
|
||||
s.script("BCLOSE\n").unwrap();
|
||||
assert_eq!(editor(&s), None);
|
||||
|
||||
// Typed name, a change, then BCLOSE No discards it.
|
||||
s.script("BEDIT Door\nLINE 0,0 5,5\n\nBCLOSE\nN\n").unwrap();
|
||||
assert_eq!(editor(&s), None);
|
||||
assert_eq!(lines_of(&s, "Door").len(), 1);
|
||||
// BCLOSE Yes saves it.
|
||||
s.script("BEDIT Door\nLINE 0,0 5,5\n\nBCLOSE\n\n").unwrap();
|
||||
assert_eq!(lines_of(&s, "Door").len(), 2);
|
||||
assert_eq!(s.execute("getvar", &json!({ "name": "BLOCKEDITOR" })).map(|v| v["value"].clone()).unwrap_or(Value::Null), json!(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_save_writes_the_drawing_outside_the_editor() {
|
||||
// The writer records how many objects the drawing's model space has, and block Door's.
|
||||
set_io(IoHooks {
|
||||
read: |_, _| Err("no reader in this test".into()),
|
||||
write: |d, _| Ok(format!("{} {}", d.model.len(), d.block("Door").map_or(0, |b| b.entities.len())).into_bytes()),
|
||||
plot: None,
|
||||
});
|
||||
let mut s = Session::new();
|
||||
door(&mut s);
|
||||
let path = std::env::temp_dir().join(format!("cadcraft-bedit-{}.dxf", std::process::id()));
|
||||
let path = path.to_string_lossy().to_string();
|
||||
s.execute("saveas", &json!({ "path": path })).unwrap();
|
||||
assert!(!s.state().unwrap().is_dirty());
|
||||
|
||||
s.execute("bedit", &json!({ "name": "Door" })).unwrap();
|
||||
line(&mut s, [0.0, 0.0], [0.0, 3.0]);
|
||||
// Autosave keeps the editor's unsaved change in the block.
|
||||
let rec = s.state().unwrap().recovery_drawing();
|
||||
assert_eq!(rec.block("Door").unwrap().entities.len(), 2);
|
||||
assert_eq!(rec.model.len(), 2);
|
||||
|
||||
s.execute("qsave", &json!({})).unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), "2 1", "the drawing, without the unsaved block change");
|
||||
assert!(s.state().unwrap().is_dirty(), "the block change is still unsaved");
|
||||
s.execute("bsave", &json!({})).unwrap();
|
||||
s.execute("qsave", &json!({})).unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), "2 2");
|
||||
assert!(!s.state().unwrap().is_dirty());
|
||||
s.execute("bclose", &json!({})).unwrap();
|
||||
assert!(!s.state().unwrap().is_dirty());
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dynamic_block_from_another_program_says_its_parameters_are_kept() {
|
||||
use cadcraft_engine::doc::foreign::{Foreign, ForeignExtra, ForeignObject};
|
||||
use cadcraft_engine::doc::{Handle, RawTag};
|
||||
let mut s = Session::new();
|
||||
door(&mut s);
|
||||
// As a DXF from AutoCAD has it: the block record's extension dictionary holds ACAD_ENHANCEDBLOCK.
|
||||
let mut f = Foreign::default();
|
||||
let xdict = Handle(0xF00);
|
||||
f.records.insert(("BLOCK_RECORD".into(), "DOOR".into()), ForeignExtra { xdict: Some(xdict), ..Default::default() });
|
||||
let tag = |code, value: &str| RawTag { code, value: value.into() };
|
||||
f.objects.push(ForeignObject { handle: xdict, kind: "DICTIONARY".into(), tags: vec![tag(3, "ACAD_ENHANCEDBLOCK"), tag(360, "F01")] });
|
||||
let mut d = s.doc().unwrap().clone();
|
||||
d.foreign = std::sync::Arc::new(f);
|
||||
s.open_drawing(d, "dynamic", None);
|
||||
s.execute("bedit", &json!({ "name": "Door" })).unwrap();
|
||||
assert!(s.log.iter().any(|l| l.contains("dynamic block")), "{:?}", s.log);
|
||||
// A plain block says nothing of the kind.
|
||||
s.execute("bclose", &json!({})).unwrap();
|
||||
let mut s = Session::new();
|
||||
door(&mut s);
|
||||
s.execute("bedit", &json!({ "name": "Door" })).unwrap();
|
||||
assert!(!s.log.iter().any(|l| l.contains("dynamic block")), "{:?}", s.log);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dynamic_reference_with_its_own_values_opens_its_dynamic_block() {
|
||||
use cadcraft_engine::doc::foreign::{Foreign, ForeignExtra};
|
||||
use cadcraft_engine::doc::{Block, Handle, RawTag};
|
||||
let mut s = Session::new();
|
||||
let (a, _) = door(&mut s);
|
||||
// As AutoCAD leaves it: the reference shows anonymous block *U7, whose record's
|
||||
// AcDbBlockRepBTag xdata names Door's record (file handle 1D).
|
||||
let mut d = s.doc().unwrap().clone();
|
||||
let door_block = d.block("Door").unwrap().clone();
|
||||
d.blocks.insert("*U7".into(), std::sync::Arc::new(Block { name: "*U7".into(), anonymous: true, ..(*door_block).clone() }));
|
||||
let h = cadcraft_engine::cmd::curves::handle_value(&a).unwrap();
|
||||
d.model.modify(h, |e| {
|
||||
if let EntityKind::Insert(i) = &mut e.kind {
|
||||
i.block = "*U7".into();
|
||||
}
|
||||
});
|
||||
let mut f = Foreign::default();
|
||||
let tag = |code, value: &str| RawTag { code, value: value.into() };
|
||||
f.records.insert(
|
||||
("BLOCK_RECORD".into(), "*U7".into()),
|
||||
ForeignExtra { xdata: vec![tag(1001, "AcDbBlockRepBTag"), tag(1070, "1"), tag(1005, "1D")], ..Default::default() },
|
||||
);
|
||||
f.targets.insert(Handle(0x1D), ("BLOCK_RECORD".into(), "DOOR".into()));
|
||||
d.foreign = std::sync::Arc::new(f);
|
||||
s.open_drawing(d, "dynamic", None);
|
||||
|
||||
// Selected (the right-click Block Editor) and by handle: both open Door.
|
||||
s.set_selection(vec![h]);
|
||||
s.start("bedit").unwrap();
|
||||
assert_eq!(editor(&s).as_deref(), Some("Door"));
|
||||
s.execute("bclose", &json!({})).unwrap();
|
||||
s.execute("bedit", &json!({ "handle": h.hex() })).unwrap();
|
||||
assert_eq!(editor(&s).as_deref(), Some("Door"));
|
||||
s.execute("bclose", &json!({})).unwrap();
|
||||
// Other anonymous blocks are still refused by name.
|
||||
assert!(s.execute("bedit", &json!({ "name": "*U7" })).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bedit_can_start_a_new_block() {
|
||||
let mut s = Session::new();
|
||||
door(&mut s);
|
||||
// An existing name edits that block.
|
||||
let r = s.execute("bedit", &json!({ "name": "door", "create": true })).unwrap();
|
||||
assert_eq!(r, json!({ "block": "Door", "new": false }));
|
||||
s.execute("bclose", &json!({})).unwrap();
|
||||
|
||||
// A new name starts an empty block; closing without saving creates nothing.
|
||||
let r = s.execute("bedit", &json!({ "name": " Window ", "create": true })).unwrap();
|
||||
assert_eq!(r, json!({ "block": "Window", "new": true }));
|
||||
assert!(s.doc().unwrap().model.is_empty());
|
||||
s.execute("bclose", &json!({ "save": false })).unwrap();
|
||||
assert!(s.doc().unwrap().block("Window").is_none());
|
||||
|
||||
// Saving creates it, base point at the origin.
|
||||
s.execute("bedit", &json!({ "name": "Window", "create": true })).unwrap();
|
||||
line(&mut s, [0.0, 0.0], [3.0, 0.0]);
|
||||
s.execute("bclose", &json!({})).unwrap();
|
||||
let d = s.doc().unwrap();
|
||||
let w = d.block("Window").unwrap();
|
||||
assert_eq!((w.name.as_str(), w.entities.len(), w.base), ("Window", 1, Vec3::new(0.0, 0.0, 0.0)));
|
||||
|
||||
// Without `create` a missing block is an error; invalid new names are refused.
|
||||
assert!(s.execute("bedit", &json!({ "name": "Skylight" })).is_err());
|
||||
for bad in ["", "a*b", "*U9", "x<y"] {
|
||||
assert!(s.execute("bedit", &json!({ "name": bad, "create": true })).is_err(), "{bad}");
|
||||
}
|
||||
assert!(s.state().unwrap().block_editor().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_current_drawing_opens_in_the_block_editor() {
|
||||
use cadcraft_engine::blockedit::{CURRENT_DRAWING, has_authoring_elements};
|
||||
use cadcraft_engine::doc::foreign::{Foreign, ForeignExtra, ForeignObject};
|
||||
use cadcraft_engine::doc::{Handle, RawTag};
|
||||
let mut s = Session::new();
|
||||
line(&mut s, [0.0, 0.0], [10.0, 0.0]);
|
||||
// A dynamic block's own drawing: model space's record carries ACAD_ENHANCEDBLOCK.
|
||||
let mut d = s.doc().unwrap().clone();
|
||||
assert!(!has_authoring_elements(&d));
|
||||
let mut f = Foreign::default();
|
||||
let tag = |code, value: &str| RawTag { code, value: value.into() };
|
||||
f.records.insert(("BLOCK_RECORD".into(), "*MODEL_SPACE".into()), ForeignExtra { xdict: Some(Handle(0xF00)), ..Default::default() });
|
||||
f.objects.push(ForeignObject { handle: Handle(0xF00), kind: "DICTIONARY".into(), tags: vec![tag(3, "ACAD_ENHANCEDBLOCK"), tag(360, "F01")] });
|
||||
d.foreign = std::sync::Arc::new(f);
|
||||
assert!(has_authoring_elements(&d));
|
||||
s.open_drawing(d, "symbol", None);
|
||||
|
||||
// Opened on the drawing itself: its objects are there to edit, and it says it is dynamic.
|
||||
let r = s.execute("bedit", &json!({ "name": "<current drawing>", "create": true })).unwrap();
|
||||
assert_eq!(r, json!({ "block": CURRENT_DRAWING, "new": false }));
|
||||
assert_eq!(editor(&s).as_deref(), Some(CURRENT_DRAWING));
|
||||
assert_eq!(s.doc().unwrap().model.len(), 1);
|
||||
assert!(s.log.iter().any(|l| l.contains("dynamic block")), "{:?}", s.log);
|
||||
// A block of it can't be saved under another name here.
|
||||
assert!(s.execute("bsaveas", &json!({ "name": "Copy" })).is_err());
|
||||
// BCLOSE without saving puts the drawing back.
|
||||
line(&mut s, [0.0, 5.0], [10.0, 5.0]);
|
||||
s.execute("bclose", &json!({ "save": false })).unwrap();
|
||||
assert_eq!(editor(&s), None);
|
||||
assert_eq!(s.doc().unwrap().model.len(), 1);
|
||||
|
||||
// Saved, the change is the drawing's: one undo step takes it back.
|
||||
s.execute("bedit", &json!({ "name": CURRENT_DRAWING })).unwrap();
|
||||
line(&mut s, [0.0, 5.0], [10.0, 5.0]);
|
||||
s.execute("bclose", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().model.len(), 2);
|
||||
assert!(s.doc().unwrap().block(CURRENT_DRAWING).is_none(), "no block is made");
|
||||
s.undo().unwrap();
|
||||
assert_eq!(s.doc().unwrap().model.len(), 1);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
//! BREAK, BREAKATPOINT and JOIN leave objects on locked layers alone (#727).
|
||||
|
||||
use cadcraft_engine::geom::Vec2;
|
||||
use cadcraft_engine::{Input, Session};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn h(r: &Value) -> String {
|
||||
r["handle"].as_str().or_else(|| r["handles"][0].as_str()).unwrap().to_string()
|
||||
}
|
||||
|
||||
/// Everything in the drawing a locked-layer edit could disturb.
|
||||
fn snapshot(s: &Session) -> String {
|
||||
let d = s.doc().unwrap();
|
||||
format!("{:?}", d.model.iter().map(|e| (e.handle, e.common.layer.clone(), e.kind.clone())).collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
fn set_lock(s: &mut Session, locked: bool) {
|
||||
s.execute("layer.set", &json!({"name": "0", "locked": locked})).unwrap();
|
||||
}
|
||||
|
||||
fn line(s: &mut Session, a: [f64; 2], b: [f64; 2]) -> String {
|
||||
h(&s.execute("line", &json!({"points": [a, b]})).unwrap())
|
||||
}
|
||||
|
||||
fn lines_ab(s: &mut Session) -> (String, String) {
|
||||
(line(s, [0.0, 0.0], [10.0, 0.0]), line(s, [10.0, 0.0], [20.0, 0.0]))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn break_and_breakatpoint_refuse_a_locked_line() {
|
||||
let mut s = Session::new();
|
||||
let a = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
|
||||
let control = line(&mut s, [0.0, 5.0], [10.0, 5.0]);
|
||||
set_lock(&mut s, true);
|
||||
let before = snapshot(&s);
|
||||
assert!(s.execute("break", &json!({"handle": a, "p1": [3, 0], "p2": [7, 0]})).is_err());
|
||||
assert!(s.execute("breakatpoint", &json!({"handle": a, "at": [5, 0]})).is_err());
|
||||
// Selection fallback reaches the same guard.
|
||||
s.execute("select", &json!({"handles": [a]})).unwrap();
|
||||
assert!(s.execute("break", &json!({"p1": [3, 0], "p2": [7, 0]})).is_err());
|
||||
assert!(s.execute("breakatpoint", &json!({"at": [5, 0]})).is_err());
|
||||
assert_eq!(snapshot(&s), before);
|
||||
// Unlocked, the same edits work, and undo and redo them.
|
||||
set_lock(&mut s, false);
|
||||
let unlocked = snapshot(&s);
|
||||
let r = s.execute("break", &json!({"handle": a, "p1": [3, 0], "p2": [7, 0]})).unwrap();
|
||||
assert_eq!(r["handles"].as_array().unwrap().len(), 2);
|
||||
assert!(s.doc().unwrap().entity(cadcraft_engine::doc::Handle::parse_hex(&a).unwrap()).is_none());
|
||||
assert!(s.doc().unwrap().entity(cadcraft_engine::doc::Handle::parse_hex(&control).unwrap()).is_some());
|
||||
let broken = snapshot(&s);
|
||||
s.execute("undo", &json!({})).unwrap();
|
||||
assert_eq!(snapshot(&s), unlocked);
|
||||
s.execute("redo", &json!({})).unwrap();
|
||||
assert_eq!(snapshot(&s), broken);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn break_refuses_a_locked_arc_and_circle() {
|
||||
let mut s = Session::new();
|
||||
let arc = h(&s.execute("arc", &json!({"center": [0, 0], "radius": 5, "start": 0, "end": 90})).unwrap());
|
||||
let circle = h(&s.execute("circle", &json!({"center": [20, 0], "radius": 3})).unwrap());
|
||||
set_lock(&mut s, true);
|
||||
let before = snapshot(&s);
|
||||
assert!(s.execute("break", &json!({"handle": arc, "p1": [5, 0], "p2": [0, 5]})).is_err());
|
||||
assert!(s.execute("break", &json!({"handle": circle, "p1": [23, 0], "p2": [20, 3]})).is_err());
|
||||
assert_eq!(snapshot(&s), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_refuses_when_any_line_is_locked() {
|
||||
let mut s = Session::new();
|
||||
let (a, b) = lines_ab(&mut s);
|
||||
set_lock(&mut s, true);
|
||||
let before = snapshot(&s);
|
||||
assert!(s.execute("join", &json!({"handles": [a, b]})).is_err());
|
||||
s.execute("select", &json!({"handles": [a, b]})).unwrap();
|
||||
assert!(s.execute("join", &json!({})).is_err());
|
||||
assert_eq!(snapshot(&s), before);
|
||||
set_lock(&mut s, false);
|
||||
let r = s.execute("join", &json!({"handles": [a, b]})).unwrap();
|
||||
assert!(r["handle"].is_string());
|
||||
assert_eq!(s.doc().unwrap().model.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_keeps_a_locked_original_in_a_mixed_layer_pair() {
|
||||
for locked_first in [true, false] {
|
||||
let mut s = Session::new();
|
||||
s.execute("layer.new", &json!({"name": "L"})).unwrap();
|
||||
// The first line goes on the soon-locked layer 0, the second on "L" (or the reverse).
|
||||
let on_l = |s: &mut Session, a: [f64; 2], b: [f64; 2]| {
|
||||
s.execute("layer.current", &json!({"name": "L"})).unwrap();
|
||||
let r = line(s, a, b);
|
||||
s.execute("layer.current", &json!({"name": "0"})).unwrap();
|
||||
r
|
||||
};
|
||||
let (a, b) = if locked_first {
|
||||
(line(&mut s, [0.0, 0.0], [10.0, 0.0]), on_l(&mut s, [10.0, 0.0], [20.0, 0.0]))
|
||||
} else {
|
||||
(on_l(&mut s, [0.0, 0.0], [10.0, 0.0]), line(&mut s, [10.0, 0.0], [20.0, 0.0]))
|
||||
};
|
||||
let locked = if locked_first { &a } else { &b };
|
||||
set_lock(&mut s, true);
|
||||
let before = snapshot(&s);
|
||||
assert!(s.execute("join", &json!({"handles": [a, b]})).is_err());
|
||||
assert_eq!(snapshot(&s), before, "locked_first = {locked_first}");
|
||||
assert!(s.doc().unwrap().entity(cadcraft_engine::doc::Handle::parse_hex(locked).unwrap()).is_some());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interactive_break_and_join_refuse_locked_lines() {
|
||||
let mut s = Session::new();
|
||||
let (a, b) = lines_ab(&mut s);
|
||||
set_lock(&mut s, true);
|
||||
let before = snapshot(&s);
|
||||
// BREAK: pick the line, then the second break point.
|
||||
s.start("break").unwrap();
|
||||
s.input(Input::Point(Vec2::new(3.0, 0.0))).unwrap();
|
||||
let _ = s.input(Input::Point(Vec2::new(7.0, 0.0)));
|
||||
assert!(s.log.last().is_some_and(|m| m.contains("locked layer")), "{:?}", s.log.last());
|
||||
assert_eq!(snapshot(&s), before);
|
||||
// JOIN on a preselected pair reports the refusal on the command line.
|
||||
s.cancel();
|
||||
s.execute("select", &json!({"handles": [a, b]})).unwrap();
|
||||
s.start("join").unwrap();
|
||||
assert!(s.log.last().is_some_and(|m| m.contains("locked layer")), "{:?}", s.log.last());
|
||||
assert_eq!(snapshot(&s), before);
|
||||
// Unlocked, the interactive commands still edit.
|
||||
set_lock(&mut s, false);
|
||||
s.start("break").unwrap();
|
||||
s.input(Input::Point(Vec2::new(3.0, 0.0))).unwrap();
|
||||
s.input(Input::Point(Vec2::new(7.0, 0.0))).unwrap();
|
||||
assert_ne!(snapshot(&s), before);
|
||||
}
|
||||
@@ -46,7 +46,7 @@ fn loaded_spacing_is_used_by_getvar_settings_snapshots_and_grid_snap() {
|
||||
s.settings.snapmode = true;
|
||||
s.settings.osmode = 0;
|
||||
s.settings.polarmode = false;
|
||||
let q = CursorQuery { base: None, deferred: false, aperture: 0.1, tol: 0.1, now: 0.0, shift: false };
|
||||
let q = CursorQuery { base: None, deferred: false, corner: false, aperture: 0.1, tol: 0.1, now: 0.0, shift: false };
|
||||
assert_eq!(s.snap_cursor(Vec2::new(0.27, 0.15), &q).point, Vec2::new(0.25, 0.125));
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,25 @@ fn spline_rejects_text_and_switches_method() {
|
||||
assert!(sp.fit.is_empty() && sp.control.len() == 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn circle_radius_point_on_the_centre_is_refused() {
|
||||
for input in ["circle 2,3 2,3", "circle 2,3 d 2,3"] {
|
||||
let mut s = Session::new();
|
||||
s.cmdline(input).unwrap();
|
||||
assert_eq!(s.doc().unwrap().model.len(), 0, "{input}: no circle");
|
||||
assert!(logged(&s, "Value must be positive and nonzero."), "{input}: {:?}", s.log);
|
||||
assert!(s.running.is_some(), "{input}: still at the radius prompt");
|
||||
s.cmdline("4").unwrap();
|
||||
assert!(s.running.is_none(), "{input}: a positive value finishes");
|
||||
assert_eq!(s.doc().unwrap().model.len(), 1, "{input}: one circle");
|
||||
let want = if input.contains(" d ") { 2.0 } else { 4.0 };
|
||||
assert!(
|
||||
matches!(last(&s), EntityKind::Circle(c) if c.center.xy().near(Vec2::new(2.0, 3.0), 1e-9) && (c.radius - want).abs() < 1e-9),
|
||||
"{input}: the centre is kept"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The two lowest vertices of a closed polyline share their y (the bottom edge is horizontal).
|
||||
fn bottom_y(k: &EntityKind) -> Option<f64> {
|
||||
let EntityKind::LwPolyline(p) = k else { return None };
|
||||
@@ -65,6 +84,20 @@ fn polygon_typed_radius_has_a_horizontal_bottom_edge() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn polygon_edge_endpoints_must_differ() {
|
||||
let mut s = Session::new();
|
||||
s.cmdline("polygon 4 e 1,1 1,1").unwrap();
|
||||
assert_eq!(s.doc().unwrap().model.len(), 0, "no polygon");
|
||||
assert!(logged(&s, "Edge endpoints must differ."), "{:?}", s.log);
|
||||
assert!(s.running.is_some(), "still at the second endpoint prompt");
|
||||
s.cmdline("3,1").unwrap();
|
||||
assert!(s.running.is_none(), "a distinct endpoint finishes");
|
||||
let EntityKind::LwPolyline(p) = last(&s) else { panic!("no polygon") };
|
||||
assert!(p.closed && p.vertices.len() == 4);
|
||||
assert!(p.vertices.iter().any(|v| v.p.near(Vec2::new(3.0, 1.0), 1e-9)), "the edge runs from 1,1 to 3,1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn circle_center_prompt_rejects_garbage() {
|
||||
let mut s = Session::new();
|
||||
@@ -74,3 +107,19 @@ fn circle_center_prompt_rejects_garbage() {
|
||||
s.cmdline("0,0 5").unwrap();
|
||||
assert!(matches!(last(&s), EntityKind::Circle(c) if (c.radius - 5.0).abs() < 1e-9));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn polygon_radius_must_be_positive() {
|
||||
for input in ["polygon 4 0,0 i 0", "polygon 4 0,0 c 0", "polygon 5 0,0 i -5", "polygon 5 0,0 c -5", "polygon 4 0,0 i 0,0", "polygon 4 0,0 c 0,0"]
|
||||
{
|
||||
let mut s = Session::new();
|
||||
s.cmdline(input).unwrap();
|
||||
assert_eq!(s.doc().unwrap().model.len(), 0, "{input}: no polygon");
|
||||
assert!(logged(&s, "Value must be positive and nonzero."), "{input}: {:?}", s.log);
|
||||
assert!(s.running.is_some(), "{input}: still at the radius prompt");
|
||||
s.cmdline("2").unwrap();
|
||||
assert!(s.running.is_none(), "{input}: a positive radius finishes");
|
||||
assert_eq!(s.doc().unwrap().model.len(), 1, "{input}: one polygon");
|
||||
assert!(matches!(last(&s), EntityKind::LwPolyline(p) if p.closed), "{input}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
//! `blocks.dynamic` and `blocks.list` report a dynamic block's parameters and actions as read
|
||||
//! from another program's file (kept foreign data, built by hand here).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::doc::foreign::{Foreign, ForeignExtra, ForeignObject};
|
||||
use cadcraft_engine::doc::{Handle, RawTag};
|
||||
use serde_json::json;
|
||||
|
||||
fn obj(handle: u64, kind: &str, tags: &[(i32, &str)]) -> ForeignObject {
|
||||
ForeignObject { handle: Handle(handle), kind: kind.into(), tags: tags.iter().map(|(c, v)| RawTag { code: *c, value: (*v).into() }).collect() }
|
||||
}
|
||||
|
||||
/// A session whose block "Door" has a visibility parameter with two states.
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
let l = s.execute("line", &json!({ "points": [[0, 0], [1, 0]] })).unwrap()["handles"][0].clone();
|
||||
s.execute("block", &json!({ "name": "Door", "base": [0, 0], "handles": [l] })).unwrap();
|
||||
let line = s.doc().unwrap().block("Door").unwrap().entities.handles()[0].hex();
|
||||
let mut f = Foreign::default();
|
||||
f.records.insert(("BLOCK_RECORD".into(), "DOOR".into()), ForeignExtra { xdict: Some(Handle(0xF00)), ..Default::default() });
|
||||
f.objects.push(obj(0xF00, "DICTIONARY", &[(100, "AcDbDictionary"), (3, "ACAD_ENHANCEDBLOCK"), (360, "F01")]));
|
||||
f.objects.push(obj(0xF01, "ACAD_EVALUATION_GRAPH", &[(100, "AcDbEvalGraph"), (91, "0"), (95, "1"), (360, "F02")]));
|
||||
f.objects.push(obj(
|
||||
0xF02,
|
||||
"BLOCKVISIBILITYPARAMETER",
|
||||
&[
|
||||
(100, "AcDbEvalExpr"),
|
||||
(90, "1"),
|
||||
(100, "AcDbBlockElement"),
|
||||
(300, "Visibility State"),
|
||||
(100, "AcDbBlockParameter"),
|
||||
(280, "1"),
|
||||
(100, "AcDbBlock1PtParameter"),
|
||||
(1010, "2"),
|
||||
(1020, "3"),
|
||||
(100, "AcDbBlockVisibilityParameter"),
|
||||
(301, "Visibility1"),
|
||||
(302, ""),
|
||||
(93, "1"),
|
||||
(331, &line),
|
||||
(92, "2"),
|
||||
(303, "Open"),
|
||||
(94, "1"),
|
||||
(332, &line),
|
||||
(95, "0"),
|
||||
(303, "Closed"),
|
||||
(94, "0"),
|
||||
(95, "0"),
|
||||
],
|
||||
));
|
||||
let mut d = s.doc().unwrap().clone();
|
||||
d.foreign = Arc::new(f);
|
||||
s.open_drawing(d, "dynamic", None);
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_block_data_is_reported() {
|
||||
let mut s = session();
|
||||
let list = s.execute("blocks.list", &json!({})).unwrap();
|
||||
let flags: Vec<_> = list["blocks"].as_array().unwrap().iter().map(|b| (b["name"].as_str().unwrap().to_string(), b["dynamic"].clone())).collect();
|
||||
assert!(flags.contains(&("Door".into(), json!(true))), "{flags:?}");
|
||||
|
||||
let m = s.execute("blocks.dynamic", &json!({ "name": "DOOR" })).unwrap();
|
||||
let p = &m["parameters"][0];
|
||||
assert_eq!((p["node"].clone(), p["element"].clone(), p["showProperties"].clone()), (json!(1), json!("Visibility State"), json!(true)));
|
||||
assert_eq!(p["kind"]["type"], "visibility");
|
||||
assert_eq!(p["kind"]["label"], "Visibility1");
|
||||
assert_eq!(p["kind"]["at"], json!({ "x": 2.0, "y": 3.0, "z": 0.0 }));
|
||||
let states: Vec<_> =
|
||||
p["kind"]["states"].as_array().unwrap().iter().map(|st| (st["name"].clone(), st["visible"].as_array().unwrap().len())).collect();
|
||||
assert_eq!(states, [(json!("Open"), 1), (json!("Closed"), 0)]);
|
||||
assert!(p["kind"]["controlled"][0].is_string(), "handles are hex strings");
|
||||
assert_eq!(m["actions"], json!([]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocks_without_dynamic_data_and_bad_names() {
|
||||
let mut s = session();
|
||||
let l = s.execute("line", &json!({ "points": [[0, 0], [1, 1]] })).unwrap()["handles"][0].clone();
|
||||
s.execute("block", &json!({ "name": "Window", "base": [0, 0], "handles": [l] })).unwrap();
|
||||
assert_eq!(s.execute("blocks.dynamic", &json!({ "name": "Window" })).unwrap(), json!(null));
|
||||
assert_eq!(s.execute("blocks.dynamic", &json!({ "name": "*Model_Space" })).unwrap(), json!(null));
|
||||
for p in [json!({}), json!({ "name": 3 }), json!({ "name": "Nope" }), json!(null)] {
|
||||
assert!(s.execute("blocks.dynamic", &p).is_err(), "{p}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reference_to_a_dynamic_block_reports_its_custom_properties() {
|
||||
let mut s = session();
|
||||
// BLOCK made the line a reference to Door, which has Door's own values.
|
||||
let insert = s.doc().unwrap().model.handles()[0].hex();
|
||||
let r = s.execute("blocks.reference", &json!({ "handle": insert })).unwrap();
|
||||
assert_eq!(r["definition"], "Door");
|
||||
assert_eq!(r["values"], json!({}));
|
||||
assert_eq!(r["properties"], json!([{ "name": "Visibility1", "value": "Open", "choices": ["Open", "Closed"], "editable": true }]));
|
||||
|
||||
// A plain block's reference, objects that aren't references and bad parameters.
|
||||
let l = s.execute("line", &json!({ "points": [[0, 0], [1, 1]] })).unwrap()["handles"][0].clone();
|
||||
let plain = s.execute("block", &json!({ "name": "Window", "base": [0, 0], "handles": [l.clone()] })).unwrap()["insert"].clone();
|
||||
assert_eq!(s.execute("blocks.reference", &json!({ "handle": plain })).unwrap(), json!(null));
|
||||
let l = s.execute("line", &json!({ "points": [[0, 0], [2, 2]] })).unwrap()["handles"][0].clone();
|
||||
assert!(s.execute("blocks.reference", &json!({ "handle": l })).is_err());
|
||||
for p in [json!({}), json!({ "handle": "zz" }), json!({ "handle": "FFFFFF" }), json!(null)] {
|
||||
assert!(s.execute("blocks.reference", &p).is_err(), "{p}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_visibility_state_is_changed_in_one_undo_step() {
|
||||
let mut s = session();
|
||||
let insert = s.doc().unwrap().model.handles()[0].hex();
|
||||
let r = s.execute("blocks.reference.set", &json!({ "handle": insert, "name": "Visibility1", "value": "Closed" })).unwrap();
|
||||
assert_eq!(r["properties"][0]["value"], "Closed");
|
||||
let block = |s: &Session| match s.doc().unwrap().model.iter().next().map(|e| e.kind.clone()) {
|
||||
Some(cadcraft_engine::doc::EntityKind::Insert(i)) => i.block,
|
||||
other => panic!("{other:?}"),
|
||||
};
|
||||
assert!(block(&s).starts_with("*U"));
|
||||
// Still a reference to Door, in its new state.
|
||||
let r = s.execute("blocks.reference", &json!({ "handle": insert })).unwrap();
|
||||
assert_eq!((r["definition"].clone(), r["properties"][0]["value"].clone()), (json!("Door"), json!("Closed")));
|
||||
|
||||
s.execute("undo", &json!({})).unwrap();
|
||||
assert_eq!(block(&s), "Door");
|
||||
let r = s.execute("blocks.reference", &json!({ "handle": insert })).unwrap();
|
||||
assert_eq!(r["properties"][0]["value"], "Open");
|
||||
|
||||
for p in [
|
||||
json!({ "handle": insert, "name": "Visibility1", "value": "Ajar" }),
|
||||
json!({ "handle": insert, "name": "Visibility1" }),
|
||||
json!({ "name": "Visibility1", "value": "Open" }),
|
||||
json!({ "handle": insert, "value": "Open" }),
|
||||
] {
|
||||
assert!(s.execute("blocks.reference.set", &p).is_err(), "{p}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
//! Grips of dynamic block references: listed, dragged by `blocks.reference.grip` (one undo
|
||||
//! step), and dragged interactively the way a click on a grip starts it, with a preview and a
|
||||
//! typed distance. The drawing is the synthetic DOOR block of the io tests, with grips added.
|
||||
|
||||
use cadcraft_engine::doc::dynblock::GripPart;
|
||||
use cadcraft_engine::doc::{Handle, Space};
|
||||
use cadcraft_engine::geom::Vec2;
|
||||
use cadcraft_engine::{Input, Session};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// DXF text from lines of `code value`.
|
||||
fn dxf(src: &str) -> String {
|
||||
src.lines()
|
||||
.map(str::trim)
|
||||
.filter(|l| !l.is_empty())
|
||||
.map(|l| {
|
||||
let (code, value) = l.split_once(' ').unwrap_or((l, ""));
|
||||
format!("{code}\n{value}\n")
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A session with DOOR (Width's end grip 9 shown in the Open state, the visibility grip 10) and
|
||||
/// a reference to it at (100, 0).
|
||||
fn session() -> (Session, Handle) {
|
||||
let grip = "0 BLOCKLINEARGRIP\n5 68\n330 61\n100 AcDbEvalExpr\n90 9\n98 33\n99 232\n100 AcDbBlockElement\n300 End Grip\n98 33\n99 232\n1071 0\n";
|
||||
let src = include_str!("../../io/tests/data/dynblock_door.txt")
|
||||
.replace("\r\n", "\n")
|
||||
.replace("170 4\n91 0\n91 0\n", "170 4\n91 8\n91 9\n")
|
||||
.replace("1030 0\n93 0\n170 0\n171 0\n100 AcDbBlockVisibilityParameter", "1030 0\n93 10\n170 0\n171 0\n100 AcDbBlockVisibilityParameter")
|
||||
.replace("91 5\n93 32\n95 6\n360 67", "91 5\n93 32\n95 6\n360 67\n92 -1\n92 -1\n92 -1\n92 -1\n91 6\n93 32\n95 9\n360 68")
|
||||
.replace("95 1\n333 63\n", "95 2\n333 63\n333 68\n")
|
||||
.replace("0 BLOCKFLIPGRIP", &format!("{grip}0 BLOCKFLIPGRIP"));
|
||||
let d = cadcraft_io::read_dxf(dxf(&src).as_bytes()).unwrap();
|
||||
let mut s = Session::empty();
|
||||
s.open_drawing(d, "door", None);
|
||||
let r = s.execute("insert", &json!({ "name": "Door", "at": [100, 0] })).unwrap();
|
||||
let h = Handle::parse_hex(r["handle"].as_str().unwrap()).unwrap();
|
||||
assert!(s.doc().unwrap().space_of(h) == Some(Space::Model));
|
||||
(s, h)
|
||||
}
|
||||
|
||||
fn width(s: &mut Session, h: Handle) -> String {
|
||||
let r = s.execute("blocks.reference", &json!({ "handle": h.hex() })).unwrap();
|
||||
let p = r["properties"].as_array().unwrap().iter().find(|p| p["name"] == "Width").cloned().unwrap_or(Value::Null);
|
||||
p["value"].as_str().unwrap_or_default().to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grips_are_listed_and_dragged_in_one_undo_step() {
|
||||
let (mut s, h) = session();
|
||||
let g = s.execute("blocks.reference.grips", &json!({ "handle": h.hex() })).unwrap();
|
||||
let parts: Vec<(i64, &str, &str)> =
|
||||
g.as_array().unwrap().iter().map(|g| (g["node"].as_i64().unwrap(), g["part"].as_str().unwrap(), g["shape"].as_str().unwrap())).collect();
|
||||
assert_eq!(parts, [(1, "at", "list"), (2, "end", "linear")]);
|
||||
assert_eq!(g[1]["at"], json!([136.0, -4.0]));
|
||||
s.execute("blocks.reference.grip", &json!({ "handle": h.hex(), "node": 2, "part": "end", "to": [130.4, 7] })).unwrap();
|
||||
assert_eq!(width(&mut s, h), "30.0000");
|
||||
s.undo().unwrap();
|
||||
assert_eq!(width(&mut s, h), "36.0000");
|
||||
for bad in [
|
||||
json!({ "handle": h.hex(), "node": 2, "part": "middle", "to": [130, 0] }),
|
||||
json!({ "handle": h.hex(), "node": 2, "part": "end" }),
|
||||
json!({ "handle": h.hex(), "part": "end", "to": [130, 0] }),
|
||||
json!({ "handle": h.hex(), "node": 1, "part": "at", "to": [130, 0] }),
|
||||
json!({ "handle": h.hex(), "node": 2, "part": "end", "to": [10, 0] }),
|
||||
json!({ "handle": "FFFFF", "node": 2, "part": "end", "to": [130, 0] }),
|
||||
] {
|
||||
assert!(s.execute("blocks.reference.grip", &bad).is_err(), "{bad}");
|
||||
}
|
||||
assert_eq!(width(&mut s, h), "36.0000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_grip_drag_previews_the_reference_and_takes_a_point_or_a_distance() {
|
||||
let (mut s, h) = session();
|
||||
s.start_dyn_grip(h, 2, GripPart::End).unwrap();
|
||||
// The preview is the reference re-evaluated at the cursor, and the rubber band.
|
||||
assert!(s.preview(Vec2::new(132.0, 0.0)).len() > 1);
|
||||
s.input(Input::Point(Vec2::new(132.2, 3.0))).unwrap();
|
||||
assert!(s.running.is_none());
|
||||
assert_eq!(width(&mut s, h), "32.0000");
|
||||
// A typed distance (snapped to the list).
|
||||
s.start_dyn_grip(h, 2, GripPart::End).unwrap();
|
||||
s.input(Input::Text("29".into())).unwrap();
|
||||
assert_eq!(width(&mut s, h), "30.0000");
|
||||
// A list grip offers its choices instead; a grip that isn't there is refused.
|
||||
assert!(s.start_dyn_grip(h, 1, GripPart::At).is_err());
|
||||
assert!(s.start_dyn_grip(h, 2, GripPart::Base).is_err());
|
||||
}
|
||||
@@ -17,7 +17,7 @@ fn setvar(s: &mut Session, name: &str, value: Value) {
|
||||
|
||||
/// Move the cursor to `raw` as the canvas does.
|
||||
fn hover(s: &mut Session, raw: Vec2, base: Option<Vec2>) -> Vec2 {
|
||||
let q = CursorQuery { base, deferred: false, aperture: 0.5, tol: 0.1, now: 100.0, shift: false };
|
||||
let q = CursorQuery { base, deferred: false, corner: false, aperture: 0.5, tol: 0.1, now: 100.0, shift: false };
|
||||
let r = s.snap_cursor(raw, &q);
|
||||
s.cursor = r.point;
|
||||
r.point
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
//! The engine side of the Hatch and Gradient dialog: GRADIENT's name and centred, points and
|
||||
//! objects in one HATCH call, HATCHEDIT's gradient, HATCHPICK and HPDLGMODE.
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::doc::{EntityKind, Handle, Hatch};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn rect(s: &mut Session, a: [f64; 2], b: [f64; 2]) -> String {
|
||||
s.execute("rectang", &json!({ "p1": a, "p2": b })).unwrap()["handle"].as_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
/// The handle of the object added last.
|
||||
fn last(s: &Session) -> String {
|
||||
s.doc().unwrap().model.iter().last().unwrap().handle.hex()
|
||||
}
|
||||
|
||||
fn hatch(s: &Session, h: &str) -> Hatch {
|
||||
match &s.doc().unwrap().entity(Handle::parse_hex(h).unwrap()).unwrap().kind {
|
||||
EntityKind::Hatch(x) => x.clone(),
|
||||
k => panic!("not a hatch: {k:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn hatches(s: &Session) -> Vec<(String, Hatch)> {
|
||||
s.doc().unwrap().model.iter().filter_map(|e| if let EntityKind::Hatch(h) = &e.kind { Some((e.handle.hex(), h.clone())) } else { None }).collect()
|
||||
}
|
||||
|
||||
fn count(s: &Session) -> usize {
|
||||
s.doc().unwrap().model.len()
|
||||
}
|
||||
|
||||
fn undo_depth(s: &Session) -> usize {
|
||||
s.state().unwrap().undo.len()
|
||||
}
|
||||
|
||||
fn handle(v: &Value) -> String {
|
||||
v["handle"].as_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradient_takes_a_name_and_centred() {
|
||||
let mut s = Session::new();
|
||||
rect(&mut s, [0.0, 0.0], [10.0, 10.0]);
|
||||
let h = handle(
|
||||
&s.execute("gradient", &json!({ "points": [[5, 5]], "name": "invspherical", "centered": false, "color1": "1", "color2": "3", "angle": 45 }))
|
||||
.unwrap(),
|
||||
);
|
||||
let g = hatch(&s, &h).gradient.unwrap();
|
||||
assert_eq!(g.name, "INVSPHERICAL");
|
||||
assert!(!g.centered);
|
||||
assert!((g.angle - 45f64.to_radians()).abs() < 1e-12);
|
||||
// Linear and centred unless told otherwise.
|
||||
let h = handle(&s.execute("gradient", &json!({ "points": [[5, 5]] })).unwrap());
|
||||
let g = hatch(&s, &h).gradient.unwrap();
|
||||
assert_eq!((g.name.as_str(), g.centered), ("LINEAR", true));
|
||||
// An unknown name is refused and adds nothing.
|
||||
let n = count(&s);
|
||||
assert!(s.execute("gradient", &json!({ "points": [[5, 5]], "name": "RAINBOW" })).is_err());
|
||||
assert_eq!(count(&s), n);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hatch_takes_points_and_objects_in_one_call() {
|
||||
let mut s = Session::new();
|
||||
rect(&mut s, [0.0, 0.0], [10.0, 10.0]);
|
||||
s.execute("circle", &json!({ "center": [30, 5], "radius": 3 })).unwrap();
|
||||
let c = last(&s);
|
||||
let depth = undo_depth(&s);
|
||||
s.execute("hatch", &json!({ "points": [[5, 5]], "handles": [c], "pattern": "SOLID" })).unwrap();
|
||||
let hs = hatches(&s);
|
||||
assert_eq!(hs.len(), 1, "one hatch for both picks");
|
||||
assert_eq!(hs[0].1.loops.len(), 2);
|
||||
assert_eq!(undo_depth(&s), depth + 1, "one undo step");
|
||||
// Separate: one hatch per point and one per object.
|
||||
s.execute("hatch", &json!({ "points": [[5, 5]], "handles": [c], "separate": true })).unwrap();
|
||||
assert_eq!(hatches(&s).len(), 3);
|
||||
s.undo().unwrap();
|
||||
assert_eq!(hatches(&s).len(), 1, "one undo removes both");
|
||||
// Points alone still ignore the selection.
|
||||
s.set_selection(vec![Handle::parse_hex(&c).unwrap()]);
|
||||
let h = handle(&s.execute("hatch", &json!({ "points": [[5, 5]] })).unwrap());
|
||||
assert_eq!(hatch(&s, &h).loops.len(), 1);
|
||||
}
|
||||
|
||||
use cadcraft_engine::doc::color::{Color, Rgb};
|
||||
|
||||
#[test]
|
||||
fn hatchedit_turns_a_pattern_into_a_gradient_and_back() {
|
||||
let mut s = Session::new();
|
||||
rect(&mut s, [0.0, 0.0], [10.0, 10.0]);
|
||||
let h = handle(&s.execute("hatch", &json!({ "points": [[5, 5]], "pattern": "ANSI31" })).unwrap());
|
||||
let depth = undo_depth(&s);
|
||||
s.execute(
|
||||
"hatchedit",
|
||||
&json!({ "handles": [h], "gradient": { "name": "cylinder", "color1": "red", "color2": "255,128,0", "angle": 30, "centered": false } }),
|
||||
)
|
||||
.unwrap();
|
||||
let x = hatch(&s, &h);
|
||||
let g = x.gradient.clone().unwrap();
|
||||
assert_eq!((g.name.as_str(), g.color1, g.color2, g.centered), ("CYLINDER", Color::Index(1), Color::True(Rgb(255, 128, 0)), false));
|
||||
assert!((g.angle - 30f64.to_radians()).abs() < 1e-12);
|
||||
assert!(x.solid);
|
||||
assert_eq!(x.pattern, "SOLID");
|
||||
assert_eq!(undo_depth(&s), depth + 1, "one undo step");
|
||||
// Only the fields given change.
|
||||
s.execute("hatchedit", &json!({ "handles": [h], "gradient": { "color2": "3" } })).unwrap();
|
||||
let g = hatch(&s, &h).gradient.unwrap();
|
||||
assert_eq!((g.name.as_str(), g.color1, g.color2), ("CYLINDER", Color::Index(1), Color::Index(3)));
|
||||
// A pattern turns it back.
|
||||
s.execute("hatchedit", &json!({ "handles": [h], "pattern": "ANSI31" })).unwrap();
|
||||
let x = hatch(&s, &h);
|
||||
assert!(x.gradient.is_none());
|
||||
assert!(!x.solid);
|
||||
s.undo().unwrap();
|
||||
assert!(hatch(&s, &h).gradient.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hatchedit_refuses_a_bad_gradient_and_changes_nothing() {
|
||||
let mut s = Session::new();
|
||||
rect(&mut s, [0.0, 0.0], [10.0, 10.0]);
|
||||
let h = handle(&s.execute("hatch", &json!({ "points": [[5, 5]], "pattern": "ANSI31" })).unwrap());
|
||||
let before = hatch(&s, &h);
|
||||
for bad in [
|
||||
json!("x"),
|
||||
json!({ "name": "RAINBOW" }),
|
||||
json!({ "name": 5 }),
|
||||
json!({ "color1": "nope" }),
|
||||
json!({ "angle": "x" }),
|
||||
json!({ "centered": 1 }),
|
||||
] {
|
||||
assert!(s.execute("hatchedit", &json!({ "handles": [h], "gradient": bad })).is_err(), "{bad}");
|
||||
assert_eq!(hatch(&s, &h), before, "{bad}");
|
||||
}
|
||||
assert!(s.execute("hatchedit", &json!({ "handles": [h], "pattern": "ANSI31", "gradient": { "name": "LINEAR" } })).is_err());
|
||||
assert_eq!(hatch(&s, &h), before);
|
||||
}
|
||||
|
||||
use cadcraft_engine::geom::Vec2;
|
||||
use cadcraft_engine::{Input, PickMode};
|
||||
|
||||
fn h(hex: &str) -> Handle {
|
||||
Handle::parse_hex(hex).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hatchpick_keeps_points_inside_boundaries_and_changes_nothing() {
|
||||
let mut s = Session::new();
|
||||
rect(&mut s, [0.0, 0.0], [10.0, 10.0]);
|
||||
let (n, depth) = (count(&s), undo_depth(&s));
|
||||
s.start("hatchpick").unwrap();
|
||||
s.input(Input::Keyword("Points".into())).unwrap();
|
||||
assert!(s.prompt_text().contains("Pick internal point"), "{}", s.prompt_text());
|
||||
s.input(Input::Point(Vec2::new(5.0, 5.0))).unwrap();
|
||||
assert!(!s.preview(Vec2::new(6.0, 6.0)).is_empty(), "the found boundary is previewed");
|
||||
// Outside every boundary: reported, not kept.
|
||||
s.input(Input::Point(Vec2::new(50.0, 50.0))).unwrap();
|
||||
assert!(s.log.iter().any(|l| l.contains("Valid hatch boundary not found")), "{:?}", s.log);
|
||||
s.input(Input::Point(Vec2::new(2.0, 2.0))).unwrap();
|
||||
s.input(Input::Keyword("Undo".into())).unwrap();
|
||||
s.input(Input::Enter).unwrap();
|
||||
assert!(s.running.is_none());
|
||||
let p = s.hatch_picks.clone().unwrap();
|
||||
assert_eq!(p.mode, PickMode::Points);
|
||||
assert_eq!(p.points, vec![Vec2::new(5.0, 5.0)]);
|
||||
assert_eq!((count(&s), undo_depth(&s)), (n, depth), "nothing added, no undo step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hatchpick_keeps_closed_objects_only() {
|
||||
let mut s = Session::new();
|
||||
let r = rect(&mut s, [0.0, 0.0], [10.0, 10.0]);
|
||||
s.execute("line", &json!({ "points": [[20, 0], [30, 0]] })).unwrap();
|
||||
let l = last(&s);
|
||||
s.start("hatchpick").unwrap();
|
||||
s.input(Input::Keyword("Objects".into())).unwrap();
|
||||
s.input(Input::Pick(vec![h(&r), h(&l)])).unwrap();
|
||||
s.input(Input::Enter).unwrap();
|
||||
let p = s.hatch_picks.clone().unwrap();
|
||||
assert_eq!((p.mode, p.handles), (PickMode::Objects, vec![h(&r)]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hatchpick_origin_and_hatch_modes() {
|
||||
let mut s = Session::new();
|
||||
let r = rect(&mut s, [0.0, 0.0], [10.0, 10.0]);
|
||||
let hx = handle(&s.execute("hatch", &json!({ "points": [[5, 5]] })).unwrap());
|
||||
s.start("hatchpick").unwrap();
|
||||
s.input(Input::Keyword("oRigin".into())).unwrap();
|
||||
s.input(Input::Point(Vec2::new(1.0, 2.0))).unwrap();
|
||||
assert!(s.running.is_none());
|
||||
assert_eq!(s.hatch_picks.clone().unwrap().origin, Some(Vec2::new(1.0, 2.0)));
|
||||
s.hatch_picks = None;
|
||||
s.start("hatchpick").unwrap();
|
||||
s.input(Input::Keyword("Hatch".into())).unwrap();
|
||||
// Not a hatch: asked again.
|
||||
s.input(Input::Pick(vec![h(&r)])).unwrap();
|
||||
assert!(s.running.is_some());
|
||||
s.input(Input::Pick(vec![h(&r), h(&hx)])).unwrap();
|
||||
assert!(s.running.is_none());
|
||||
let p = s.hatch_picks.clone().unwrap();
|
||||
assert_eq!((p.mode, p.handles), (PickMode::Hatch, vec![h(&hx)]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hatchpick_typed_mode_and_escape_keeps_nothing() {
|
||||
let mut s = Session::new();
|
||||
rect(&mut s, [0.0, 0.0], [10.0, 10.0]);
|
||||
s.cmdline("hatchpick points").unwrap();
|
||||
assert!(s.prompt_text().contains("Pick internal point"), "{}", s.prompt_text());
|
||||
s.input(Input::Point(Vec2::new(5.0, 5.0))).unwrap();
|
||||
s.cancel();
|
||||
assert!(s.running.is_none());
|
||||
assert!(s.hatch_picks.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hatchpick_json_form_validates_like_the_prompts() {
|
||||
let mut s = Session::new();
|
||||
let r = rect(&mut s, [0.0, 0.0], [10.0, 10.0]);
|
||||
s.execute("line", &json!({ "points": [[20, 0], [30, 0]] })).unwrap();
|
||||
let l = last(&s);
|
||||
s.execute("hatchpick", &json!({ "points": [[5, 5]], "handles": [r] })).unwrap();
|
||||
let p = s.hatch_picks.clone().unwrap();
|
||||
assert_eq!((p.points, p.handles), (vec![Vec2::new(5.0, 5.0)], vec![h(&r)]));
|
||||
let too_many: Vec<[f64; 2]> = vec![[5.0, 5.0]; 1001];
|
||||
for bad in [
|
||||
json!({}),
|
||||
json!({ "points": [[50, 50]] }),
|
||||
json!({ "points": [["a"]] }),
|
||||
json!({ "handles": [l] }),
|
||||
json!({ "handles": ["zz"] }),
|
||||
json!({ "handles": "x" }),
|
||||
json!({ "points": too_many }),
|
||||
] {
|
||||
s.hatch_picks = None;
|
||||
assert!(s.execute("hatchpick", &bad).is_err(), "{bad}");
|
||||
assert!(s.hatch_picks.is_none(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hpdlgmode_defaults_to_1_and_takes_0_or_1() {
|
||||
let mut s = Session::new();
|
||||
assert_eq!(cadcraft_engine::sysvars::get(&s, "HPDLGMODE"), Some(json!(1)));
|
||||
s.execute("setvar", &json!({ "name": "HPDLGMODE", "value": 0 })).unwrap();
|
||||
assert_eq!(cadcraft_engine::sysvars::get(&s, "HPDLGMODE"), Some(json!(0)));
|
||||
assert!(s.execute("setvar", &json!({ "name": "HPDLGMODE", "value": 2 })).is_err());
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
//! HATCHEDIT refuses an explicit bad `scale` or `angle` before changing anything: no partial
|
||||
//! edit, no undo step, redo history kept.
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::doc::EntityKind;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn fixture() -> (Session, String) {
|
||||
let mut s = Session::new();
|
||||
s.execute("rectang", &json!({"p1": [0, 0], "p2": [10, 10]})).unwrap();
|
||||
let handle = s.execute("hatch", &json!({"points": [[5, 5]]})).unwrap()["handle"].as_str().unwrap().to_owned();
|
||||
// One redo entry, which a rejected edit must leave alone.
|
||||
s.execute("circle", &json!({"center": [20, 20], "radius": 1})).unwrap();
|
||||
s.undo().unwrap();
|
||||
assert_eq!(s.state().unwrap().redo.len(), 1);
|
||||
(s, handle)
|
||||
}
|
||||
|
||||
fn hatch_props(s: &Session) -> (f64, f64) {
|
||||
let hatches: Vec<(f64, f64)> = s
|
||||
.doc()
|
||||
.unwrap()
|
||||
.model
|
||||
.iter()
|
||||
.filter_map(|e| if let EntityKind::Hatch(h) = &e.kind { Some((h.scale, h.angle.to_degrees())) } else { None })
|
||||
.collect();
|
||||
assert_eq!(hatches.len(), 1);
|
||||
hatches[0]
|
||||
}
|
||||
|
||||
fn assert_props(s: &Session, scale: f64, angle: f64) {
|
||||
let (sc, an) = hatch_props(s);
|
||||
assert!((sc - scale).abs() < 1e-9 && (an - angle).abs() < 1e-9, "scale {sc}, angle {an}");
|
||||
}
|
||||
|
||||
fn edit(s: &mut Session, handle: &str, extra: Value) -> cadcraft_engine::Result<Value> {
|
||||
let mut params = json!({ "handles": [handle] });
|
||||
for (k, v) in extra.as_object().unwrap() {
|
||||
params[k] = v.clone();
|
||||
}
|
||||
s.execute("hatchedit", ¶ms)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_scale_or_angle_is_refused_and_changes_nothing() {
|
||||
let cases = [
|
||||
(json!({"scale": 0}), "scale"),
|
||||
(json!({"scale": -1}), "scale"),
|
||||
(json!({"scale": "x"}), "scale"),
|
||||
(json!({"angle": "x"}), "angle"),
|
||||
(json!({"scale": 0, "angle": 45}), "scale"),
|
||||
(json!({"scale": 2, "angle": "x"}), "angle"),
|
||||
(json!({"scale": 0, "recreate": "polyline"}), "scale"),
|
||||
(json!({"angle": "x", "recreate": "polyline"}), "angle"),
|
||||
];
|
||||
for (extra, key) in cases {
|
||||
let (mut s, handle) = fixture();
|
||||
let before = s.doc().unwrap().clone();
|
||||
let (undo, redo, revision) = {
|
||||
let st = s.state().unwrap();
|
||||
(st.undo.len(), st.redo.len(), st.revision)
|
||||
};
|
||||
let err = edit(&mut s, &handle, extra.clone()).expect_err(&format!("{extra} should be refused"));
|
||||
assert!(err.to_string().contains(&format!("`{key}`")), "{extra}: {err}");
|
||||
assert_eq!(s.doc().unwrap(), &before, "{extra}: the drawing is untouched");
|
||||
let st = s.state().unwrap();
|
||||
assert_eq!((st.undo.len(), st.redo.len(), st.revision), (undo, redo, revision), "{extra}: history is untouched");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_omitted_and_null_values_still_edit() {
|
||||
let (mut s, handle) = fixture();
|
||||
edit(&mut s, &handle, json!({"scale": 2, "angle": 45})).unwrap();
|
||||
assert_props(&s, 2.0, 45.0);
|
||||
// Omitted or null numeric fields keep what the hatch has while another property changes.
|
||||
edit(&mut s, &handle, json!({"style": "outer"})).unwrap();
|
||||
assert_props(&s, 2.0, 45.0);
|
||||
edit(&mut s, &handle, json!({"scale": null, "angle": null, "style": "normal"})).unwrap();
|
||||
assert_props(&s, 2.0, 45.0);
|
||||
// Zero and negative angles are angles.
|
||||
edit(&mut s, &handle, json!({"angle": 0})).unwrap();
|
||||
assert_props(&s, 2.0, 0.0);
|
||||
edit(&mut s, &handle, json!({"angle": -30})).unwrap();
|
||||
assert_props(&s, 2.0, -30.0);
|
||||
}
|
||||
@@ -42,7 +42,7 @@ fn on_ellipse(e: &cadcraft_doc::Ellipse, p: Vec2) -> bool {
|
||||
}
|
||||
|
||||
fn cursor(s: &mut Session, raw: Vec2, base: Option<Vec2>) -> Vec2 {
|
||||
s.snap_cursor(raw, &CursorQuery { base, deferred: false, aperture: 1e-6, tol: 1e-6, now: 0.0, shift: false }).point
|
||||
s.snap_cursor(raw, &CursorQuery { base, deferred: false, corner: false, aperture: 1e-6, tol: 1e-6, now: 0.0, shift: false }).point
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
//! The AutoLISP conformance corpus (#648): each case's routine runs in a new drawing and must
|
||||
//! leave exactly the expected entities, layers and printed text. The corpus lives in its own
|
||||
//! repository; point `CADCRAFT_LISP_CORPUS` at its `cases` directory (or the repository) to run
|
||||
//! it. Without it the test only says so.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::cmd::file::{EntityCodec, set_entity_codec};
|
||||
use cadcraft_engine::doc::Entity;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn cases_dir() -> Option<PathBuf> {
|
||||
let root = PathBuf::from(std::env::var_os("CADCRAFT_LISP_CORPUS")?);
|
||||
let cases = root.join("cases");
|
||||
Some(if cases.is_dir() { cases } else { root })
|
||||
}
|
||||
|
||||
/// An entity's DXF groups as (code, value) pairs.
|
||||
fn groups(d: &cadcraft_engine::doc::Drawing, e: &Entity) -> (String, Vec<(i32, Value)>) {
|
||||
let (kind, tags) = cadcraft_io::entity_groups(d, e);
|
||||
let v = tags
|
||||
.into_iter()
|
||||
.map(|t| {
|
||||
let v = match t.value {
|
||||
cadcraft_dxf::Value::Str(s) | cadcraft_dxf::Value::Hex(s) => json!(s),
|
||||
cadcraft_dxf::Value::Real(r) => json!(r),
|
||||
cadcraft_dxf::Value::Int(i) => json!(i),
|
||||
cadcraft_dxf::Value::Bool(b) => json!(i32::from(b)),
|
||||
};
|
||||
(t.code, v)
|
||||
})
|
||||
.collect();
|
||||
(kind, v)
|
||||
}
|
||||
|
||||
fn num(g: &[(i32, Value)], code: i32) -> Option<f64> {
|
||||
g.iter().find(|(c, _)| *c == code).and_then(|(_, v)| v.as_f64())
|
||||
}
|
||||
|
||||
fn point(g: &[(i32, Value)], code: i32) -> Value {
|
||||
json!([num(g, code).unwrap_or(0.0), num(g, code + 10).unwrap_or(0.0), num(g, code + 20).unwrap_or(0.0)])
|
||||
}
|
||||
|
||||
/// The fields the corpus checks, read from an entity's groups.
|
||||
fn actual(d: &cadcraft_engine::doc::Drawing, e: &Entity) -> Value {
|
||||
let (kind, g) = groups(d, e);
|
||||
let s = |code: i32| g.iter().find(|(c, _)| *c == code).and_then(|(_, v)| v.as_str().map(str::to_string)).unwrap_or_default();
|
||||
let mut o = json!({ "type": kind, "layer": s(8) });
|
||||
let m = o.as_object_mut().unwrap();
|
||||
match kind.as_str() {
|
||||
"LINE" => {
|
||||
m.insert("start".into(), point(&g, 10));
|
||||
m.insert("end".into(), point(&g, 11));
|
||||
}
|
||||
"CIRCLE" | "ARC" => {
|
||||
m.insert("center".into(), point(&g, 10));
|
||||
m.insert("radius".into(), json!(num(&g, 40).unwrap_or(0.0)));
|
||||
if kind == "ARC" {
|
||||
m.insert("start_angle".into(), json!(num(&g, 50).unwrap_or(0.0)));
|
||||
m.insert("end_angle".into(), json!(num(&g, 51).unwrap_or(0.0)));
|
||||
}
|
||||
}
|
||||
"POINT" => {
|
||||
m.insert("location".into(), point(&g, 10));
|
||||
}
|
||||
"LWPOLYLINE" => {
|
||||
let xs: Vec<f64> = g.iter().filter(|(c, _)| *c == 10).filter_map(|(_, v)| v.as_f64()).collect();
|
||||
let ys: Vec<f64> = g.iter().filter(|(c, _)| *c == 20).filter_map(|(_, v)| v.as_f64()).collect();
|
||||
let verts: Vec<Value> = xs.iter().zip(&ys).map(|(x, y)| json!([x, y])).collect();
|
||||
m.insert("closed".into(), json!(num(&g, 70).unwrap_or(0.0) as i64 & 1 == 1));
|
||||
m.insert("vertices".into(), json!(verts));
|
||||
}
|
||||
"TEXT" => {
|
||||
m.insert("insert".into(), point(&g, 10));
|
||||
m.insert("height".into(), json!(num(&g, 40).unwrap_or(0.0)));
|
||||
m.insert("rotation".into(), json!(num(&g, 50).unwrap_or(0.0)));
|
||||
m.insert("text".into(), json!(s(1)));
|
||||
}
|
||||
"DIMENSION" => {
|
||||
let k = num(&g, 70).unwrap_or(0.0) as i64 & 7;
|
||||
m.insert(
|
||||
"kind".into(),
|
||||
json!(if k == 0 {
|
||||
"linear"
|
||||
} else if k == 1 {
|
||||
"aligned"
|
||||
} else {
|
||||
"other"
|
||||
}),
|
||||
);
|
||||
m.insert("xline1".into(), point(&g, 13));
|
||||
m.insert("xline2".into(), point(&g, 14));
|
||||
m.insert("rotation".into(), json!(num(&g, 50).unwrap_or(0.0)));
|
||||
m.insert("measurement".into(), json!(num(&g, 42).unwrap_or(0.0)));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
o
|
||||
}
|
||||
|
||||
fn close(a: &Value, b: &Value, tol: f64) -> bool {
|
||||
match (a, b) {
|
||||
(Value::Number(x), Value::Number(y)) => (x.as_f64().unwrap_or(f64::NAN) - y.as_f64().unwrap_or(f64::NAN)).abs() <= tol,
|
||||
(Value::Array(x), Value::Array(y)) => {
|
||||
// A 2D point compares with z = 0.
|
||||
let pad = |v: &Vec<Value>| -> Vec<Value> {
|
||||
let mut v = v.clone();
|
||||
if v.len() == 2 && v.iter().all(Value::is_number) {
|
||||
v.push(json!(0.0));
|
||||
}
|
||||
v
|
||||
};
|
||||
let (x, y) = (pad(x), pad(y));
|
||||
x.len() == y.len() && x.iter().zip(&y).all(|(p, q)| close(p, q, tol))
|
||||
}
|
||||
_ => a == b,
|
||||
}
|
||||
}
|
||||
|
||||
/// Closed polylines match as the same cyclic sequence in either direction.
|
||||
fn same_ring(have: &[Value], want: &[Value], tol: f64) -> bool {
|
||||
let n = want.len();
|
||||
if have.len() != n {
|
||||
return false;
|
||||
}
|
||||
if n == 0 {
|
||||
return true;
|
||||
}
|
||||
for start in 0..n {
|
||||
for dir in [1isize, -1] {
|
||||
let ok = (0..n).all(|i| {
|
||||
let j = (start as isize + dir * i as isize).rem_euclid(n as isize) as usize;
|
||||
close(&have[j], &want[i], tol)
|
||||
});
|
||||
if ok {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn matches(have: &Value, want: &Value, tol: f64) -> bool {
|
||||
let Some(w) = want.as_object() else { return false };
|
||||
w.iter().all(|(k, v)| {
|
||||
let h = have.get(k).unwrap_or(&Value::Null);
|
||||
if k == "vertices" && have.get("closed") == Some(&json!(true)) {
|
||||
return match (h, v) {
|
||||
(Value::Array(h), Value::Array(v)) => same_ring(h, v, tol),
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
close(h, v, tol)
|
||||
})
|
||||
}
|
||||
|
||||
fn run_case(dir: &Path) -> Result<(), String> {
|
||||
let routine = std::fs::read_to_string(dir.join("routine.lsp")).map_err(|e| e.to_string())?;
|
||||
let expected: Value =
|
||||
serde_json::from_str(&std::fs::read_to_string(dir.join("expected.json")).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;
|
||||
let mut s = Session::new();
|
||||
if let Some(vars) = expected["preconditions"]["sysvars"].as_object() {
|
||||
for (k, v) in vars {
|
||||
cadcraft_engine::sysvars::set(&mut s, k, v).map_err(|e| format!("setvar {k}: {e}"))?;
|
||||
}
|
||||
}
|
||||
let from = s.log.len();
|
||||
match s.lisp_load("routine.lsp", &routine, true) {
|
||||
Ok(Some(Err(e))) => return Err(format!("LISP error: {e}\n{:#?}", &s.log[from..])),
|
||||
Err(e) => return Err(e.to_string()),
|
||||
_ => {}
|
||||
}
|
||||
let exp = &expected["expect"];
|
||||
let tol = exp["tolerance"].as_f64().unwrap_or(1e-9);
|
||||
let d = s.doc().map_err(|e| e.to_string())?;
|
||||
let mut have: Vec<Value> = d.model.iter().map(|e| actual(d, e)).collect();
|
||||
let want = exp["entities"].as_array().cloned().unwrap_or_default();
|
||||
let mut missing = Vec::new();
|
||||
for w in &want {
|
||||
match have.iter().position(|h| matches(h, w, tol)) {
|
||||
Some(i) => {
|
||||
have.remove(i);
|
||||
}
|
||||
None => missing.push(w.clone()),
|
||||
}
|
||||
}
|
||||
if !missing.is_empty() || !have.is_empty() {
|
||||
return Err(format!(
|
||||
"missing {}:\n{}\nunexpected {}:\n{}\nlog:\n{}",
|
||||
missing.len(),
|
||||
serde_json::to_string_pretty(&missing).unwrap_or_default(),
|
||||
have.len(),
|
||||
serde_json::to_string_pretty(&have).unwrap_or_default(),
|
||||
s.log[from..].join("\n")
|
||||
));
|
||||
}
|
||||
for l in exp["layers"].as_array().cloned().unwrap_or_default() {
|
||||
let name = l["name"].as_str().unwrap_or("");
|
||||
let layer = d.layer(name).ok_or_else(|| format!("layer {name} missing"))?;
|
||||
if let Some(c) = l["color"].as_i64() {
|
||||
let have = match layer.color {
|
||||
cadcraft_color::Color::Index(i) => i64::from(i),
|
||||
_ => -1,
|
||||
};
|
||||
if have != c {
|
||||
return Err(format!("layer {name}: colour {have}, expected {c}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(p) = exp["printed"].as_str() {
|
||||
let out = s.log[from..].join("\n");
|
||||
if !out.contains(p.trim_start_matches('\n')) {
|
||||
return Err(format!("printed {p:?} not found in:\n{out}"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autolisp_corpus() {
|
||||
set_entity_codec(EntityCodec { groups: cadcraft_io::entity_groups, entity: cadcraft_io::entity_from_groups });
|
||||
let Some(dir) = cases_dir() else {
|
||||
eprintln!("CADCRAFT_LISP_CORPUS is not set: the AutoLISP corpus is skipped");
|
||||
return;
|
||||
};
|
||||
let mut cases: Vec<PathBuf> =
|
||||
std::fs::read_dir(&dir).unwrap().filter_map(|e| e.ok().map(|e| e.path())).filter(|p| p.join("routine.lsp").is_file()).collect();
|
||||
cases.sort();
|
||||
assert!(!cases.is_empty(), "no cases in {}", dir.display());
|
||||
let mut failed = Vec::new();
|
||||
for c in &cases {
|
||||
if let Err(e) = run_case(c) {
|
||||
failed.push(format!("== {}\n{e}", c.file_name().and_then(|n| n.to_str()).unwrap_or("?")));
|
||||
}
|
||||
}
|
||||
assert!(failed.is_empty(), "{} of {} cases failed:\n{}", failed.len(), cases.len(), failed.join("\n\n"));
|
||||
}
|
||||
@@ -197,6 +197,27 @@ fn lengthen_open_splines() {
|
||||
assert!(s.execute("lengthen", &json!({ "handle": sp, "pick": [9, 0], "delta": -1e9 })).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shortening_a_tightly_bent_spline_reaches_the_requested_length() {
|
||||
// #705: a fixed quadrature left this cubic 0.137 longer than asked, whatever the mode.
|
||||
let control = json!([[2.164, -9.484], [8.964, -13.012], [-16.428, -1.276], [18.58, -18.08]]);
|
||||
let half = 26.708_124_545_291_4 / 2.0;
|
||||
for mode in [json!({ "total": half }), json!({ "delta": -half }), json!({ "percent": 50 })] {
|
||||
let mut s = Session::new();
|
||||
let sp = h(&s.execute("spline", &json!({ "degree": 3, "control": control })).unwrap());
|
||||
let before = spline(&s, &sp);
|
||||
assert!((length(&mut s, &sp) - 2.0 * half).abs() < 1e-6, "{mode}");
|
||||
let mut q = mode.clone();
|
||||
q["handle"] = json!(sp);
|
||||
s.execute("lengthen", &q).unwrap();
|
||||
let got = length(&mut s, &sp);
|
||||
assert!((got - half).abs() < 1e-6, "{mode}: {got}");
|
||||
let now = spline(&s, &sp);
|
||||
assert!(ends(&now).0.near(ends(&before).0, 1e-9), "{mode}");
|
||||
assert!(dist_to(&before, ends(&now).1) < 1e-9, "{mode}: the new end lies on the old curve");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lengthen_at_the_command_line_on_an_elliptical_arc() {
|
||||
let mut s = Session::new();
|
||||
@@ -241,6 +262,26 @@ fn join_merges_elliptical_arcs_on_one_ellipse() {
|
||||
assert_eq!(count(&s), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_names_an_elliptical_arc_twice_without_losing_it() {
|
||||
// The same arc twice is one object: nothing to join, and the arc stays.
|
||||
let mut s = Session::new();
|
||||
let a = arc_on_ellipse(&mut s, 0.0, 90.0);
|
||||
let before = ellipse(&s, &a);
|
||||
assert!(s.execute("join", &json!({ "handles": [a, a] })).is_err());
|
||||
assert_eq!(count(&s), 1);
|
||||
assert_eq!(ellipse(&s, &a), before);
|
||||
// Repeating the source next to a second arc still merges the two into the source.
|
||||
let mut s = Session::new();
|
||||
let a = arc_on_ellipse(&mut s, 0.0, 90.0);
|
||||
let b = arc_on_ellipse(&mut s, 90.0, 180.0);
|
||||
let r = s.execute("join", &json!({ "handles": [a, a, b] })).unwrap();
|
||||
assert_eq!(h(&r), a);
|
||||
let m = ellipse(&s, &a);
|
||||
assert!(m.start.abs() < 1e-9 && (m.end - PI).abs() < 1e-9, "{m:?}");
|
||||
assert_eq!(count(&s), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_makes_one_spline_from_lines_arcs_polylines_and_splines() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! MTEXT columns and paragraph formatting from the command line, scripts and the JSON form
|
||||
//! (#532).
|
||||
|
||||
use cadcraft_doc::{EntityKind, MText, MTextColumnType};
|
||||
use cadcraft_doc::{EntityKind, MLeader, MText, MTextColumnType, MTextColumns};
|
||||
use cadcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
@@ -46,7 +46,7 @@ fn dynamic_columns_and_defaults() {
|
||||
#[test]
|
||||
fn json_columns_and_lists() {
|
||||
let mut s = Session::new();
|
||||
s.execute("mtext", &json!({"at": [0, 0], "text": "one\ntwo\nthree", "width": 5, "list": "number", "columns": {"type": "static", "count": 2, "gutter": 1, "height": 2}}))
|
||||
s.execute("mtext", &json!({"at": [0, 0], "text": "one\ntwo\nthree", "height": 1, "width": 5, "list": "number", "columns": {"type": "static", "count": 2, "gutter": 1, "height": 2}}))
|
||||
.unwrap();
|
||||
let t = last_mtext(&s);
|
||||
assert_eq!(t.contents, "\\pxi-3,l3,t3;1.\tone\\P2.\ttwo\\P3.\tthree");
|
||||
@@ -79,3 +79,49 @@ fn columns_shape_the_drawn_text_and_its_extents() {
|
||||
let b = cadcraft_doc::entity_bounds(d, e, 0);
|
||||
assert!((b.width() - 34.0).abs() < 1e-9 && (b.height() - 6.0).abs() < 1e-9, "{b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_scales_multileader_text_width_and_columns() {
|
||||
// #709: SCALE 2 on a multileader's text doubles its width and column lengths like it does
|
||||
// for a standalone MTEXT, not only its height.
|
||||
let text = MText {
|
||||
insert: Default::default(),
|
||||
height: 2.0,
|
||||
width: 10.0,
|
||||
attach: 1,
|
||||
rotation: 0.0,
|
||||
style: "Standard".into(),
|
||||
contents: "hello world".into(),
|
||||
line_spacing: 1.0,
|
||||
line_spacing_exact: false,
|
||||
columns: Some(MTextColumns {
|
||||
kind: MTextColumnType::Static,
|
||||
count: 2,
|
||||
gutter: 1.0,
|
||||
height: 5.0,
|
||||
auto_height: true,
|
||||
heights: vec![3.0, 4.0],
|
||||
flow_reversed: false,
|
||||
}),
|
||||
};
|
||||
let mut s = Session::new();
|
||||
let ml = MLeader {
|
||||
leaders: Vec::new(),
|
||||
landing: Default::default(),
|
||||
dogleg: 1.0,
|
||||
text: Some(text),
|
||||
style: "Standard".into(),
|
||||
arrow_size: 1.0,
|
||||
spline: false,
|
||||
block: None,
|
||||
};
|
||||
let h = s.add_entity(EntityKind::MLeader(ml)).unwrap();
|
||||
s.execute("scale", &json!({ "handles": [h.hex()], "base": [0, 0], "factor": 2 })).unwrap();
|
||||
let t = match &s.doc().unwrap().entity(h).unwrap().kind {
|
||||
EntityKind::MLeader(m) => m.text.clone().unwrap(),
|
||||
k => panic!("{k:?}"),
|
||||
};
|
||||
let c = t.columns.unwrap();
|
||||
assert_eq!((t.height, t.width), (4.0, 20.0));
|
||||
assert_eq!((c.kind, c.count, c.gutter, c.height, c.heights), (MTextColumnType::Static, 2, 2.0, 10.0, vec![6.0, 8.0]));
|
||||
}
|
||||
Loaded 100 of 241 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user