mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-31 06:16:51 -06:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
660e15c9f4 | ||
|
|
d4eab70e63 | ||
|
|
822f61b73a | ||
|
|
dc1442ac89 | ||
|
|
737f8a2ec6 | ||
|
|
b22a068262 | ||
|
|
d5128dfcd1 | ||
|
|
60f1bd6277 | ||
|
|
c808939bd1 | ||
|
|
344c022071 | ||
|
|
5e52e5285a | ||
|
|
5bc93ccf18 | ||
|
|
9c0c02f917 | ||
|
|
d79f4f5732 | ||
|
|
8822462be2 | ||
|
|
e348a30ebd | ||
|
|
b187af646a | ||
|
|
75efe07d85 | ||
|
|
4792ca2941 | ||
|
|
b4b8ae294a | ||
|
|
85fc3991b1 | ||
|
|
9b7149676b | ||
|
|
dc6a0e9ec3 | ||
|
|
2701983688 | ||
|
|
1ecc2a130b | ||
|
|
0ab812c12a | ||
|
|
47ed3af26c | ||
|
|
3d623fc4d4 | ||
|
|
04e74a165c | ||
|
|
ba3196e3f7 | ||
|
|
8038de04e4 | ||
|
|
8d1bd19ee2 | ||
|
|
43c41b8e68 | ||
|
|
18d3aa4ed8 | ||
|
|
189bfcbf08 | ||
|
|
8252d0e9bd | ||
|
|
29584fa4c4 | ||
|
|
6350c5b145 | ||
|
|
50df1284a3 | ||
|
|
16ae23424d | ||
|
|
dda6562a97 | ||
|
|
2be869a154 | ||
|
|
6ec6d97ea0 | ||
|
|
3ba2362ec9 | ||
|
|
0ae424c771 | ||
|
|
b883d94bf7 | ||
|
|
1e96a3d50c | ||
|
|
f536bf406f | ||
|
|
d6de2db6db | ||
|
|
dc0564a7a7 | ||
|
|
f8b09e92e5 | ||
|
|
b3158b56a8 | ||
|
|
7546277128 | ||
|
|
2d2be155dd | ||
|
|
f883454d7d | ||
|
|
9b4c1fb840 | ||
|
|
912558635e | ||
|
|
42667d1332 | ||
|
|
d15de75406 | ||
|
|
dcbcb6ab32 | ||
|
|
0d9e62bc9a | ||
|
|
3243271d4e | ||
|
|
303bc97f9a | ||
|
|
b9b7b3d439 | ||
|
|
1d987904f9 | ||
|
|
1d6c72db11 | ||
|
|
61fefc3ed9 | ||
|
|
74e77f8a92 | ||
|
|
f817ea9a3a | ||
|
|
31dba6e7a7 | ||
|
|
58687dda2d | ||
|
|
3ccfad60dc | ||
|
|
35b4332ee2 | ||
|
|
9a1f200795 | ||
|
|
bcf0dbc5e8 | ||
|
|
c2695f6ab0 | ||
|
|
7efb32b455 | ||
|
|
ab8953067a | ||
|
|
afc12e4dce | ||
|
|
c5d186ae92 | ||
|
|
9ff37ba262 | ||
|
|
4226a2610a | ||
|
|
263eba2ef6 | ||
|
|
b17ae78cec | ||
|
|
336243e0af | ||
|
|
9de2b8bbdc | ||
|
|
8b8e921f6a | ||
|
|
069d276fe5 | ||
|
|
4fdde4f301 | ||
|
|
0646dca86e | ||
|
|
ea02e45099 | ||
|
|
7af20d0bb2 | ||
|
|
936f08db1f | ||
|
|
28fe95fff0 |
@@ -25,8 +25,8 @@ permissions:
|
||||
|
||||
jobs:
|
||||
windows-arm64:
|
||||
# Immutable supporting workflow revision until the new install contract lands upstream.
|
||||
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main
|
||||
with:
|
||||
app-name: cadcraft
|
||||
app-dir: CADCraft
|
||||
|
||||
@@ -17,8 +17,8 @@ People trust CADCraft with their drawings; a crash loses their work. **This outr
|
||||
- **Errors are `Result<T, E>`** through the crate's error type and `?`. An unfinished feature returns an error or reports "not available yet"; it never panics.
|
||||
- **Input-derived numbers are hostile** (DXF/DWG files, command-line text, MCP/control params): `get()` not `[i]`, checked arithmetic, no NaN casts, cap input-sized allocations and loop counts.
|
||||
- **Bound recursion** (nested/cyclic block references: `MAX_BLOCK_DEPTH`).
|
||||
- **Last-resort guard:** `Session::execute` and every step of an interactive command (its factory, `begin`, input, `prompt` and `preview`; `crates/engine/src/guard.rs`) run under `catch_unwind`; an escaped panic restores the drawing and reports an error.
|
||||
- **Prove it:** every crash fix lands with a regression test (see `hostile_params_never_panic`, `hostile_dxf_does_not_panic`).
|
||||
- **Last-resort guard:** `Session::execute` and every step of an interactive command (its factory, `begin`, input, `prompt`, `preview` and the passes after it; `crates/engine/src/guard.rs`) run under `catch_unwind`; an escaped panic restores the drawing and reports an error. It does nothing on wasm, where a panic aborts.
|
||||
- **Prove it:** every crash fix lands with a regression test (see `crates/engine/tests/panic_hunt.rs` and `hostile_dxf_does_not_panic` in `crates/io/src/tests.rs`).
|
||||
- Every workspace crate manifest carries `[lints] workspace = true` to inherit `[workspace.lints]`; every production crate root carries `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
|
||||
|
||||
## Non-negotiables
|
||||
@@ -47,7 +47,7 @@ People trust CADCraft with their drawings; a crash loses their work. **This outr
|
||||
- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; delete your target dir when done (disk).
|
||||
|
||||
## Roadmap
|
||||
`ROADMAP.md` (committed) is the one-page summary: stage, parity numbers, estimates. It follows craftrules' `standards/progress-docs.md`: the assessment is `docs/target-app-parity.md`, the work list `docs/gaps.md` (pick work from its top rows), milestones `docs/roadmap.md`, deep checklists `docs/geometry-parity.md`, `docs/ui-parity.md`, `docs/file-format-parity.md`, `docs/hardware-parity.md`, `docs/localization-parity.md`. When work lands, update the affected rows and the docs' status lines and revision history. `cargo xtask parity` regenerates the menu-breadth checklist in `docs/parity-checklist.md`.
|
||||
`ROADMAP.md` (committed) is the one-page summary: stage, parity numbers, estimates. It follows craftrules' `standards/progress-docs.md`: the assessment is `docs/target-app-parity.md`, the work list `docs/gaps.md` (pick work from its top rows), milestones `docs/roadmap.md`, deep checklists `docs/geometry-parity.md`, `docs/ui-parity.md`, `docs/file-format-parity.md`, `docs/hardware-parity.md`, `docs/localization-parity.md`. PRs don't edit these docs: name the affected rows in the PR description, and maintainers update the rows, status lines and revision history in batches, which keeps open PRs from conflicting. `cargo xtask parity` regenerates the menu-breadth checklist in `docs/parity-checklist.md`.
|
||||
|
||||
## Contributor credits (About window)
|
||||
|
||||
@@ -66,3 +66,8 @@ People trust CADCraft with their drawings; a crash loses their work. **This outr
|
||||
GitHub profiles). Never hand-edit `contributors.json`.
|
||||
- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also
|
||||
re-verifies who wrote each `people.toml` entry; `--check` only verifies).
|
||||
|
||||
## CI workflows
|
||||
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
|
||||
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
|
||||
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
|
||||
|
||||
@@ -65,6 +65,7 @@ Generated-in-code assets are original and have no file to list:
|
||||
| `crates/ui-egui/src/i18n/de.tsv` | the CADCraft contributors | Original German translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
|
||||
| `crates/ui-egui/src/i18n/es.tsv` | @PedroGuille | Original Spanish translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
|
||||
| `crates/ui-egui/src/i18n/pt_br.tsv` | @WladmirJunior | Original Brazilian Portuguese translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
|
||||
| `crates/ui-egui/src/i18n/bs.tsv` | Nine (@nine-codes) with Claude Code (AI-assisted) | Original Bosnian translations of CADCraft English strings, shared with storytold/craft-i18n `locales/bs/` | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied; not yet reviewed by a native speaker |
|
||||
| `crates/ui-egui/src/i18n/cs.tsv` | @Losminek (AI-assisted) | Original Czech translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
|
||||
| `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 |
|
||||
|
||||
@@ -17,8 +17,8 @@ People trust CADCraft with their drawings; a crash loses their work. **This outr
|
||||
- **Errors are `Result<T, E>`** through the crate's error type and `?`. An unfinished feature returns an error or reports "not available yet"; it never panics.
|
||||
- **Input-derived numbers are hostile** (DXF/DWG files, command-line text, MCP/control params): `get()` not `[i]`, checked arithmetic, no NaN casts, cap input-sized allocations and loop counts.
|
||||
- **Bound recursion** (nested/cyclic block references: `MAX_BLOCK_DEPTH`).
|
||||
- **Last-resort guard:** `Session::execute` and every step of an interactive command (its factory, `begin`, input, `prompt` and `preview`; `crates/engine/src/guard.rs`) run under `catch_unwind`; an escaped panic restores the drawing and reports an error.
|
||||
- **Prove it:** every crash fix lands with a regression test (see `hostile_params_never_panic`, `hostile_dxf_does_not_panic`).
|
||||
- **Last-resort guard:** `Session::execute` and every step of an interactive command (its factory, `begin`, input, `prompt`, `preview` and the passes after it; `crates/engine/src/guard.rs`) run under `catch_unwind`; an escaped panic restores the drawing and reports an error. It does nothing on wasm, where a panic aborts.
|
||||
- **Prove it:** every crash fix lands with a regression test (see `crates/engine/tests/panic_hunt.rs` and `hostile_dxf_does_not_panic` in `crates/io/src/tests.rs`).
|
||||
- Every workspace crate manifest carries `[lints] workspace = true` to inherit `[workspace.lints]`; every production crate root carries `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
|
||||
|
||||
## Non-negotiables
|
||||
@@ -46,4 +46,9 @@ People trust CADCraft with their drawings; a crash loses their work. **This outr
|
||||
- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; delete your target dir when done (disk).
|
||||
|
||||
## Roadmap
|
||||
`ROADMAP.md` (committed) is the one-page summary: stage, parity numbers, estimates. It follows craftrules' `standards/progress-docs.md`: the assessment is `docs/target-app-parity.md`, the work list `docs/gaps.md` (pick work from its top rows), milestones `docs/roadmap.md`, deep checklists `docs/geometry-parity.md`, `docs/ui-parity.md`, `docs/file-format-parity.md`, `docs/hardware-parity.md`, `docs/localization-parity.md`. When work lands, update the affected rows and the docs' status lines and revision history. `cargo xtask parity` regenerates the menu-breadth checklist in `docs/parity-checklist.md`.
|
||||
`ROADMAP.md` (committed) is the one-page summary: stage, parity numbers, estimates. It follows craftrules' `standards/progress-docs.md`: the assessment is `docs/target-app-parity.md`, the work list `docs/gaps.md` (pick work from its top rows), milestones `docs/roadmap.md`, deep checklists `docs/geometry-parity.md`, `docs/ui-parity.md`, `docs/file-format-parity.md`, `docs/hardware-parity.md`, `docs/localization-parity.md`. PRs don't edit these docs: name the affected rows in the PR description, and maintainers update the rows, status lines and revision history in batches, which keeps open PRs from conflicting. `cargo xtask parity` regenerates the menu-breadth checklist in `docs/parity-checklist.md`.
|
||||
|
||||
## CI workflows
|
||||
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
|
||||
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
|
||||
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
|
||||
|
||||
Generated
+28
-27
@@ -268,7 +268,7 @@ dependencies = [
|
||||
"objc2-foundation 0.3.2",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
"wl-clipboard-rs",
|
||||
"x11rb",
|
||||
]
|
||||
@@ -614,7 +614,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft"
|
||||
version = "0.14.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"cadcraft-engine",
|
||||
"cadcraft-io",
|
||||
@@ -643,7 +643,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-cli"
|
||||
version = "0.14.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"cadcraft-engine",
|
||||
"cadcraft-io",
|
||||
@@ -659,14 +659,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-color"
|
||||
version = "0.14.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-constraints"
|
||||
version = "0.14.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"cadcraft-doc",
|
||||
"cadcraft-geom",
|
||||
@@ -676,7 +676,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-doc"
|
||||
version = "0.14.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-geom",
|
||||
@@ -687,14 +687,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-dwg"
|
||||
version = "0.14.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"acadrust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-dxf"
|
||||
version = "0.14.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"log",
|
||||
@@ -703,7 +703,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-engine"
|
||||
version = "0.14.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-constraints",
|
||||
@@ -724,7 +724,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-fonts"
|
||||
version = "0.14.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"cadcraft-geom",
|
||||
"harfrust 0.14.0",
|
||||
@@ -734,14 +734,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-geom"
|
||||
version = "0.14.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-io"
|
||||
version = "0.14.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"acadrust",
|
||||
"cadcraft-color",
|
||||
@@ -762,7 +762,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-mcp"
|
||||
version = "0.14.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"cadcraft-engine",
|
||||
"cadcraft-io",
|
||||
@@ -775,7 +775,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-render"
|
||||
version = "0.14.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-doc",
|
||||
@@ -789,7 +789,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-ui-egui"
|
||||
version = "0.14.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-doc",
|
||||
@@ -815,7 +815,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-web"
|
||||
version = "0.14.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"cadcraft-engine",
|
||||
"cadcraft-io",
|
||||
@@ -1191,13 +1191,14 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-update"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=04aa1703b32d299e54f78fcde4182f2b393a9116#04aa1703b32d299e54f78fcde4182f2b393a9116"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dunce",
|
||||
"egui",
|
||||
"flate2",
|
||||
"getrandom 0.3.4",
|
||||
"image",
|
||||
"log",
|
||||
"plist",
|
||||
"semver",
|
||||
@@ -1642,7 +1643,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3434,7 +3435,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4029,7 +4030,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.12.1",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4086,7 +4087,7 @@ dependencies = [
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4561,10 +4562,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.4.3",
|
||||
"getrandom 0.3.4",
|
||||
"once_cell",
|
||||
"rustix 1.1.5",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5470,7 +5471,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5893,7 +5894,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.14.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"serde_json",
|
||||
@@ -6114,9 +6115,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "9.1.1"
|
||||
version = "9.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "518b847fd864440c379748ad684c449f293af68978a485a4ff16f7c901c1f7c4"
|
||||
checksum = "1156b39d142a34b999c2310a50455e0cb3760bf7efad0122f796d813bee26ccb"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
|
||||
+3
-2
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
# default-members
|
||||
|
||||
[workspace.package]
|
||||
version = "0.14.0"
|
||||
version = "0.15.1"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -65,7 +65,8 @@ eframe = { version = "0.37", default-features = false }
|
||||
egui-wgpu = "0.37"
|
||||
# Monotonic time on native and web (performance.now on wasm).
|
||||
web-time = "1.1"
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] }
|
||||
# craft-update: app-specific pretend versions apply to startup and manual checks.
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "04aa1703b32d299e54f78fcde4182f2b393a9116", features = ["egui"] }
|
||||
rfd = "0.17"
|
||||
arboard = { version = "3.6", default-features = false }
|
||||
|
||||
|
||||
@@ -14,6 +14,9 @@ pub mod download;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod host;
|
||||
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
mod panel_layout;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod web {
|
||||
use cadcraft_engine::Session;
|
||||
@@ -59,10 +62,14 @@ mod web {
|
||||
self.0.raw_input_hook(ctx, raw);
|
||||
cadcraft_ui_egui::cmdline::capture_tab(ctx, raw);
|
||||
}
|
||||
/// Preferences (interface theme and language) survive reloads; egui state is not kept.
|
||||
/// Preferences and validated panel placement survive reloads; egui memory is not kept.
|
||||
fn save(&mut self, storage: &mut dyn eframe::Storage) {
|
||||
storage.set_string(cadcraft_ui_egui::options::PROFILE_KEY, self.0.profile_json());
|
||||
storage.set_string(cadcraft_ui_egui::PREFS_KEY, self.0.prefs_json());
|
||||
match crate::panel_layout::save(&self.0.ui) {
|
||||
Ok(layout) => storage.set_string(crate::panel_layout::KEY, layout),
|
||||
Err(error) => log::warn!("panel layout was not saved: {error}"),
|
||||
}
|
||||
}
|
||||
fn persist_egui_memory(&self) -> bool {
|
||||
false
|
||||
@@ -160,6 +167,11 @@ mod web {
|
||||
if let Some(prefs) = cc.storage.and_then(|s| s.get_string(cadcraft_ui_egui::PREFS_KEY)) {
|
||||
app.load_prefs(&prefs);
|
||||
}
|
||||
if let Some(layout) = cc.storage.and_then(|s| s.get_string(crate::panel_layout::KEY))
|
||||
&& let Err(error) = crate::panel_layout::restore(&mut app.ui, &layout)
|
||||
{
|
||||
log::warn!("panel layout was not restored: {error}");
|
||||
}
|
||||
// `?cpu`: no render state for the app, so the canvas draws with egui shapes.
|
||||
if !crate::wants_cpu(&query())
|
||||
&& let Some(rs) = &cc.wgpu_render_state
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
//! Browser panel layout storage, independent of drawings and native workspace files.
|
||||
|
||||
use cadcraft_ui_egui::{UiState, docking};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub const KEY: &str = "cadcraft.panel-layout";
|
||||
const MAX_BYTES: usize = 64 * 1024;
|
||||
|
||||
/// Serialize only validated panel placement and visibility, with a bounded record size.
|
||||
pub fn save(ui: &UiState) -> Result<String, String> {
|
||||
docking::validate(&ui.docking)?;
|
||||
let saved = json!({
|
||||
"version": docking::LAYOUT_VERSION,
|
||||
"docking": ui.docking,
|
||||
"showToolsets": ui.show_toolsets,
|
||||
"showPalettes": ui.show_palettes,
|
||||
})
|
||||
.to_string();
|
||||
if saved.len() > MAX_BYTES {
|
||||
return Err("panel layout exceeds 64 KiB".into());
|
||||
}
|
||||
Ok(saved)
|
||||
}
|
||||
|
||||
/// Validate the entire optional record before changing any UI field.
|
||||
pub fn restore(ui: &mut UiState, saved: &str) -> Result<(), String> {
|
||||
if saved.len() > MAX_BYTES {
|
||||
return Err("panel layout exceeds 64 KiB".into());
|
||||
}
|
||||
let value: Value = serde_json::from_str(saved).map_err(|error| error.to_string())?;
|
||||
if value.get("version").and_then(Value::as_u64) != Some(u64::from(docking::LAYOUT_VERSION)) {
|
||||
return Err("unsupported panel layout version".into());
|
||||
}
|
||||
let workspace: docking::Workspace =
|
||||
serde_json::from_value(value.get("docking").cloned().ok_or("missing panel layout")?).map_err(|error| error.to_string())?;
|
||||
docking::validate(&workspace)?;
|
||||
let tools = value.get("showToolsets").and_then(Value::as_bool).ok_or("invalid Tool Sets visibility")?;
|
||||
let palettes = value.get("showPalettes").and_then(Value::as_bool).ok_or("invalid palette visibility")?;
|
||||
ui.docking = workspace;
|
||||
ui.show_toolsets = tools;
|
||||
ui.show_palettes = palettes;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_ui_egui::{CadApp, Services};
|
||||
|
||||
#[test]
|
||||
fn floating_and_hidden_panels_survive_storage_without_replacing_drawing_state() {
|
||||
let mut source = CadApp::new(Session::default(), Services::default());
|
||||
source.run("ui.dock", json!({"operation":"float","panel":"toolSets","rect":[120,90,340,410]})).unwrap();
|
||||
source.run("ui.dock", json!({"operation":"close","panel":"properties"})).unwrap();
|
||||
let saved = save(&source.ui).unwrap();
|
||||
let mut destination = CadApp::new(Session::default(), Services::default());
|
||||
destination.session.start("line").unwrap();
|
||||
let before = destination.session.doc().unwrap().clone();
|
||||
restore(&mut destination.ui, &saved).unwrap();
|
||||
assert_eq!(destination.ui.docking, source.ui.docking);
|
||||
assert_eq!(destination.ui.show_toolsets, source.ui.show_toolsets);
|
||||
assert_eq!(destination.ui.show_palettes, source.ui.show_palettes);
|
||||
assert_eq!(destination.session.running.as_ref().map(|command| command.id.as_str()), Some("line"));
|
||||
assert_eq!(destination.session.doc().unwrap(), &before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_or_unsupported_storage_is_rejected_atomically() {
|
||||
let mut ui = UiState::default();
|
||||
let before = serde_json::to_value(&ui).unwrap();
|
||||
let valid: Value = serde_json::from_str(&save(&ui).unwrap()).unwrap();
|
||||
for (field, bad) in [
|
||||
("version", json!(0)),
|
||||
("version", json!(docking::LAYOUT_VERSION + 1)),
|
||||
("docking", json!({})),
|
||||
("showToolsets", json!("false")),
|
||||
("showPalettes", Value::Null),
|
||||
] {
|
||||
let mut invalid = valid.clone();
|
||||
invalid[field] = bad;
|
||||
assert!(restore(&mut ui, &invalid.to_string()).is_err());
|
||||
assert_eq!(serde_json::to_value(&ui).unwrap(), before);
|
||||
}
|
||||
for invalid in ["not json".to_string(), " ".repeat(MAX_BYTES + 1)] {
|
||||
assert!(restore(&mut ui, &invalid).is_err());
|
||||
assert_eq!(serde_json::to_value(&ui).unwrap(), before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn storage_cannot_remove_the_document_view() {
|
||||
let mut ui = UiState::default();
|
||||
let before = serde_json::to_value(&ui).unwrap();
|
||||
let mut invalid = ui.clone();
|
||||
invalid.docking.layout.root = None;
|
||||
assert!(save(&invalid).is_err());
|
||||
let mut record: Value = serde_json::from_str(&save(&ui).unwrap()).unwrap();
|
||||
record["docking"] = serde_json::to_value(&invalid.docking).unwrap();
|
||||
assert!(restore(&mut ui, &record.to_string()).is_err());
|
||||
assert_eq!(serde_json::to_value(&ui).unwrap(), before);
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
//! ([`Limits`]): simultaneous connections, request line length, queued requests, and how long a
|
||||
//! socket may sit idle or blocked on a write.
|
||||
|
||||
use std::io::{BufRead, BufReader, ErrorKind, Read, Write};
|
||||
use std::io::{BufRead, BufReader, ErrorKind, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
@@ -24,6 +24,9 @@ struct Limits {
|
||||
max_clients: usize,
|
||||
/// Longest request line in bytes (newline excluded); a longer one closes the connection.
|
||||
max_line: usize,
|
||||
/// Request-line bytes buffered at once across all connections; a request that would pass it
|
||||
/// is refused with an error reply.
|
||||
total_bytes: usize,
|
||||
/// Requests waiting for the app at once; more are answered `busy`.
|
||||
queue: usize,
|
||||
/// How long a connection may go without sending a byte before it is closed.
|
||||
@@ -40,6 +43,8 @@ impl Default for Limits {
|
||||
max_clients: 16,
|
||||
// Generous, so requests that carry whole drawings or images as base64 still fit.
|
||||
max_line: 256 * 1024 * 1024,
|
||||
// Room for one maximal request plus smaller ones, not `max_clients` maximal ones.
|
||||
total_bytes: 512 * 1024 * 1024,
|
||||
queue: 64,
|
||||
idle_timeout: Duration::from_secs(600),
|
||||
write_timeout: Duration::from_secs(30),
|
||||
@@ -106,6 +111,7 @@ impl Drop for Slot {
|
||||
/// Accept clients within the connection limit and give each worker the same session token.
|
||||
fn accept_loop(listener: TcpListener, tx: SyncSender<ControlRequest>, ctx: egui::Context, limits: Limits, token: Arc<String>) {
|
||||
let open = Arc::new(AtomicUsize::new(0));
|
||||
let budget = Budget::new(limits.total_bytes);
|
||||
for mut stream in accepted(listener.incoming(), || std::thread::sleep(ACCEPT_RETRY)) {
|
||||
let _ = stream.set_write_timeout(Some(limits.write_timeout));
|
||||
if open.fetch_add(1, Ordering::SeqCst) >= limits.max_clients {
|
||||
@@ -117,39 +123,102 @@ fn accept_loop(listener: TcpListener, tx: SyncSender<ControlRequest>, ctx: egui:
|
||||
let tx = tx.clone();
|
||||
let ctx = ctx.clone();
|
||||
let token = Arc::clone(&token);
|
||||
let budget = budget.clone();
|
||||
let _ = std::thread::Builder::new().spawn(move || {
|
||||
let _slot = slot;
|
||||
serve(stream, &tx, &ctx, limits, &token);
|
||||
serve(stream, &tx, &ctx, limits, &token, budget);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Read one `\n`-terminated line of at most `max` bytes into `buf` (newline stripped).
|
||||
/// `Ok(false)` at end of stream; `Err` when the line is too long or the read fails.
|
||||
fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec<u8>, max: usize) -> Result<bool, String> {
|
||||
/// A total-bytes budget for request lines buffered across all connections, so
|
||||
/// [`Limits::max_clients`] peers can't each hold a [`Limits::max_line`] line at once (#1925).
|
||||
#[derive(Clone, Debug)]
|
||||
struct Budget {
|
||||
used: Arc<AtomicUsize>,
|
||||
total: usize,
|
||||
}
|
||||
|
||||
impl Budget {
|
||||
fn new(total: usize) -> Self {
|
||||
Self { used: Arc::new(AtomicUsize::new(0)), total }
|
||||
}
|
||||
|
||||
/// Reserve `n` more bytes, or `false` (nothing reserved) when that would pass the total.
|
||||
fn reserve(&self, n: usize) -> bool {
|
||||
self.used.try_update(Ordering::SeqCst, Ordering::SeqCst, |u| u.checked_add(n).filter(|&t| t <= self.total)).is_ok()
|
||||
}
|
||||
}
|
||||
|
||||
/// Bytes a connection holds from the [`Budget`] for its current request line; given back when the
|
||||
/// next line starts or the connection ends.
|
||||
struct Held {
|
||||
budget: Budget,
|
||||
bytes: usize,
|
||||
}
|
||||
|
||||
impl Held {
|
||||
fn new(budget: Budget) -> Self {
|
||||
Self { budget, bytes: 0 }
|
||||
}
|
||||
|
||||
fn release(&mut self) {
|
||||
self.budget.used.fetch_sub(self.bytes, Ordering::SeqCst);
|
||||
self.bytes = 0;
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Held {
|
||||
fn drop(&mut self) {
|
||||
self.release();
|
||||
}
|
||||
}
|
||||
|
||||
/// Read one `\n`-terminated line of at most `max` bytes into `buf` (newline stripped), charging
|
||||
/// the bytes buffered to `held`. `Ok(false)` at end of stream; `Err` when the line is too long,
|
||||
/// the shared budget is used up, or the read fails.
|
||||
fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec<u8>, max: usize, held: &mut Held) -> Result<bool, String> {
|
||||
buf.clear();
|
||||
// `take` bounds the bytes buffered for one line: a peer can't make us allocate more than `max`.
|
||||
let limit = u64::try_from(max).unwrap_or(u64::MAX).saturating_add(1);
|
||||
let n = reader.take(limit).read_until(b'\n', buf).map_err(|e| match e.kind() {
|
||||
// The idle timeout ran out (Windows reports it as TimedOut, Unix as WouldBlock).
|
||||
ErrorKind::TimedOut | ErrorKind::WouldBlock => "request line not completed in time; closing the connection".to_string(),
|
||||
_ => e.to_string(),
|
||||
})?;
|
||||
if n == 0 {
|
||||
return Ok(false);
|
||||
}
|
||||
if buf.last() == Some(&b'\n') {
|
||||
buf.pop();
|
||||
if buf.last() == Some(&b'\r') {
|
||||
held.release();
|
||||
// A line is at most `max` bytes plus its newline; never buffer more than that.
|
||||
let cap = max.saturating_add(1);
|
||||
loop {
|
||||
let chunk = match reader.fill_buf() {
|
||||
Ok(c) => c,
|
||||
Err(e) if e.kind() == ErrorKind::Interrupted => continue,
|
||||
Err(e) => {
|
||||
return Err(match e.kind() {
|
||||
// The idle timeout ran out (Windows reports it as TimedOut, Unix as WouldBlock).
|
||||
ErrorKind::TimedOut | ErrorKind::WouldBlock => "request line not completed in time; closing the connection".to_string(),
|
||||
_ => e.to_string(),
|
||||
});
|
||||
}
|
||||
};
|
||||
if chunk.is_empty() {
|
||||
// End of stream: nothing read, or a last line without a newline.
|
||||
return Ok(!buf.is_empty());
|
||||
}
|
||||
let line_end = chunk.iter().position(|&b| b == b'\n').map(|i| i + 1);
|
||||
let n = line_end.unwrap_or(chunk.len()).min(cap.saturating_sub(buf.len()));
|
||||
buf.extend_from_slice(chunk.get(..n).unwrap_or(chunk));
|
||||
reader.consume(n);
|
||||
// The chunk is already buffered (a few KiB), so it is added before it is charged: a
|
||||
// refusal still leaves `buf` non-empty and the connection gets an error reply.
|
||||
if !held.budget.reserve(n) {
|
||||
return Err("too many large requests in flight; try again later".to_string());
|
||||
}
|
||||
held.bytes += n;
|
||||
if buf.last() == Some(&b'\n') {
|
||||
buf.pop();
|
||||
if buf.last() == Some(&b'\r') {
|
||||
buf.pop();
|
||||
}
|
||||
return Ok(true);
|
||||
}
|
||||
if buf.len() > max {
|
||||
return Err(format!("request line longer than {max} bytes; closing the connection"));
|
||||
}
|
||||
return Ok(true);
|
||||
}
|
||||
if buf.len() > max {
|
||||
return Err(format!("request line longer than {max} bytes; closing the connection"));
|
||||
}
|
||||
// Last line without a newline, then end of stream.
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// One request line, parsed.
|
||||
@@ -204,7 +273,7 @@ fn is_http_request_line(text: &str) -> bool {
|
||||
|
||||
/// Serve bounded JSON lines, authenticating each request before enqueueing it for the UI.
|
||||
/// Invalid credentials close the connection; queue and reply limits report whether a request ran.
|
||||
fn serve(stream: TcpStream, tx: &SyncSender<ControlRequest>, ctx: &egui::Context, limits: Limits, token: &str) {
|
||||
fn serve(stream: TcpStream, tx: &SyncSender<ControlRequest>, ctx: &egui::Context, limits: Limits, token: &str, budget: Budget) {
|
||||
if stream.set_read_timeout(Some(limits.idle_timeout)).is_err() || stream.set_write_timeout(Some(limits.write_timeout)).is_err() {
|
||||
return;
|
||||
}
|
||||
@@ -212,8 +281,9 @@ fn serve(stream: TcpStream, tx: &SyncSender<ControlRequest>, ctx: &egui::Context
|
||||
let mut out = stream;
|
||||
let mut reader = BufReader::new(read);
|
||||
let mut buf = Vec::new();
|
||||
let mut held = Held::new(budget);
|
||||
loop {
|
||||
let reject = match read_bounded_line(&mut reader, &mut buf, limits.max_line) {
|
||||
let reject = match read_bounded_line(&mut reader, &mut buf, limits.max_line, &mut held) {
|
||||
Ok(false) => break,
|
||||
// Nothing of a line arrived (idle timeout, or the peer is gone): close without a word.
|
||||
// A line nobody asked for would be read as the reply to the client's next request.
|
||||
@@ -264,6 +334,7 @@ fn serve(stream: TcpStream, tx: &SyncSender<ControlRequest>, ctx: &egui::Context
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Read;
|
||||
|
||||
/// A failed accept is followed by a wait before the next one, so a listener that keeps
|
||||
/// failing does not spin, and the connections around the failures still come through.
|
||||
@@ -281,13 +352,13 @@ mod tests {
|
||||
fn lines_are_read_up_to_the_limit() {
|
||||
let mut buf = Vec::new();
|
||||
let mut input: &[u8] = b"aaaaaaaaaa\nbb\r\ncc";
|
||||
assert_eq!(read_bounded_line(&mut input, &mut buf, 10), Ok(true));
|
||||
assert_eq!(read_bounded_line(&mut input, &mut buf, 10, &mut held()), Ok(true));
|
||||
assert_eq!(buf, b"aaaaaaaaaa", "a line exactly at the limit is accepted");
|
||||
assert_eq!(read_bounded_line(&mut input, &mut buf, 10), Ok(true));
|
||||
assert_eq!(read_bounded_line(&mut input, &mut buf, 10, &mut held()), Ok(true));
|
||||
assert_eq!(buf, b"bb", "CRLF is stripped");
|
||||
assert_eq!(read_bounded_line(&mut input, &mut buf, 10), Ok(true));
|
||||
assert_eq!(read_bounded_line(&mut input, &mut buf, 10, &mut held()), Ok(true));
|
||||
assert_eq!(buf, b"cc", "a last line without a newline is still read");
|
||||
assert_eq!(read_bounded_line(&mut input, &mut buf, 10), Ok(false), "then end of stream");
|
||||
assert_eq!(read_bounded_line(&mut input, &mut buf, 10, &mut held()), Ok(false), "then end of stream");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -295,11 +366,15 @@ mod tests {
|
||||
let mut buf = Vec::new();
|
||||
let long = [b'x'; 100];
|
||||
let mut input: &[u8] = &long;
|
||||
assert!(read_bounded_line(&mut input, &mut buf, 10).is_err());
|
||||
assert!(read_bounded_line(&mut input, &mut buf, 10, &mut held()).is_err());
|
||||
assert!(buf.len() <= 11, "at most limit + 1 bytes are buffered, got {}", buf.len());
|
||||
assert_eq!(input.len(), 100 - 11, "the rest of the line is left unread");
|
||||
}
|
||||
|
||||
fn held() -> Held {
|
||||
Held::new(Budget::new(usize::MAX))
|
||||
}
|
||||
|
||||
fn short() -> Limits {
|
||||
Limits {
|
||||
queue: 1,
|
||||
@@ -414,6 +489,40 @@ mod tests {
|
||||
drop(rx);
|
||||
}
|
||||
|
||||
/// Connections can't together buffer more request-line bytes than the budget: a second large
|
||||
/// request is refused with an error reply while the first still holds its bytes (#1925).
|
||||
#[test]
|
||||
fn requests_past_the_total_budget_are_refused() {
|
||||
let (addr, _rx) = serve_with(Limits { max_line: 1000, total_bytes: 300, idle_timeout: Duration::from_secs(5), ..short() });
|
||||
// The first peer sends 200 bytes of an unfinished line and keeps the connection open.
|
||||
let mut first = TcpStream::connect(addr).unwrap();
|
||||
first.write_all(&[b'a'; 200]).unwrap();
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
// The second would take 200 more, past the 300 byte total.
|
||||
let mut second = TcpStream::connect(addr).unwrap();
|
||||
second.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
second.write_all(&[b'b'; 200]).unwrap();
|
||||
let mut line = String::new();
|
||||
BufReader::new(second).read_line(&mut line).unwrap();
|
||||
let v: Value = serde_json::from_str(&line).unwrap();
|
||||
assert_eq!(v["ok"], json!(false));
|
||||
assert!(v["error"].as_str().unwrap_or("").contains("too many large requests"), "{line}");
|
||||
drop(first);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_budget_is_given_back_when_the_connection_ends() {
|
||||
let budget = Budget::new(100);
|
||||
{
|
||||
let mut h = Held::new(budget.clone());
|
||||
let mut buf = Vec::new();
|
||||
let mut input: &[u8] = &[b'x'; 80];
|
||||
assert_eq!(read_bounded_line(&mut input, &mut buf, 1000, &mut h), Ok(true));
|
||||
assert!(!budget.reserve(30), "80 of 100 bytes are held");
|
||||
}
|
||||
assert!(budget.reserve(100), "all given back on drop");
|
||||
}
|
||||
|
||||
/// Replies on one authenticated connection preserve the ID of each request.
|
||||
#[test]
|
||||
fn requests_are_answered_with_their_ids() {
|
||||
|
||||
@@ -218,7 +218,7 @@ fn main() -> eframe::Result {
|
||||
let logger = logging::install();
|
||||
// The update helper relaunches this binary after a self-update; it runs and exits here.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Some(done) = craft_update::self_update::finish(&craft_update::suite::CADCRAFT, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
|
||||
if let Some(done) = craft_update::self_update::finish(&cadcraft_ui_egui::UPDATE_APP, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
|
||||
if let Err(e) = done {
|
||||
eprintln!("CADCraft update helper failed: {e}");
|
||||
std::process::exit(1);
|
||||
|
||||
@@ -91,3 +91,11 @@ sync_github_name = false
|
||||
real_name = "Robert Lee"
|
||||
sync_github_name = true
|
||||
|
||||
[people.2ez4gcx]
|
||||
real_name = "Doan Huynh Huu Khuong"
|
||||
display_name = "Doan Huynh Huu Khuong"
|
||||
sync_github_name = false
|
||||
|
||||
|
||||
[people.dylanmtaylor]
|
||||
sync_github_name = true
|
||||
|
||||
@@ -52,15 +52,11 @@ fn collect(d: &Drawing, handles: &[Handle]) -> Geo {
|
||||
g.curves.push((GeomRef::whole(h), ga.center, a.radius));
|
||||
}
|
||||
}
|
||||
EntityKind::Circle(c) => {
|
||||
if c.center.is_finite() && c.radius.is_finite() {
|
||||
g.curves.push((GeomRef::whole(h), c.center.xy(), c.radius));
|
||||
}
|
||||
EntityKind::Circle(c) if c.center.is_finite() && c.radius.is_finite() => {
|
||||
g.curves.push((GeomRef::whole(h), c.center.xy(), c.radius));
|
||||
}
|
||||
EntityKind::Point(p) => {
|
||||
if p.p.is_finite() {
|
||||
g.points.push((GeomRef::whole(h), p.p.xy()));
|
||||
}
|
||||
EntityKind::Point(p) if p.p.is_finite() => {
|
||||
g.points.push((GeomRef::whole(h), p.p.xy()));
|
||||
}
|
||||
EntityKind::LwPolyline(p) => {
|
||||
let n = p.vertices.len();
|
||||
|
||||
@@ -44,14 +44,16 @@ mod dim;
|
||||
mod fill;
|
||||
mod layout;
|
||||
mod text;
|
||||
mod wipeout;
|
||||
|
||||
pub use block::{Attrib, AttribModes, AttribProps, Insert, MultilineAttrib, mtext_literal};
|
||||
pub use common::{Common, Handle, Lineweight, RawTag, Transparency};
|
||||
pub use curves::{Arc, Circle, Ellipse, Face3d, Line, LwPolyline, Point, Polyline3d, RayLine, Solid};
|
||||
pub use dim::{AssocSnap, DimAssoc, DimBreak, DimKind, DimMarks, Dimension, Inspection, JoggedRadius, Leader, MLeader};
|
||||
pub use fill::{Gradient, Hatch, HatchLoop, Wipeout};
|
||||
pub use fill::{Gradient, Hatch, HatchLoop};
|
||||
pub use layout::{Image, Table, TableCell, Viewport};
|
||||
pub use text::{HAlign, MText, MTextColumnType, MTextColumns, Text, VAlign};
|
||||
pub use wipeout::Wipeout;
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Unknown {
|
||||
pub dxf_type: String,
|
||||
@@ -226,11 +228,7 @@ impl EntityKind {
|
||||
h.loops.iter().flat_map(|l| Polyline { vertices: l.vertices.clone(), closed: true }.segments()).map(Prim::Seg).collect()
|
||||
}
|
||||
EntityKind::Wipeout(w) => {
|
||||
let mut pts = w.boundary.clone();
|
||||
if let Some(f) = pts.first().copied() {
|
||||
pts.push(f);
|
||||
}
|
||||
pts.windows(2).filter_map(|w| Some(Prim::Seg(Segment::Line(GLine::new(*w.first()?, *w.get(1)?))))).collect()
|
||||
w.closed_boundary().windows(2).filter_map(|w| Some(Prim::Seg(Segment::Line(GLine::new(*w.first()?, *w.get(1)?))))).collect()
|
||||
}
|
||||
EntityKind::Viewport(v) => {
|
||||
let c = v.center.xy();
|
||||
@@ -535,7 +533,7 @@ impl EntityKind {
|
||||
Bounds2::new(c - h, c + h).corners().to_vec()
|
||||
}
|
||||
EntityKind::Image(i) => vec![i.insert.xy()],
|
||||
EntityKind::Wipeout(w) => w.boundary.clone(),
|
||||
EntityKind::Wipeout(w) => w.vertices().to_vec(),
|
||||
EntityKind::Table(t) => vec![t.insert.xy()],
|
||||
EntityKind::Tolerance(t) => vec![t.insert.xy()],
|
||||
EntityKind::Unknown(_) => Vec::new(),
|
||||
|
||||
@@ -130,3 +130,20 @@ pub struct RawTag {
|
||||
pub code: i32,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
impl Common {
|
||||
/// Whether "Match orientation to layout" is on for this annotative object (AutoCAD's
|
||||
/// `AcadAnnoPO` xdata = 1). AutoCAD draws such text upright and ignores its stored rotation.
|
||||
/// The object's own `AcadAnnotative` xdata never reaches [`Common::xdata`] (the reader drops
|
||||
/// it), so the `AcadAnnoPO` flag alone decides.
|
||||
pub fn matches_layout_orientation(&self) -> bool {
|
||||
let app = self.xdata.iter().position(|t| t.code == 1001 && t.value.trim().eq_ignore_ascii_case("AcadAnnoPO"));
|
||||
let Some(at) = app else { return false };
|
||||
self.xdata
|
||||
.iter()
|
||||
.skip(at + 1)
|
||||
.take_while(|t| t.code != 1001)
|
||||
.find(|t| t.code == 1070)
|
||||
.is_some_and(|t| t.value.trim().parse::<i64>().is_ok_and(|v| v != 0))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,8 +54,3 @@ pub struct Gradient {
|
||||
#[serde(default)]
|
||||
pub centered: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Wipeout {
|
||||
pub boundary: Vec<Vec2>,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
//! WIPEOUT is always a closed masking polygon, unlike a polyline with an open/closed flag.
|
||||
//! One exact terminal copy of the first point is a closure marker, not an editable corner.
|
||||
//! Preserve logical corners when editing, adding a separate marker if the endpoints meet.
|
||||
//! Grips, edits and explicit outlines share this rule; other duplicates are not simplified.
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use cadcraft_geom::Vec2;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Wipeout {
|
||||
/// Ordered boundary, implicitly closed or ending in a repeated first point.
|
||||
pub boundary: Vec<Vec2>,
|
||||
}
|
||||
|
||||
impl Wipeout {
|
||||
/// Editable vertices, excluding one exactly repeated closing point, without changing storage.
|
||||
pub fn vertices(&self) -> &[Vec2] {
|
||||
match self.boundary.split_last() {
|
||||
Some((last, vertices)) if vertices.first() == Some(last) => vertices,
|
||||
_ => &self.boundary,
|
||||
}
|
||||
}
|
||||
|
||||
/// An explicitly closed outline for geometry, rendering and export. Already closed or
|
||||
/// empty/single-point boundaries are borrowed; otherwise one closing point is appended.
|
||||
pub fn closed_boundary(&self) -> Cow<'_, [Vec2]> {
|
||||
if self.vertices().len() < self.boundary.len() || self.boundary.len() < 2 {
|
||||
Cow::Borrowed(&self.boundary)
|
||||
} else {
|
||||
let mut points = self.boundary.clone();
|
||||
points.extend(self.boundary.first());
|
||||
Cow::Owned(points)
|
||||
}
|
||||
}
|
||||
|
||||
/// Move an editable vertex, carrying an existing closing marker with vertex zero. If an
|
||||
/// implicit boundary's endpoints meet, add a marker so both corners remain addressable.
|
||||
/// Invalid indices/non-finite points leave the boundary unchanged.
|
||||
pub fn set_vertex(&mut self, index: usize, to: Vec2) -> Option<()> {
|
||||
let count = self.vertices().len();
|
||||
if index >= count || !to.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let explicit = count < self.boundary.len();
|
||||
*self.boundary.get_mut(index)? = to;
|
||||
if explicit
|
||||
&& index == 0
|
||||
&& let Some(last) = self.boundary.last_mut()
|
||||
{
|
||||
*last = to;
|
||||
}
|
||||
if !explicit && self.vertices().len() < count {
|
||||
// The moved endpoint is a logical corner, not the newly implied closing marker.
|
||||
if let Some(first) = self.boundary.first().copied() {
|
||||
self.boundary.push(first);
|
||||
}
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::EntityKind;
|
||||
|
||||
#[test]
|
||||
fn vertices_and_grips_exclude_only_the_exact_closing_point() {
|
||||
let (a, b, c) = (Vec2::ZERO, Vec2::new(7.0, 1.0), Vec2::new(3.0, 7.0));
|
||||
let near_a = Vec2::new(1e-13, 0.0);
|
||||
for (boundary, expected) in [
|
||||
(vec![], vec![]),
|
||||
(vec![a], vec![a]),
|
||||
(vec![a, a], vec![a]),
|
||||
(vec![a, b, c], vec![a, b, c]),
|
||||
(vec![a, b, c, a], vec![a, b, c]),
|
||||
(vec![a, b, a, c, a], vec![a, b, a, c]),
|
||||
(vec![a, b, c, a, a], vec![a, b, c, a]),
|
||||
(vec![a, b, c, near_a], vec![a, b, c, near_a]),
|
||||
] {
|
||||
let wipeout = Wipeout { boundary: boundary.clone() };
|
||||
assert_eq!(wipeout.vertices(), expected);
|
||||
assert_eq!(EntityKind::Wipeout(wipeout.clone()).grips(), expected);
|
||||
assert_eq!(wipeout.boundary, boundary);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_outline_is_stable_and_does_not_add_zero_length_closing_edges() {
|
||||
let points = vec![Vec2::ZERO, Vec2::new(7.0, 1.0), Vec2::new(3.0, 7.0)];
|
||||
let implicit = Wipeout { boundary: points.clone() };
|
||||
let mut expected = points.clone();
|
||||
expected.push(points[0]);
|
||||
assert!(matches!(implicit.closed_boundary(), Cow::Owned(_)));
|
||||
assert_eq!(implicit.closed_boundary().as_ref(), expected);
|
||||
assert_eq!(implicit.boundary, points);
|
||||
let explicit = Wipeout { boundary: expected.clone() };
|
||||
assert!(matches!(explicit.closed_boundary(), Cow::Borrowed(_)));
|
||||
assert_eq!(explicit.closed_boundary().as_ref(), expected);
|
||||
let a = EntityKind::Wipeout(implicit).prims();
|
||||
let b = EntityKind::Wipeout(explicit).prims();
|
||||
assert_eq!(a, b);
|
||||
assert_eq!(a.len(), 3);
|
||||
for boundary in [vec![], vec![Vec2::ZERO]] {
|
||||
let wipeout = Wipeout { boundary: boundary.clone() };
|
||||
assert!(matches!(wipeout.closed_boundary(), Cow::Borrowed(_)));
|
||||
assert_eq!(wipeout.closed_boundary().as_ref(), boundary);
|
||||
assert!(EntityKind::Wipeout(wipeout).prims().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_endpoints_together_keeps_both_corners_editable() {
|
||||
let points = [Vec2::ZERO, Vec2::new(8.0, 0.0), Vec2::new(8.0, 6.0), Vec2::new(0.0, 6.0)];
|
||||
for explicit in [false, true] {
|
||||
for index in [0, 3] {
|
||||
let other = 3 - index;
|
||||
let mut boundary = points.to_vec();
|
||||
if explicit {
|
||||
boundary.push(points[0]);
|
||||
}
|
||||
let mut wipeout = Wipeout { boundary };
|
||||
let mut expected = points;
|
||||
expected[index] = points[other];
|
||||
for _ in 0..2 {
|
||||
assert_eq!(wipeout.set_vertex(index, points[other]), Some(()));
|
||||
assert_eq!(wipeout.vertices(), expected);
|
||||
assert_eq!(wipeout.boundary.len(), 5, "one separate closing marker, no growth");
|
||||
}
|
||||
expected[other] = Vec2::new(-2.0, 4.0);
|
||||
assert_eq!(wipeout.set_vertex(other, expected[other]), Some(()));
|
||||
assert_eq!(wipeout.vertices(), expected);
|
||||
assert_eq!(wipeout.boundary.len(), 5);
|
||||
assert_eq!(wipeout.boundary.first(), wipeout.boundary.last());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_keeps_the_closure_but_not_other_coincident_vertices_attached() {
|
||||
let (a, b, c, to) = (Vec2::ZERO, Vec2::new(7.0, 1.0), Vec2::new(3.0, 7.0), Vec2::new(2.0, 3.0));
|
||||
for explicit in [false, true] {
|
||||
let mut boundary = vec![a, b, a, c];
|
||||
if explicit {
|
||||
boundary.push(a);
|
||||
}
|
||||
let mut wipeout = Wipeout { boundary };
|
||||
assert_eq!(wipeout.set_vertex(0, to), Some(()));
|
||||
let mut expected = vec![to, b, a, c];
|
||||
if explicit {
|
||||
expected.push(to);
|
||||
}
|
||||
assert_eq!(wipeout.boundary, expected);
|
||||
for (index, point) in [(4, a), (usize::MAX, a), (0, Vec2::new(f64::NAN, 0.0)), (0, Vec2::new(0.0, f64::INFINITY))] {
|
||||
assert_eq!(wipeout.set_vertex(index, point), None);
|
||||
assert_eq!(wipeout.boundary, expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+78
-14
@@ -36,11 +36,26 @@ fn text_box(t: &crate::Text, len: usize) -> Bounds2 {
|
||||
/// as when drawing. Dimensional constraints of dynamic blocks (layer `*ADSK_CONSTRAINTS`) and the
|
||||
/// entities a visibility state hides are invisible this way, and they sit far from the block's
|
||||
/// geometry in real drawings. Layer "0" takes the insert's layer, which the caller has checked.
|
||||
fn in_block_visible(d: &Drawing, e: &Entity) -> bool {
|
||||
/// `off_counts`: a layer turned off counts too, as in the drawing's extents ([`extent_bounds`]).
|
||||
fn in_block_visible(d: &Drawing, e: &Entity, off_counts: bool) -> bool {
|
||||
// Attribute definitions are not drawn in an insert (its attribs are, see below).
|
||||
!matches!(e.kind, EntityKind::AttDef(_))
|
||||
&& e.common.visible
|
||||
&& (e.common.layer == "0" || d.layer(&e.common.layer).is_none_or(crate::Layer::visible))
|
||||
!matches!(e.kind, EntityKind::AttDef(_)) && e.common.visible && layer_counts(d, &e.common.layer, off_counts)
|
||||
}
|
||||
|
||||
/// Whether what lies on `layer` inside an insert counts: layer "0" takes the insert's layer, which
|
||||
/// the caller has checked; a frozen layer never counts, one turned off only with `off_counts`.
|
||||
fn layer_counts(d: &Drawing, layer: &str, off_counts: bool) -> bool {
|
||||
layer == "0" || d.layer(layer).is_none_or(|l| !l.frozen && (l.on || off_counts))
|
||||
}
|
||||
|
||||
/// The layer an insert's attribute draws on, as the renderer takes it: its own, else its
|
||||
/// definition's (layer "0" being the insert's).
|
||||
fn attrib_layer<'a>(d: &'a Drawing, ins: &crate::Insert, a: &'a crate::Attrib) -> &'a str {
|
||||
a.props.layer.as_deref().unwrap_or_else(|| {
|
||||
d.block(&ins.block)
|
||||
.and_then(|blk| blk.entities.iter().find(|be| matches!(&be.kind, EntityKind::AttDef(ad) if ad.tag.eq_ignore_ascii_case(&a.tag))))
|
||||
.map_or("0", |be| be.common.layer.as_str())
|
||||
})
|
||||
}
|
||||
|
||||
/// A text that draws no glyph (empty, or only blanks and non-breaking spaces) takes no room.
|
||||
@@ -52,7 +67,9 @@ fn blank(s: &str) -> bool {
|
||||
fn mtext_box(t: &crate::MText) -> Bounds2 {
|
||||
let lines = t.contents.split("\\P").count().max(1);
|
||||
let longest = t.contents.split("\\P").map(|l| l.chars().count()).max().unwrap_or(1);
|
||||
let mut w = if t.width > 0.0 { t.width } else { t.height * 0.8 * longest as f64 };
|
||||
let estimate = t.height * 0.8 * longest as f64;
|
||||
// The wrap width only narrows the box: a garbage width must not stretch the drawing extents (#2465).
|
||||
let mut w = if t.width > 0.0 { t.width.min(estimate) } else { estimate };
|
||||
let mut h = t.height * 1.66 * lines as f64;
|
||||
if let Some(c) = t.columns.as_ref().filter(|_| t.width > 0.0) {
|
||||
// Columns side by side, the text flowing down each in turn.
|
||||
@@ -75,16 +92,27 @@ fn mtext_box(t: &crate::MText) -> Bounds2 {
|
||||
Bounds2::from_points(pts)
|
||||
}
|
||||
|
||||
/// The box of what `e` draws (block contents on layers turned off left out).
|
||||
pub fn entity_bounds(d: &Drawing, e: &Entity, depth: usize) -> Bounds2 {
|
||||
bounds_of(d, e, depth, false)
|
||||
}
|
||||
|
||||
/// The box `e` takes in the drawing's extents: as [`entity_bounds`], but block contents on a layer
|
||||
/// turned off count, as AutoCAD 2024's ZOOM Extents counts them (a frozen layer's do not).
|
||||
pub fn extent_bounds(d: &Drawing, e: &Entity) -> Bounds2 {
|
||||
bounds_of(d, e, 0, true)
|
||||
}
|
||||
|
||||
fn bounds_of(d: &Drawing, e: &Entity, depth: usize, off_counts: bool) -> Bounds2 {
|
||||
// A top-level annotative object measures as it draws at the current annotation scale.
|
||||
if depth == 0 {
|
||||
match d.anno_draw(e) {
|
||||
crate::AnnoDraw::Plain => {}
|
||||
crate::AnnoDraw::Hidden => return Bounds2::EMPTY,
|
||||
crate::AnnoDraw::Scaled(f) => return bounds_in(d, &crate::anno_scaled(e, f), 0, &mut HashMap::new()),
|
||||
crate::AnnoDraw::Scaled(f) => return bounds_in(d, &crate::anno_scaled(e, f), 0, &mut HashMap::new(), off_counts),
|
||||
}
|
||||
}
|
||||
bounds_in(d, e, depth, &mut HashMap::new())
|
||||
bounds_in(d, e, depth, &mut HashMap::new(), off_counts)
|
||||
}
|
||||
|
||||
/// [`entity_bounds`] with the bounds of block contents (in block coordinates) already computed,
|
||||
@@ -93,11 +121,16 @@ pub fn entity_bounds(d: &Drawing, e: &Entity, depth: usize) -> Bounds2 {
|
||||
/// 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 {
|
||||
fn bounds_in<'a>(d: &'a Drawing, e: &Entity, depth: usize, blocks: &mut HashMap<(&'a str, usize), Bounds2>, off_counts: bool) -> Bounds2 {
|
||||
match &e.kind {
|
||||
EntityKind::Table(t) if t.is_broken() => t.bounds(),
|
||||
EntityKind::Text(t) if blank(&t.value) => Bounds2::EMPTY,
|
||||
EntityKind::MText(t) if blank(&t.contents) => Bounds2::EMPTY,
|
||||
// Text that matches the layout's orientation is drawn upright (#2498).
|
||||
EntityKind::Text(t) if e.common.matches_layout_orientation() => {
|
||||
text_box(&crate::Text { rotation: 0.0, ..t.clone() }, t.value.chars().count())
|
||||
}
|
||||
EntityKind::MText(t) if e.common.matches_layout_orientation() => mtext_box(&crate::MText { rotation: 0.0, ..t.clone() }),
|
||||
EntityKind::Text(t) => text_box(t, t.value.chars().count()),
|
||||
EntityKind::AttDef(a) => text_box(&a.text, a.tag.chars().count()),
|
||||
EntityKind::MText(t) => mtext_box(t),
|
||||
@@ -115,8 +148,8 @@ fn bounds_in<'a>(d: &'a Drawing, e: &Entity, depth: usize, blocks: &mut HashMap<
|
||||
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));
|
||||
for be in blk.entities.iter().filter(|be| in_block_visible(d, be, off_counts)) {
|
||||
inner = inner.union(&bounds_in(d, be, depth + 1, blocks, off_counts));
|
||||
}
|
||||
blocks.insert((blk.name.as_str(), depth), inner);
|
||||
inner
|
||||
@@ -131,7 +164,7 @@ fn bounds_in<'a>(d: &'a Drawing, e: &Entity, depth: usize, blocks: &mut HashMap<
|
||||
}
|
||||
}
|
||||
for a in &ins.attribs {
|
||||
if !a.invisible {
|
||||
if !a.invisible && layer_counts(d, attrib_layer(d, ins, a), off_counts) {
|
||||
b = b.union(&match a.multiline_text() {
|
||||
Some(m) => mtext_box(&m),
|
||||
None => text_box(&a.text, a.text.value.chars().count()),
|
||||
@@ -172,8 +205,8 @@ fn bounds_in<'a>(d: &'a Drawing, e: &Entity, depth: usize, blocks: &mut HashMap<
|
||||
Some(cached) => *cached,
|
||||
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));
|
||||
for be in blk.entities.iter().filter(|be| in_block_visible(d, be, off_counts)) {
|
||||
inner = inner.union(&bounds_in(d, be, depth + 1, blocks, off_counts));
|
||||
}
|
||||
blocks.insert((blk.name.as_str(), depth), inner);
|
||||
inner
|
||||
@@ -193,7 +226,7 @@ fn bounds_in<'a>(d: &'a Drawing, e: &Entity, depth: usize, blocks: &mut HashMap<
|
||||
}
|
||||
if let Some(ins) = &m.block {
|
||||
let be = Entity { handle: e.handle, common: e.common.clone(), kind: EntityKind::Insert(ins.clone()) };
|
||||
b = b.union(&bounds_in(d, &be, depth, blocks));
|
||||
b = b.union(&bounds_in(d, &be, depth, blocks, off_counts));
|
||||
}
|
||||
b
|
||||
}
|
||||
@@ -309,6 +342,37 @@ mod tests {
|
||||
entity_bounds(&Drawing::default(), &Entity::new(crate::Handle(1), kind), 0)
|
||||
}
|
||||
|
||||
fn mtext(contents: &str, width: f64) -> crate::MText {
|
||||
crate::MText {
|
||||
insert: Vec3::new(10.0, 10.0, 0.0),
|
||||
height: 2.5,
|
||||
width,
|
||||
attach: 1,
|
||||
rotation: 0.0,
|
||||
style: "Standard".into(),
|
||||
contents: contents.into(),
|
||||
line_spacing: 1.0,
|
||||
line_spacing_exact: false,
|
||||
columns: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A garbage wrap width only narrows an MTEXT's box, never widens it: a huge group 41 must not
|
||||
/// stretch the drawing extents (#2465).
|
||||
#[test]
|
||||
fn a_huge_mtext_wrap_width_does_not_inflate_the_extents() {
|
||||
let b = bounds(EntityKind::MText(mtext("AB", 300_000_009_519_104.0)));
|
||||
// "AB" at height 2.5 is 2 chars * 0.8 * 2.5 = 4 wide.
|
||||
assert!(b.max.x <= 10.0 + 4.0 + 1e-9, "box ends at x = {}", b.max.x);
|
||||
assert!(b.min.x >= 10.0 - 1e-9);
|
||||
}
|
||||
|
||||
/// A wrap width narrower than the text still bounds the box to that width.
|
||||
#[test]
|
||||
fn a_narrow_mtext_wrap_width_still_sets_the_box_width() {
|
||||
let b = bounds(EntityKind::MText(mtext("A LONG LINE OF WRAPPING TEXT", 5.0)));
|
||||
assert!((b.max.x - b.min.x - 5.0).abs() < 1e-9, "width {}", b.max.x - b.min.x);
|
||||
}
|
||||
#[test]
|
||||
fn right_justified_text_is_left_of_alignment_point() {
|
||||
let b = bounds(EntityKind::Text(text(HAlign::Right, VAlign::Baseline)));
|
||||
|
||||
+10
-4
@@ -27,7 +27,7 @@ pub use cadcraft_color as color;
|
||||
pub use cadcraft_geom as geom;
|
||||
pub use constraint::*;
|
||||
pub use entity::*;
|
||||
pub use extents::{MAX_BLOCK_DEPTH, entity_bounds};
|
||||
pub use extents::{MAX_BLOCK_DEPTH, entity_bounds, extent_bounds};
|
||||
pub use format::Format;
|
||||
pub use gdt::*;
|
||||
pub use header::{HVal, Header};
|
||||
@@ -369,13 +369,19 @@ impl Drawing {
|
||||
!self.hidden_objects.is_empty() && self.hidden_objects.contains(&h)
|
||||
}
|
||||
|
||||
/// Bounds of all visible entities in a space.
|
||||
/// Whether an entity counts in the extents: as AutoCAD 2024 counts it for ZOOM Extents and
|
||||
/// EXTMIN/EXTMAX, on a layer that is off too, but not on a frozen one.
|
||||
pub fn in_extents(&self, e: &Entity) -> bool {
|
||||
e.common.visible && self.layer(&e.common.layer).is_none_or(|l| !l.frozen) && !self.is_hidden_object(e.handle)
|
||||
}
|
||||
|
||||
/// Bounds of the entities in a space that count in its extents ([`Drawing::in_extents`]).
|
||||
pub fn extents(&self, space: &Space) -> cadcraft_geom::Bounds2 {
|
||||
let mut b = cadcraft_geom::Bounds2::EMPTY;
|
||||
if let Some(store) = self.space(space) {
|
||||
for e in store.iter() {
|
||||
if self.is_visible(e) {
|
||||
b = b.union(&entity_bounds(self, e, 0));
|
||||
if self.in_extents(e) {
|
||||
b = b.union(&extent_bounds(self, e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+110
-21
@@ -44,15 +44,85 @@ fn add_and_extents() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_layers_excluded_from_extents() {
|
||||
fn frozen_layers_are_left_out_of_extents_and_layers_turned_off_are_not() {
|
||||
let mut d = Drawing::new_imperial();
|
||||
let c = Common { layer: "Hidden".into(), ..Default::default() };
|
||||
d.add(&Space::Model, c, line((100.0, 100.0), (200.0, 200.0))).unwrap();
|
||||
d.add(&Space::Model, Common::default(), line((0.0, 0.0), (1.0, 1.0))).unwrap();
|
||||
d.layer_mut("Hidden").unwrap().on = false;
|
||||
assert_eq!(d.extents(&Space::Model).max, Vec2::new(200.0, 200.0));
|
||||
d.layer_mut("Hidden").unwrap().frozen = true;
|
||||
assert_eq!(d.extents(&Space::Model).max, Vec2::new(1.0, 1.0));
|
||||
}
|
||||
|
||||
/// ZOOM Extents in AutoCAD 2024 (accoreconsole, EXTMIN/EXTMAX read back; AutoCAD pads them by a
|
||||
/// few hundredths): a layer turned off counts and a frozen one does not, for an entity, for an
|
||||
/// entity inside a block, and for a block reference itself.
|
||||
#[test]
|
||||
fn extents_count_layers_turned_off_as_autocad() {
|
||||
let mut d = Drawing::new_imperial();
|
||||
for (name, on, frozen) in [("OFFL", false, false), ("FRZ", true, true), ("BOFF", false, false), ("BFRZ", true, true)] {
|
||||
let mut l = Layer::new(name);
|
||||
(l.on, l.frozen) = (on, frozen);
|
||||
d.layers.push(l);
|
||||
}
|
||||
for (layer, b) in [("0", (10.0, 10.0)), ("OFFL", (100.0, 0.0)), ("FRZ", (0.0, 200.0))] {
|
||||
d.add(&Space::Model, Common { layer: layer.into(), ..Default::default() }, line((0.0, 0.0), b)).unwrap();
|
||||
}
|
||||
// AutoCAD: (-0.013, -0.009)…(100.027, 10.006).
|
||||
assert_eq!(extents_pair(&d), (Vec2::ZERO, Vec2::new(100.0, 10.0)));
|
||||
|
||||
let mut d = Drawing::new_imperial();
|
||||
for (name, on, frozen) in [("FRZ", true, true), ("BOFF", false, false), ("BFRZ", true, true)] {
|
||||
let mut l = Layer::new(name);
|
||||
(l.on, l.frozen) = (on, frozen);
|
||||
d.layers.push(l);
|
||||
}
|
||||
let mut blk = Block::new("BX");
|
||||
blk.entities.push(on_layer("0", line((0.0, 0.0), (1.0, 1.0)), 1));
|
||||
blk.entities.push(on_layer("BOFF", line((0.0, 0.0), (50.0, 0.0)), 2));
|
||||
blk.entities.push(on_layer("BFRZ", line((0.0, 0.0), (0.0, 70.0)), 3));
|
||||
d.blocks.insert("BX".into(), std::sync::Arc::new(blk));
|
||||
let at = |x: f64| match plain_insert("BX") {
|
||||
EntityKind::Insert(i) => EntityKind::Insert(Insert { insert: Vec3::new(x, x, 0.0), ..i }),
|
||||
k => k,
|
||||
};
|
||||
d.add(&Space::Model, Common::default(), at(1000.0)).unwrap();
|
||||
d.add(&Space::Model, Common { layer: "FRZ".into(), ..Default::default() }, at(-500.0)).unwrap();
|
||||
// AutoCAD: (1000, 1000)…(1050, 1001).
|
||||
assert_eq!(extents_pair(&d), (Vec2::new(1000.0, 1000.0), Vec2::new(1050.0, 1001.0)));
|
||||
|
||||
// An insert's attributes lie on their own layer, else on their definition's: one on a frozen
|
||||
// layer either way does not count, one on a layer turned off does.
|
||||
let mut d = Drawing::new_imperial();
|
||||
for (name, on, frozen) in [("IOFF", false, false), ("AOFF", false, false), ("AFRZ", true, true)] {
|
||||
let mut l = Layer::new(name);
|
||||
(l.on, l.frozen) = (on, frozen);
|
||||
d.layers.push(l);
|
||||
}
|
||||
let attrib = |tag: &str, at: (f64, f64), layer: Option<&str>| {
|
||||
let mut a = plain_attrib(tag);
|
||||
a.text.insert = Vec3::new(at.0, at.1, 0.0);
|
||||
a.props.layer = layer.map(str::to_string);
|
||||
a
|
||||
};
|
||||
let mut blk = Block::new("BA");
|
||||
blk.entities.push(on_layer("0", line((0.0, 0.0), (1.0, 1.0)), 1));
|
||||
blk.entities.push(on_layer("AFRZ", EntityKind::AttDef(plain_attrib("DEF")), 2));
|
||||
d.blocks.insert("BA".into(), std::sync::Arc::new(blk));
|
||||
let ins = match plain_insert("BA") {
|
||||
EntityKind::Insert(i) => {
|
||||
let attribs =
|
||||
vec![attrib("OWN", (0.0, 300.0), Some("AFRZ")), attrib("DEF", (0.0, -400.0), None), attrib("OFF", (40.0, 0.0), Some("AOFF"))];
|
||||
EntityKind::Insert(Insert { attribs, ..i })
|
||||
}
|
||||
k => k,
|
||||
};
|
||||
d.add(&Space::Model, Common { layer: "IOFF".into(), ..Default::default() }, ins).unwrap();
|
||||
let (min, max) = extents_pair(&d);
|
||||
assert!(min.y > -1.0 && max.y < 50.0 && max.x > 40.0, "{min:?} {max:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rays_and_xlines_do_not_stretch_extents() {
|
||||
// ZOOM Extents ignores infinite lines; real drawings carry stray rays millions of units away.
|
||||
@@ -315,6 +385,31 @@ fn plain_insert(block: &str) -> EntityKind {
|
||||
})
|
||||
}
|
||||
|
||||
/// An attribute (or its definition) `tag` at the origin, one unit high, reading "x".
|
||||
fn plain_attrib(tag: &str) -> Attrib {
|
||||
Attrib {
|
||||
tag: tag.into(),
|
||||
text: Text {
|
||||
insert: Vec3::ZERO,
|
||||
align_pt: None,
|
||||
height: 1.0,
|
||||
value: "x".into(),
|
||||
rotation: 0.0,
|
||||
width_factor: 1.0,
|
||||
oblique: 0.0,
|
||||
style: "Standard".into(),
|
||||
halign: HAlign::Left,
|
||||
valign: VAlign::Baseline,
|
||||
},
|
||||
invisible: false,
|
||||
constant: false,
|
||||
prompt: String::new(),
|
||||
props: Default::default(),
|
||||
multiline: None,
|
||||
modes: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn on_layer(layer: &str, kind: EntityKind, h: u64) -> Entity {
|
||||
Entity { handle: Handle(h), common: Common { layer: layer.into(), ..Default::default() }, kind }
|
||||
}
|
||||
@@ -383,24 +478,18 @@ fn block_entity_with_invisible_flag_is_ignored_in_insert_extents() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_entity_on_off_or_frozen_layer_is_ignored() {
|
||||
for frozen in [false, true] {
|
||||
let mut d = drawing_with_block(far_line("Hidden"));
|
||||
assert!(d.layer("Hidden").is_none(), "block contents do not create layers");
|
||||
let mut layer = Layer::new("Hidden");
|
||||
if frozen {
|
||||
layer.frozen = true;
|
||||
} else {
|
||||
layer.on = false;
|
||||
}
|
||||
d.layers.push(layer);
|
||||
assert_eq!(extents_pair(&d), (Vec2::ZERO, Vec2::new(1.0, 1.0)), "frozen = {frozen}");
|
||||
// Turned back on, it counts again.
|
||||
let l = d.layer_mut("Hidden").unwrap();
|
||||
l.on = true;
|
||||
l.frozen = false;
|
||||
assert_eq!(d.extents(&Space::Model).max, Vec2::new(6000.0, 6000.0));
|
||||
}
|
||||
fn block_entity_on_frozen_layer_is_ignored_and_on_a_layer_turned_off_counts() {
|
||||
let mut d = drawing_with_block(far_line("Hidden"));
|
||||
assert!(d.layer("Hidden").is_none(), "block contents do not create layers");
|
||||
let mut layer = Layer::new("Hidden");
|
||||
layer.frozen = true;
|
||||
d.layers.push(layer);
|
||||
assert_eq!(extents_pair(&d), (Vec2::ZERO, Vec2::new(1.0, 1.0)));
|
||||
// Thawed but turned off, it counts.
|
||||
let l = d.layer_mut("Hidden").unwrap();
|
||||
l.frozen = false;
|
||||
l.on = false;
|
||||
assert_eq!(d.extents(&Space::Model).max, Vec2::new(6000.0, 6000.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -558,10 +647,10 @@ fn invisible_dimblock_entity_is_ignored_in_dimension_extents() {
|
||||
hidden.common.visible = false;
|
||||
dim(&mut d, hidden);
|
||||
assert!(d.extents(&Space::Model).max.x < 100.0);
|
||||
// Hidden by its layer state.
|
||||
// Hidden by its layer state (frozen; a layer turned off still counts).
|
||||
let mut d = Drawing::new_imperial();
|
||||
let mut l = Layer::new("*ADSK_CONSTRAINTS");
|
||||
l.on = false;
|
||||
l.frozen = true;
|
||||
d.layers.push(l);
|
||||
dim(&mut d, on_layer("*ADSK_CONSTRAINTS", line((5000.0, 5000.0), (6000.0, 6000.0)), 2));
|
||||
assert!(d.extents(&Space::Model).max.x < 100.0);
|
||||
|
||||
@@ -8,6 +8,9 @@ license.workspace = true
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
# The same targets as build.rs's `no_dwg_bridge` leave acadrust out: wasm32, and FreeBSD aarch64,
|
||||
# where acadrust 0.6.6 passes a `*const c_char` (u8 there) to its own `*const i8` sysctlbyname.
|
||||
# 0.6.7 and 0.6.8 fix that but read some damaged DWGs as empty with no note (tests.rs, #800).
|
||||
[target.'cfg(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64"))))'.dependencies]
|
||||
# MPL-2.0: used as an unmodified dependency only (see plan/adr/0001 and NOTICE).
|
||||
acadrust = "0.6.5"
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
//! Sets `no_dwg_bridge` on the targets built without acadrust (see Cargo.toml): the DWG functions
|
||||
//! there return an error that says to use DXF.
|
||||
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-changed=build.rs");
|
||||
println!("cargo::rustc-check-cfg=cfg(no_dwg_bridge)");
|
||||
let arch = std::env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default();
|
||||
let os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
|
||||
if arch == "wasm32" || (os == "freebsd" && arch == "aarch64") {
|
||||
println!("cargo::rustc-cfg=no_dwg_bridge");
|
||||
}
|
||||
}
|
||||
+196
-31
@@ -58,13 +58,13 @@ impl Limits {
|
||||
}
|
||||
|
||||
/// A byte count in megabytes for messages ("9.9 MB", "1024 MB").
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
#[cfg_attr(no_dwg_bridge, allow(dead_code))]
|
||||
fn mb(n: u64) -> String {
|
||||
let m = n as f64 / f64::from(1u32 << 20);
|
||||
if m >= 100.0 { format!("{m:.0} MB") } else { format!("{m:.1} MB") }
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[cfg(not(no_dwg_bridge))]
|
||||
mod native {
|
||||
use std::io::{Cursor, Write};
|
||||
use std::panic::AssertUnwindSafe;
|
||||
@@ -104,10 +104,7 @@ mod native {
|
||||
}
|
||||
let mut notes = read_notes(&outcome.stats);
|
||||
let mut doc = outcome.document;
|
||||
current_style_names(&mut doc);
|
||||
table_merges(&mut doc);
|
||||
table_style_rows(&mut doc, false);
|
||||
let orphans = drop_orphaned_dgn_components(&mut doc);
|
||||
let orphans = guarded(AssertUnwindSafe(|| post_process(&mut doc))).map_err(|_| "the DWG post-processing failed on this file".to_string())?;
|
||||
if orphans > 0 {
|
||||
let (s, were) = if orphans == 1 { ("", "was") } else { ("s", "were") };
|
||||
notes.push(format!("DWG: {orphans} DGN line style component{s} that no line style definition uses {were} left out"));
|
||||
@@ -237,6 +234,33 @@ mod native {
|
||||
/// cells already carry merges are left alone. Each range's first cell gets the span; the
|
||||
/// cells it covers are marked merged with no span of their own, as CADCraft's DXF writer does.
|
||||
fn table_merges(doc: &mut acadrust::CadDocument) {
|
||||
table_merges_within(doc, MAX_MERGE_CELLS);
|
||||
}
|
||||
|
||||
/// The most table cells [`table_merges`] visits in one document: each range is capped, but
|
||||
/// can cover rows × cols cells, so the total is bounded too.
|
||||
const MAX_MERGE_CELLS: usize = 1 << 24;
|
||||
|
||||
/// The fix-ups made to a document after the DWG read, returning how many orphaned DGN
|
||||
/// components were dropped. The document comes from a third-party reader and is hostile.
|
||||
fn post_process(doc: &mut acadrust::CadDocument) -> usize {
|
||||
super::fail_in_test("post_process");
|
||||
current_style_names(doc);
|
||||
table_merges(doc);
|
||||
table_style_rows(doc, false);
|
||||
keep_sort_handles(doc);
|
||||
drop_orphaned_dgn_components(doc)
|
||||
}
|
||||
|
||||
/// Runs `f`, turning a panic into an error (the reader and writer are guarded the same way).
|
||||
fn guarded<R>(f: impl FnOnce() -> R + std::panic::UnwindSafe) -> Result<R, ()> {
|
||||
std::panic::catch_unwind(f).map_err(|_| ())
|
||||
}
|
||||
|
||||
/// [`table_merges`] visiting at most `budget` cells in all; a range that would pass it is
|
||||
/// left unmerged. Returns the cells visited.
|
||||
fn table_merges_within(doc: &mut acadrust::CadDocument, budget: usize) -> usize {
|
||||
let mut left = budget;
|
||||
for e in doc.entities_mut() {
|
||||
let acadrust::entities::EntityType::Table(t) = e else { continue };
|
||||
if t.merged_ranges.is_empty() || t.rows.iter().flat_map(|r| &r.cells).any(|c| c.merge_width > 1 || c.merge_height > 1) {
|
||||
@@ -252,6 +276,11 @@ mod native {
|
||||
if r.top_row > bottom || r.left_col > right || rows == 0 || cols == 0 || (r.top_row, r.left_col) == (bottom, right) {
|
||||
continue;
|
||||
}
|
||||
let area = (bottom - r.top_row + 1).saturating_mul(right - r.left_col + 1);
|
||||
if area > left {
|
||||
continue;
|
||||
}
|
||||
left -= area;
|
||||
let span = |n: usize| i32::try_from(n + 1).unwrap_or(i32::MAX);
|
||||
for (ri, row) in t.rows.iter_mut().enumerate().take(bottom + 1).skip(r.top_row) {
|
||||
for (ci, cell) in row.cells.iter_mut().enumerate().take(right + 1).skip(r.left_col) {
|
||||
@@ -263,6 +292,28 @@ mod native {
|
||||
}
|
||||
}
|
||||
}
|
||||
budget - left
|
||||
}
|
||||
|
||||
/// Draw-order tables (SORTENTSTABLE) keep the sort handles the file gave them. acadrust's DXF
|
||||
/// writer renumbers them past every entity handle, but an entity left out of a table sorts by
|
||||
/// its own handle, so after renumbering it would draw below every listed entity (#2410). Giving
|
||||
/// the table its codes verbatim makes the writer emit them unchanged.
|
||||
fn keep_sort_handles(doc: &mut acadrust::CadDocument) {
|
||||
use acadrust::objects::ObjectType;
|
||||
let version = doc.version;
|
||||
for o in doc.objects.values_mut() {
|
||||
let ObjectType::SortEntitiesTable(t) = o else { continue };
|
||||
let hex = |h: acadrust::types::Handle| format!("{:X}", h.value());
|
||||
let mut codes =
|
||||
vec![(5, hex(t.handle)), (330, hex(t.owner_handle)), (100, "AcDbSortentsTable".to_string()), (330, hex(t.block_owner_handle))];
|
||||
for e in t.entries() {
|
||||
codes.push((331, hex(e.entity_handle)));
|
||||
codes.push((5, hex(e.sort_handle)));
|
||||
}
|
||||
t.raw_dxf_codes = Some(codes);
|
||||
t.raw_dxf_version = Some(version);
|
||||
}
|
||||
}
|
||||
|
||||
/// Table styles' title and header suppression between the two places acadrust 0.6.3 keeps
|
||||
@@ -512,6 +563,17 @@ mod native {
|
||||
}
|
||||
}
|
||||
|
||||
/// The current layer (`$CLAYER`) by its name, the same way as [`current_dimstyle`]: a DWG
|
||||
/// stores the handle of the layer, and the one read with the DXF needn't be the layer's in
|
||||
/// the document, so the drawing reopened with layer "0" current (#2252).
|
||||
fn current_layer(doc: &mut acadrust::CadDocument) {
|
||||
let name = doc.header.current_layer_name.clone();
|
||||
// The table's own lookup compares Unicode case-insensitively ("Survey_Ä" = "Survey_ä").
|
||||
if let Some(layer) = doc.layers.get(&name) {
|
||||
doc.header.current_layer_handle = layer.handle;
|
||||
}
|
||||
}
|
||||
|
||||
fn convert(dxf: &[u8], version: Option<acadrust::DxfVersion>) -> Result<Vec<u8>, String> {
|
||||
// Reject a pathologically large DXF before handing it to the third-party decoder, which
|
||||
// allocates tables and section buffers from the input's declared sizes and cannot be
|
||||
@@ -536,13 +598,19 @@ mod native {
|
||||
None if doc.version < MIN_DWG_VERSION => doc.version = MIN_DWG_VERSION,
|
||||
None => {}
|
||||
}
|
||||
other_layout_owners(&mut doc);
|
||||
table_style_rows(&mut doc, true);
|
||||
dimension_flags(&mut doc);
|
||||
drop_unwritable(&mut doc);
|
||||
current_dimstyle(&mut doc);
|
||||
add_variable_dictionary(&mut doc);
|
||||
current_text_style(&mut doc);
|
||||
// The fix-ups run on the third-party reader's document, so a panic in one is an error, not a crash.
|
||||
guarded(AssertUnwindSafe(|| {
|
||||
super::fail_in_test("convert");
|
||||
other_layout_owners(&mut doc);
|
||||
table_style_rows(&mut doc, true);
|
||||
dimension_flags(&mut doc);
|
||||
drop_unwritable(&mut doc);
|
||||
current_dimstyle(&mut doc);
|
||||
add_variable_dictionary(&mut doc);
|
||||
current_text_style(&mut doc);
|
||||
current_layer(&mut doc);
|
||||
}))
|
||||
.map_err(|_| "the DXF to DWG conversion failed on this file".to_string())?;
|
||||
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}"))
|
||||
@@ -639,43 +707,140 @@ mod native {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod post_tests {
|
||||
use super::*;
|
||||
use acadrust::entities::{CellRange, EntityType, Table};
|
||||
|
||||
fn doc_with_table(rows: usize, cols: usize, ranges: usize) -> acadrust::CadDocument {
|
||||
let mut t = Table::new(acadrust::types::Vector3::new(0.0, 0.0, 0.0), rows, cols);
|
||||
t.merged_ranges = (0..ranges).map(|_| CellRange::new(0, 0, rows - 1, cols - 1)).collect();
|
||||
let mut doc = acadrust::CadDocument::new();
|
||||
assert!(doc.add_entity(EntityType::Table(Box::new(t))).is_ok());
|
||||
doc
|
||||
}
|
||||
|
||||
/// Overlapping whole-table ranges can't make the merge pass visit more than its budget (#1934).
|
||||
#[test]
|
||||
fn merge_work_is_bounded_by_the_cell_budget() {
|
||||
let mut doc = doc_with_table(10, 10, 50);
|
||||
assert_eq!(table_merges_within(&mut doc, 250), 200, "two whole-table ranges fit, the rest are skipped");
|
||||
let mut doc = doc_with_table(10, 10, 1);
|
||||
assert_eq!(table_merges_within(&mut doc, usize::MAX), 100);
|
||||
}
|
||||
|
||||
/// A panic in the post-read fix-ups is an error, not a crash (#1934).
|
||||
#[test]
|
||||
fn a_panic_in_post_processing_is_an_error() {
|
||||
#[allow(clippy::panic)]
|
||||
let r = guarded(AssertUnwindSafe(|| -> usize { panic!("boom") }));
|
||||
assert!(r.is_err());
|
||||
assert_eq!(guarded(|| 3), Ok(3));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[cfg(not(no_dwg_bridge))]
|
||||
pub use native::{dwg_to_dxf, dwg_to_dxf_noted, dwg_to_dxf_with, dxf_to_dwg, dxf_to_dwg_as};
|
||||
|
||||
// Test-only: the stage ("post_process" or "convert") whose guard the current test checks. The
|
||||
// stage then panics on entry, so a test through the public API shows whether it is guarded.
|
||||
#[cfg(all(test, not(no_dwg_bridge)))]
|
||||
thread_local! {
|
||||
static PANIC_IN: std::cell::Cell<Option<&'static str>> = const { std::cell::Cell::new(None) };
|
||||
}
|
||||
|
||||
/// Panics when a test asked for `stage` to fail (see [`PANIC_IN`]); does nothing otherwise.
|
||||
#[cfg(not(no_dwg_bridge))]
|
||||
#[allow(clippy::panic)]
|
||||
fn fail_in_test(_stage: &str) {
|
||||
#[cfg(test)]
|
||||
if PANIC_IN.with(|p| p.get()) == Some(_stage) {
|
||||
panic!("test-forced panic in {_stage}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The error for a DWG version CADCraft can't write.
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
#[cfg_attr(no_dwg_bridge, allow(dead_code))]
|
||||
fn unsupported(version: &str) -> String {
|
||||
let list: Vec<&str> = VERSIONS.iter().map(|(_, year)| *year).collect();
|
||||
format!("DWG version `{version}` can't be written (CADCraft writes {})", list.join(", "))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
/// Which build has no DWG bridge, for the stubs' errors.
|
||||
#[cfg(no_dwg_bridge)]
|
||||
const NO_BRIDGE_BUILD: &str = if cfg!(target_arch = "wasm32") { "web" } else { "FreeBSD aarch64" };
|
||||
|
||||
#[cfg(no_dwg_bridge)]
|
||||
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())
|
||||
Err(format!("DWG files can't be opened in the {NO_BRIDGE_BUILD} build yet; save as DXF"))
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
#[cfg(no_dwg_bridge)]
|
||||
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())
|
||||
Err(format!("DWG files can't be opened in the {NO_BRIDGE_BUILD} build yet; save as DXF"))
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
#[cfg(no_dwg_bridge)]
|
||||
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())
|
||||
Err(format!("DWG files can't be opened in the {NO_BRIDGE_BUILD} build yet; save as DXF"))
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
#[cfg(no_dwg_bridge)]
|
||||
pub fn dxf_to_dwg(_dxf: &[u8]) -> Result<Vec<u8>, String> {
|
||||
Err("DWG files can't be written in the web build yet; save as DXF".into())
|
||||
Err(format!("DWG files can't be written in the {NO_BRIDGE_BUILD} build yet; save as DXF"))
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
#[cfg(no_dwg_bridge)]
|
||||
pub fn dxf_to_dwg_as(_dxf: &[u8], _version: &str) -> Result<Vec<u8>, String> {
|
||||
Err("DWG files can't be written in the web build yet; save as DXF".into())
|
||||
Err(format!("DWG files can't be written in the {NO_BRIDGE_BUILD} build yet; save as DXF"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The DXF of an empty drawing, enough for the bridge to make a small DWG.
|
||||
#[cfg(not(no_dwg_bridge))]
|
||||
const MINIMAL_DXF: &str = " 0\nSECTION\n 2\nHEADER\n 9\n$ACADVER\n 1\nAC1032\n 0\nENDSEC\n 0\nEOF\n";
|
||||
|
||||
/// Makes `stage` panic on the current thread until dropped.
|
||||
#[cfg(not(no_dwg_bridge))]
|
||||
struct PanicIn;
|
||||
|
||||
#[cfg(not(no_dwg_bridge))]
|
||||
impl PanicIn {
|
||||
fn at(stage: &'static str) -> Self {
|
||||
PANIC_IN.with(|p| p.set(Some(stage)));
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(no_dwg_bridge))]
|
||||
impl Drop for PanicIn {
|
||||
fn drop(&mut self) {
|
||||
PANIC_IN.with(|p| p.set(None));
|
||||
}
|
||||
}
|
||||
|
||||
/// The public read reports a panic in the post-read fix-ups as an error: this fails if the
|
||||
/// `guarded` call around `post_process` is taken out (#2402).
|
||||
#[cfg(not(no_dwg_bridge))]
|
||||
#[test]
|
||||
fn reading_a_dwg_survives_a_panic_in_post_processing() {
|
||||
let dwg = dxf_to_dwg(MINIMAL_DXF.as_bytes()).unwrap();
|
||||
assert!(dwg_to_dxf(&dwg).is_ok(), "the DWG reads without the forced panic");
|
||||
let _panic = PanicIn::at("post_process");
|
||||
let err = dwg_to_dxf(&dwg).unwrap_err();
|
||||
assert!(err.contains("the DWG post-processing failed on this file"), "{err}");
|
||||
}
|
||||
|
||||
/// The DXF to DWG conversion reports a panic in its fix-ups as an error, not a crash (#2402).
|
||||
#[cfg(not(no_dwg_bridge))]
|
||||
#[test]
|
||||
fn converting_to_dwg_survives_a_panic_in_the_fix_ups() {
|
||||
let _panic = PanicIn::at("convert");
|
||||
let err = dxf_to_dwg(MINIMAL_DXF.as_bytes()).unwrap_err();
|
||||
assert!(err.contains("the DXF to DWG conversion failed on this file"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_magic() {
|
||||
assert!(is_dwg(b"AC1032\0\0\0\0"));
|
||||
@@ -685,7 +850,7 @@ mod tests {
|
||||
|
||||
/// A DXF with ROMANS as `$TEXTSTYLE` gives a DWG whose current text style is ROMANS, not
|
||||
/// Standard: the DXF names the style, and the handle read with it was Standard's (#1650).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[cfg(not(no_dwg_bridge))]
|
||||
#[test]
|
||||
fn current_text_style_reaches_the_dwg() {
|
||||
let header = " 0\nSECTION\n 2\nHEADER\n 9\n$ACADVER\n 1\nAC1032\n 9\n$TEXTSTYLE\n 7\nROMANS\n 0\nENDSEC\n";
|
||||
@@ -705,7 +870,7 @@ mod tests {
|
||||
assert!(dwg_to_dxf(b"").is_err());
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[cfg(not(no_dwg_bridge))]
|
||||
#[test]
|
||||
fn dxf_to_dwg_rejects_garbage_without_crashing() {
|
||||
// The DXF→DWG path runs the third-party reader inside catch_unwind and reports failures
|
||||
@@ -715,7 +880,7 @@ mod tests {
|
||||
}
|
||||
|
||||
/// A DXF with one DIMENSION per (text, group 70, subclass, points) row.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[cfg(not(no_dwg_bridge))]
|
||||
fn dims_dxf(dims: &[(&str, i64, &str, &[(i32, [f64; 2])])]) -> String {
|
||||
let mut s = String::from(" 0\nSECTION\n 2\nHEADER\n 9\n$ACADVER\n 1\nAC1032\n 0\nENDSEC\n 0\nSECTION\n 2\nENTITIES\n");
|
||||
for (text, flags, sub, pts) in dims {
|
||||
@@ -732,7 +897,7 @@ mod tests {
|
||||
/// the text is at its default position), and bit 32 in bit 1 (ODA DWG specification, common
|
||||
/// dimension data). The bytes are read back raw, so a reader that inverts bit 0 the same way
|
||||
/// as the writer can't hide a wrong byte.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[cfg(not(no_dwg_bridge))]
|
||||
#[test]
|
||||
fn dimension_text_position_flags_reach_the_dwg_as_autocad_reads_them() {
|
||||
let linear: &[(i32, [f64; 2])] = &[(13, [0.0, 0.0]), (14, [100.0, 0.0])];
|
||||
@@ -779,7 +944,7 @@ mod tests {
|
||||
}
|
||||
|
||||
/// The handles a DXF text refers to or gives its records (groups 5, 105 and 330–369).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[cfg(not(no_dwg_bridge))]
|
||||
fn dxf_handles(dxf: &str) -> std::collections::HashSet<u64> {
|
||||
let lines: Vec<&str> = dxf.lines().collect();
|
||||
lines
|
||||
@@ -796,7 +961,7 @@ mod tests {
|
||||
/// 1.2 GB DXF rendition over the limit, so it didn't open). They're left out with a note, and
|
||||
/// their dictionary entries with them; the definition and every component it reaches —
|
||||
/// through compound and point components, around a cycle — are kept.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[cfg(not(no_dwg_bridge))]
|
||||
#[test]
|
||||
fn orphaned_dgn_line_style_components_are_left_out() {
|
||||
use acadrust::objects::*;
|
||||
|
||||
@@ -69,6 +69,42 @@ pub fn decode_code_page(bytes: &[u8], code_page: &str) -> Option<String> {
|
||||
decoder(code_page).map(|d| decode(bytes, d).into_owned())
|
||||
}
|
||||
|
||||
/// The code page a pre-2007 file's text is written in, or `None` for pure ASCII with escapes:
|
||||
/// when CADCraft doesn't know `code_page`, and for `ASCII` (the reader takes such files as
|
||||
/// Windows-1252, but a writer must keep them ASCII) and `UTF8`/`UNICODE`, which no pre-2007
|
||||
/// reader expects.
|
||||
pub(crate) fn encoder(code_page: &str) -> Option<Decoder> {
|
||||
if code_page.trim().eq_ignore_ascii_case("ASCII") {
|
||||
return None;
|
||||
}
|
||||
decoder(code_page).filter(|d| !matches!(d, Decoder::Utf8))
|
||||
}
|
||||
|
||||
/// Append the bytes of the non-ASCII character `c` in the code page `d`, and return true, when
|
||||
/// the code page has it; otherwise append nothing and return false (the caller escapes it).
|
||||
pub(crate) fn encode_char(c: char, d: Decoder, out: &mut Vec<u8>) -> bool {
|
||||
let mut buf = [0; 4];
|
||||
match d {
|
||||
// Not a pre-2007 code page ([`encoder`] never gives it): escaped.
|
||||
Decoder::Utf8 => return false,
|
||||
Decoder::Encoding(e) => {
|
||||
let (bytes, _, unmappable) = e.encode(c.encode_utf8(&mut buf));
|
||||
// A code page byte that is ASCII would read back as another character, and a NUL,
|
||||
// CR or LF would break the group, so only all-high-bit sequences or proper double-byte
|
||||
// characters are kept (their trail byte may be ASCII, as in Shift-JIS).
|
||||
if unmappable || bytes.is_empty() || !bytes.first().is_some_and(|b| *b >= 0x80) || bytes.iter().any(|b| matches!(b, 0 | b'\r' | b'\n')) {
|
||||
return false;
|
||||
}
|
||||
out.extend_from_slice(&bytes);
|
||||
}
|
||||
Decoder::Table(t) => {
|
||||
let Some(i) = t.iter().position(|&x| x == c).and_then(|i| u8::try_from(i).ok()) else { return false };
|
||||
out.push(0x80 | i);
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Decode bytes with a decoder. UTF-8 that isn't valid falls back to Windows-1252.
|
||||
pub(crate) fn decode(bytes: &[u8], d: Decoder) -> Cow<'_, str> {
|
||||
match d {
|
||||
|
||||
+106
-24
@@ -8,6 +8,7 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use std::fmt::Write as _;
|
||||
use std::io::Write as _;
|
||||
|
||||
mod version;
|
||||
pub use version::Version;
|
||||
@@ -181,8 +182,20 @@ pub fn parse_with_limit(bytes: &[u8], max_tags: usize) -> Result<Vec<Tag>> {
|
||||
if let Some(body) = bytes.strip_prefix(BINARY_SENTINEL) {
|
||||
return parse_binary_with_limit(body, max_tags);
|
||||
}
|
||||
// Every group code takes two lines (code, value).
|
||||
let tags = bytes.iter().filter(|&&b| b == b'\n').count().div_ceil(2);
|
||||
// Count nonblank group-code/value pairs, stopping at EOF just as the parser does.
|
||||
let mut lines = bytes.split(|&b| b == b'\n');
|
||||
let mut tags = 0;
|
||||
while let Some(code) = lines.next() {
|
||||
let code = code.trim_ascii();
|
||||
if code.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some(value) = lines.next() else { break };
|
||||
tags += 1;
|
||||
if code == b"0" && value.trim_ascii() == b"EOF" {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if tags > max_tags {
|
||||
return Err(DxfError::TooLarge { tags, limit: max_tags });
|
||||
}
|
||||
@@ -264,7 +277,7 @@ pub fn parse_ascii(text: &str) -> Result<Vec<Tag>> {
|
||||
#[allow(clippy::while_let_loop)]
|
||||
fn parse_ascii_with_limit(text: &str, max_tags: usize) -> Result<Vec<Tag>> {
|
||||
let mut tags = Vec::new();
|
||||
let mut lines = text.lines().enumerate();
|
||||
let mut lines = text.strip_prefix('\u{feff}').unwrap_or(text).lines().enumerate();
|
||||
loop {
|
||||
let Some((ln, code_line)) = lines.next() else { break };
|
||||
let ct = code_line.trim();
|
||||
@@ -409,17 +422,48 @@ pub fn write_ascii(tags: &[Tag]) -> String {
|
||||
/// characters as they are) or escaped as `\U+XXXX` (older versions, see [`write_ascii`]). Line
|
||||
/// breaks inside strings become spaces either way.
|
||||
pub fn write_ascii_as(tags: &[Tag], utf8: bool) -> String {
|
||||
let mut out = String::with_capacity(tags.len() * 16);
|
||||
let bytes = ascii(tags, if utf8 { Strings::Utf8 } else { Strings::Escaped(None) });
|
||||
// Both forms are UTF-8 (the escaped one is ASCII).
|
||||
String::from_utf8(bytes).unwrap_or_else(|e| String::from_utf8_lossy(e.as_bytes()).into_owned())
|
||||
}
|
||||
|
||||
/// Write tags as pre-R2007 ASCII DXF with text in the code page `code_page` names (a
|
||||
/// `$DWGCODEPAGE` value such as `ANSI_1252`), as the DXF Reference describes for those versions:
|
||||
/// a character the code page has is written as its bytes, any other as a `\U+XXXX` escape. A
|
||||
/// code page CADCraft doesn't know, `ASCII`, and `UTF8`/`UNICODE` (not a pre-2007 code page)
|
||||
/// give [`write_ascii`]'s pure ASCII output. So does a file whose code page bytes happen to be
|
||||
/// valid UTF-8 (`СІ` in ANSI_1251 is D1 B2, U+0472), since readers, ours included, take an ASCII
|
||||
/// DXF that is valid UTF-8 as UTF-8.
|
||||
pub fn write_ascii_in(tags: &[Tag], code_page: &str) -> Vec<u8> {
|
||||
let bytes = ascii(tags, Strings::Escaped(codepage::encoder(code_page)));
|
||||
if !bytes.is_ascii() && std::str::from_utf8(&bytes).is_ok() {
|
||||
return ascii(tags, Strings::Escaped(None));
|
||||
}
|
||||
bytes
|
||||
}
|
||||
|
||||
/// How a writer stores string values.
|
||||
#[derive(Clone, Copy)]
|
||||
enum Strings {
|
||||
/// As they are (R2007+).
|
||||
Utf8,
|
||||
/// ASCII as it is, other characters in this code page when it has them, otherwise as
|
||||
/// `\U+XXXX` escapes (before R2007).
|
||||
Escaped(Option<codepage::Decoder>),
|
||||
}
|
||||
|
||||
fn ascii(tags: &[Tag], strings: Strings) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(tags.len() * 16);
|
||||
for t in tags {
|
||||
let _ = write!(out, "{:>3}\r\n", t.code);
|
||||
match &t.value {
|
||||
Value::Str(s) => escape_unicode(s, utf8, &mut out),
|
||||
Value::Real(r) => out.push_str(&format_real(*r)),
|
||||
Value::Int(i) => out.push_str(&i.to_string()),
|
||||
Value::Bool(b) => out.push_str(&i64::from(*b).to_string()),
|
||||
Value::Hex(h) => out.push_str(h),
|
||||
Value::Str(s) => push_string(s, strings, &mut out),
|
||||
Value::Real(r) => out.extend_from_slice(format_real(*r).as_bytes()),
|
||||
Value::Int(i) => out.extend_from_slice(i.to_string().as_bytes()),
|
||||
Value::Bool(b) => out.extend_from_slice(i64::from(*b).to_string().as_bytes()),
|
||||
Value::Hex(h) => out.extend_from_slice(h.as_bytes()),
|
||||
}
|
||||
out.push_str("\r\n");
|
||||
out.extend_from_slice(b"\r\n");
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -432,6 +476,16 @@ pub fn write_ascii_as(tags: &[Tag], utf8: bool) -> String {
|
||||
/// in [`write_ascii`]. A tag's value is converted to its code's kind, so a number written as text
|
||||
/// is stored as a number; a code outside the 16-bit range is skipped.
|
||||
pub fn write_binary(tags: &[Tag], utf8: bool) -> Vec<u8> {
|
||||
binary(tags, if utf8 { Strings::Utf8 } else { Strings::Escaped(None) })
|
||||
}
|
||||
|
||||
/// Write tags as pre-R2007 binary DXF with text in the code page `code_page` names, as
|
||||
/// [`write_ascii_in`] does.
|
||||
pub fn write_binary_in(tags: &[Tag], code_page: &str) -> Vec<u8> {
|
||||
binary(tags, Strings::Escaped(codepage::encoder(code_page)))
|
||||
}
|
||||
|
||||
fn binary(tags: &[Tag], strings: Strings) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(BINARY_SENTINEL.len() + tags.len() * 12);
|
||||
out.extend_from_slice(BINARY_SENTINEL);
|
||||
for t in tags {
|
||||
@@ -444,9 +498,9 @@ pub fn write_binary(tags: &[Tag], utf8: bool) -> Vec<u8> {
|
||||
Value::Str(s) => s.clone(),
|
||||
v => v.as_str(),
|
||||
};
|
||||
let mut text = String::with_capacity(s.len());
|
||||
escape_unicode(&s, utf8, &mut text);
|
||||
out.extend(text.bytes().filter(|&b| b != 0));
|
||||
let mut text = Vec::with_capacity(s.len());
|
||||
push_string(&s, strings, &mut text);
|
||||
out.extend(text.into_iter().filter(|&b| b != 0));
|
||||
out.push(0);
|
||||
}
|
||||
Kind::Real => {
|
||||
@@ -500,19 +554,27 @@ pub fn is_binary(bytes: &[u8]) -> bool {
|
||||
bytes.starts_with(BINARY_SENTINEL)
|
||||
}
|
||||
|
||||
/// Escape a string for a pre-R2007 DXF, whose text is in the `$DWGCODEPAGE` code page: ASCII
|
||||
/// stays as it is and every other character becomes `\U+XXXX` (a surrogate pair of escapes
|
||||
/// above U+FFFF), so the bytes are valid in any code page; with `utf8` they stay as they are.
|
||||
/// A backslash that would start a `\U+` or `\M+` sequence is written as `\U+005C` so the reader
|
||||
/// gives back the literal text. Line breaks become spaces.
|
||||
fn escape_unicode(s: &str, utf8: bool, out: &mut String) {
|
||||
/// A string value as `strings` stores it. Line breaks become spaces. Before R2007 the text is
|
||||
/// in the `$DWGCODEPAGE` code page: ASCII stays as it is, a character the code page has becomes
|
||||
/// its bytes, and every other one `\U+XXXX` (a surrogate pair of escapes above U+FFFF). Without
|
||||
/// a code page the result is ASCII, so it is valid in any code page. In every form a backslash
|
||||
/// that would start a `\U+` or `\M+` sequence is written as `\U+005C`, so the reader gives back
|
||||
/// the literal text (#2050). So is a double-byte character whose trail byte is 0x5C (`\`) in that
|
||||
/// place, such as Shift-JIS `表` or Big5 `許`: CADCraft decodes the code page before it looks for
|
||||
/// escapes, but a reader that looks first would take that byte and the `U+` after it for one.
|
||||
fn push_string(s: &str, strings: Strings, out: &mut Vec<u8>) {
|
||||
let chars: Vec<char> = s.chars().collect();
|
||||
let escape_at = |j: usize| matches!(chars.get(j), Some('U' | 'M')) && chars.get(j + 1) == Some(&'+');
|
||||
for (i, &c) in chars.iter().enumerate() {
|
||||
match c {
|
||||
'\r' | '\n' => out.push(' '),
|
||||
'\\' if matches!(chars.get(i + 1), Some('U' | 'M')) && chars.get(i + 2) == Some(&'+') => out.push_str("\\U+005C"),
|
||||
c if utf8 || c.is_ascii() => out.push(c),
|
||||
c => {
|
||||
match (c, strings) {
|
||||
('\r' | '\n', _) => out.push(b' '),
|
||||
('\\', _) if escape_at(i + 1) => {
|
||||
out.extend_from_slice(b"\\U+005C");
|
||||
}
|
||||
(c, Strings::Utf8) => out.extend_from_slice(c.encode_utf8(&mut [0; 4]).as_bytes()),
|
||||
(c, _) if c.is_ascii() => out.extend_from_slice(c.encode_utf8(&mut [0; 4]).as_bytes()),
|
||||
(c, Strings::Escaped(Some(d))) if code_page_bytes(c, d, escape_at(i + 1), out) => {}
|
||||
(c, Strings::Escaped(_)) => {
|
||||
let mut units = [0u16; 2];
|
||||
for u in c.encode_utf16(&mut units) {
|
||||
let _ = write!(out, "\\U+{u:04X}");
|
||||
@@ -522,6 +584,20 @@ fn escape_unicode(s: &str, utf8: bool, out: &mut String) {
|
||||
}
|
||||
}
|
||||
|
||||
/// `c` in the code page `d`, if it has it, unless it is a double-byte character whose trail byte
|
||||
/// is 0x5C and an escape's `U+` or `M+` follows (`before_escape`): see [`push_string`].
|
||||
fn code_page_bytes(c: char, d: codepage::Decoder, before_escape: bool, out: &mut Vec<u8>) -> bool {
|
||||
let start = out.len();
|
||||
if !codepage::encode_char(c, d, out) {
|
||||
return false;
|
||||
}
|
||||
if before_escape && out.len() - start > 1 && out.last() == Some(&b'\\') {
|
||||
out.truncate(start);
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// A section: `0 SECTION / 2 NAME ... 0 ENDSEC`.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Section {
|
||||
@@ -581,6 +657,12 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blank_lines_do_not_consume_group_code_limit() {
|
||||
let tags = parse_with_limit(b"0\nEOF\n\n\n", 1).expect("one group code should fit a limit of one");
|
||||
assert_eq!(tags, vec![Tag::s(0, "EOF")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ascii_roundtrip() {
|
||||
let tags = vec![
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
//! A UTF-8 byte order mark at the start of an ASCII DXF is skipped.
|
||||
|
||||
const BODY: &str = " 0\r\nSECTION\r\n 2\r\nENTITIES\r\n 0\r\nENDSEC\r\n 0\r\nEOF\r\n";
|
||||
|
||||
#[test]
|
||||
fn utf8_bom_bytes_are_skipped() {
|
||||
let plain = cadcraft_dxf::parse(BODY.as_bytes()).expect("plain parses");
|
||||
let mut bytes = vec![0xEF, 0xBB, 0xBF];
|
||||
bytes.extend_from_slice(BODY.as_bytes());
|
||||
let with_bom = cadcraft_dxf::parse(&bytes);
|
||||
assert!(with_bom.is_ok(), "{with_bom:?}");
|
||||
assert_eq!(with_bom.map(|t| t.len()).unwrap_or(0), plain.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bom_text_is_skipped() {
|
||||
let text = format!("\u{feff}{BODY}");
|
||||
assert!(cadcraft_dxf::parse_ascii(&text).is_ok());
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
//! Pre-R2007 DXF text is written in the `$DWGCODEPAGE` code page: characters it has as their
|
||||
//! bytes, the others as `\U+XXXX` escapes; every form reads back to the same text.
|
||||
|
||||
use cadcraft_dxf::{Tag, parse, write_ascii, write_ascii_in, write_binary_in};
|
||||
|
||||
fn file(code_page: &str, text: &str) -> Vec<Tag> {
|
||||
vec![
|
||||
Tag::s(0, "SECTION"),
|
||||
Tag::s(2, "HEADER"),
|
||||
Tag::s(9, "$ACADVER"),
|
||||
Tag::s(1, "AC1015"),
|
||||
Tag::s(9, "$DWGCODEPAGE"),
|
||||
Tag::s(3, code_page),
|
||||
Tag::s(0, "ENDSEC"),
|
||||
Tag::s(0, "SECTION"),
|
||||
Tag::s(2, "ENTITIES"),
|
||||
Tag::s(0, "TEXT"),
|
||||
Tag::s(1, text),
|
||||
Tag::s(0, "ENDSEC"),
|
||||
Tag::s(0, "EOF"),
|
||||
]
|
||||
}
|
||||
|
||||
fn contains(bytes: &[u8], part: &[u8]) -> bool {
|
||||
bytes.windows(part.len()).any(|w| w == part)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_is_written_in_the_code_page() {
|
||||
// (code page, text, the bytes written for it)
|
||||
let cases: [(&str, &str, &[u8]); 6] = [
|
||||
("ANSI_1252", "Ação € 图", b"A\xE7\xE3o \x80 \\U+56FE"),
|
||||
("ANSI_1251", "Слой é", b"\xD1\xEB\xEE\xE9 \\U+00E9"),
|
||||
("ANSI_932", "表図面 é", b"\x95\x5C\x90\x7D\x96\xCA \\U+00E9"),
|
||||
("DOS850", "Größe €", b"Gr\x94\xE1e \\U+20AC"),
|
||||
("ANSI_1252", "😀", b"\\U+D83D\\U+DE00"),
|
||||
// A code page CADCraft doesn't know: everything non-ASCII is escaped.
|
||||
("ANSI_1361", "Ação", b"A\\U+00E7\\U+00E3o"),
|
||||
];
|
||||
for (cp, text, raw) in cases {
|
||||
let tags = file(cp, text);
|
||||
for (bytes, what) in [(write_ascii_in(&tags, cp), "ASCII"), (write_binary_in(&tags, cp), "binary")] {
|
||||
assert!(contains(&bytes, raw), "{cp} {what}: {:?}", String::from_utf8_lossy(&bytes));
|
||||
assert_eq!(parse(&bytes).unwrap()[10].str(), text, "{cp} {what}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Without a known code page, and for `ASCII`, `UTF8` and `UNICODE` headers, the output is what
|
||||
/// `write_ascii` writes (pure ASCII with escapes), line breaks included.
|
||||
#[test]
|
||||
fn unknown_ascii_and_utf8_code_pages_are_pure_ascii() {
|
||||
for cp in ["EBCDIC", "ASCII", "ascii ", "UTF8", "UNICODE"] {
|
||||
let tags = file(cp, "Ação\r\nfim");
|
||||
let bytes = write_ascii_in(&tags, cp);
|
||||
assert!(bytes.is_ascii(), "{cp}");
|
||||
assert_eq!(bytes, write_ascii(&tags).into_bytes(), "{cp}");
|
||||
assert!(contains(&bytes, b"A\\U+00E7\\U+00E3o fim"), "{cp}");
|
||||
assert!(write_binary_in(&tags, cp).iter().all(u8::is_ascii), "{cp} binary");
|
||||
}
|
||||
}
|
||||
|
||||
/// Code page bytes that happen to form valid UTF-8 (`СІ` in ANSI_1251 is D1 B2, which is U+0472
|
||||
/// in UTF-8) would be read back as UTF-8 from an ASCII file, so that file is written with
|
||||
/// escapes. Binary DXF reads strings by the header's code page and keeps the bytes.
|
||||
#[test]
|
||||
fn code_page_bytes_that_are_valid_utf8_are_escaped_in_ascii() {
|
||||
let tags = file("ANSI_1251", "СІ");
|
||||
let ascii = write_ascii_in(&tags, "ANSI_1251");
|
||||
assert!(ascii.is_ascii(), "{:?}", String::from_utf8_lossy(&ascii));
|
||||
assert!(contains(&ascii, b"\\U+0421\\U+0406"));
|
||||
assert_eq!(parse(&ascii).unwrap()[10].str(), "СІ");
|
||||
let binary = write_binary_in(&tags, "ANSI_1251");
|
||||
assert!(contains(&binary, b"\xD1\xB2"));
|
||||
assert_eq!(parse(&binary).unwrap()[10].str(), "СІ");
|
||||
// Bytes that are not valid UTF-8 stay as they are.
|
||||
assert!(contains(&write_ascii_in(&file("ANSI_1251", "Слой"), "ANSI_1251"), b"\xD1\xEB\xEE\xE9"));
|
||||
}
|
||||
|
||||
/// A literal `\U+` or `\M+` in the text (#2050) reads back as written alongside code page bytes,
|
||||
/// in ASCII and binary, including an ASCII file that falls back to escapes because its bytes
|
||||
/// would read as UTF-8.
|
||||
#[test]
|
||||
fn literal_escapes_survive_code_page_text() {
|
||||
for (cp, text) in
|
||||
[("ANSI_1252", "Ação \\U+00E9 \\M+183A4F"), ("ANSI_1251", "Слой \\U+0041"), ("ANSI_1251", "СІ \\U+0041"), ("ANSI_932", "表 \\M+1")]
|
||||
{
|
||||
let tags = file(cp, text);
|
||||
for (bytes, what) in [(write_ascii_in(&tags, cp), "ASCII"), (write_binary_in(&tags, cp), "binary")] {
|
||||
assert!(contains(&bytes, b"\\U+005C"), "{cp} {what}: {:?}", String::from_utf8_lossy(&bytes));
|
||||
assert_eq!(parse(&bytes).unwrap()[10].str(), text, "{cp} {what}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Double-byte code pages whose trail byte can be 0x5C (`\\`): Shift-JIS `表` (95 5C) and `ソ`
|
||||
/// (83 5C), Big5 `許` (B3 5C), GBK `乗` (81 5C). Right before `U+` or `M+`, such a character is
|
||||
/// written as an escape, so even a reader that looks for escapes before it decodes the code page
|
||||
/// reads it right. Elsewhere it stays as its bytes. Both forms read back as written.
|
||||
#[test]
|
||||
fn a_trail_byte_backslash_never_starts_an_escape() {
|
||||
let cases: [(&str, &str, &[u8], &[u8]); 4] = [
|
||||
("ANSI_932", "表U+0041 表示", b"\\U+8868U+0041 \x95\x5C\x8E\xA6", b"\x95\x5CU+"),
|
||||
("ANSI_932", "ソM+1A2B", b"\\U+30BDM+1A2B", b"\x83\x5CM+"),
|
||||
("ANSI_950", "許U+0041", b"\\U+8A31U+0041", b"\xB3\x5CU+"),
|
||||
("ANSI_936", "乗U+0041", b"\\U+4E57U+0041", b"\x81\x5CU+"),
|
||||
];
|
||||
for (cp, text, written, never) in cases {
|
||||
let tags = file(cp, text);
|
||||
for (bytes, what) in [(write_ascii_in(&tags, cp), "ASCII"), (write_binary_in(&tags, cp), "binary")] {
|
||||
assert!(contains(&bytes, written), "{cp} {what}: {:?}", String::from_utf8_lossy(&bytes));
|
||||
assert!(!contains(&bytes, never), "{cp} {what}: a trail byte starts an escape");
|
||||
assert_eq!(parse(&bytes).unwrap()[10].str(), text, "{cp} {what}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
//! Small independent byte fixtures for DXF tag types, text encoding, framing and failures.
|
||||
//! Handwritten examples, not files from a CAD application or generated by our writer.
|
||||
//! Background on the tag model and file structure:
|
||||
//! https://ezdxf.readthedocs.io/en/stable/dxfinternals/dxftags.html
|
||||
//! https://ezdxf.readthedocs.io/en/stable/dxfinternals/filestructure.html
|
||||
//! ASCII spacing is our writer's documented convention; binary fixtures specify actual bytes.
|
||||
|
||||
use cadcraft_dxf::{DxfError, Tag, Value, parse, parse_with_limit, write_ascii_as, write_binary};
|
||||
|
||||
const SENTINEL: &[u8] = b"AutoCAD Binary DXF\r\n\x1a\0";
|
||||
|
||||
fn fixtures() -> Vec<(Tag, &'static [u8], &'static [u8])> {
|
||||
vec![
|
||||
(Tag::s(1, " A "), b" 1\r\n A \r\n", b"\x01\0 A \0"),
|
||||
(Tag::s(1, ""), b" 1\r\n\r\n", b"\x01\0\0"),
|
||||
(Tag::s(105, "AB12"), b"105\r\nAB12\r\n", b"\x69\0AB12\0"),
|
||||
(Tag::new(10, Value::Real(-3.25)), b" 10\r\n-3.25\r\n", b"\x0a\0\0\0\0\0\0\0\x0a\xc0"),
|
||||
(Tag::i(70, -32768), b" 70\r\n-32768\r\n", b"\x46\0\0\x80"),
|
||||
(Tag::i(90, -2147483648), b" 90\r\n-2147483648\r\n", b"\x5a\0\0\0\0\x80"),
|
||||
(Tag::i(160, 9223372036854775807), b"160\r\n9223372036854775807\r\n", b"\xa0\0\xff\xff\xff\xff\xff\xff\xff\x7f"),
|
||||
(Tag::new(290, Value::Bool(false)), b"290\r\n0\r\n", b"\x22\x01\0"),
|
||||
(Tag::new(299, Value::Bool(true)), b"299\r\n1\r\n", b"\x2b\x01\x01"),
|
||||
(Tag::new(310, Value::Hex("00FF80".into())), b"310\r\n00FF80\r\n", b"\x36\x01\x03\0\xff\x80"),
|
||||
(Tag::new(1004, Value::Hex(String::new())), b"1004\r\n\r\n", b"\xec\x03\0"),
|
||||
(Tag::i(1070, -2), b"1070\r\n-2\r\n", b"\x2e\x04\xfe\xff"),
|
||||
(Tag::i(1071, 65536), b"1071\r\n65536\r\n", b"\x2f\x04\0\0\x01\0"),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_independently_authored_ascii_and_binary_values() {
|
||||
for (expected, ascii, binary) in fixtures() {
|
||||
assert_eq!(parse(ascii).unwrap(), std::slice::from_ref(&expected), "ASCII code {}", expected.code);
|
||||
let bytes = [SENTINEL, binary].concat();
|
||||
assert_eq!(parse(&bytes).unwrap(), std::slice::from_ref(&expected), "binary code {}", expected.code);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writes_independently_specified_ascii_and_binary_bytes() {
|
||||
for (tag, ascii, binary) in fixtures() {
|
||||
for utf8 in [false, true] {
|
||||
let tags = std::slice::from_ref(&tag);
|
||||
assert_eq!(write_ascii_as(tags, utf8).as_bytes(), ascii, "ASCII code {}", tag.code);
|
||||
assert_eq!(write_binary(tags, utf8), [SENTINEL, binary].concat(), "binary code {}", tag.code);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integer_code_ranges_have_independent_binary_widths() {
|
||||
// Specification ranges and widths, never kind_of(): a shared reader/writer mistake
|
||||
// can round-trip every boundary value while still using the wrong number of bytes.
|
||||
let widths = [
|
||||
(vec![60..=79, 170..=179, 270..=289, 370..=389, 400..=409, 1060..=1070], 2, i64::from(i16::MIN), i64::from(i16::MAX)),
|
||||
(vec![90..=99, 420..=429, 440..=459, 1071..=1071], 4, i64::from(i32::MIN), i64::from(i32::MAX)),
|
||||
(std::iter::once(160..=169).collect(), 8, i64::MIN, i64::MAX),
|
||||
];
|
||||
for (ranges, width, min, max) in widths {
|
||||
for code in ranges.into_iter().flatten() {
|
||||
for value in [min, min + 1, -1, 0, 1, max - 1, max] {
|
||||
// Binary DXF uses a little-endian 16-bit group code and a fixed-width,
|
||||
// little-endian two's-complement integer. Encoding is version-independent.
|
||||
let code_bytes = u16::try_from(code).unwrap().to_le_bytes();
|
||||
let value_bytes = value.to_le_bytes();
|
||||
let expected = [SENTINEL, &code_bytes, &value_bytes[..width]].concat();
|
||||
let tags = [Tag::i(code, value)];
|
||||
for utf8 in [false, true] {
|
||||
assert_eq!(write_binary(&tags, utf8), expected, "code={code} value={value} utf8={utf8}");
|
||||
}
|
||||
assert_eq!(parse(&expected).unwrap(), tags, "code={code} value={value}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_encoding_has_independent_wire_expectations() {
|
||||
let tags = [Tag::s(1, "é😀")];
|
||||
let escaped = b"\\U+00E9\\U+D83D\\U+DE00";
|
||||
let utf8 = b"\xc3\xa9\xf0\x9f\x98\x80";
|
||||
for (unicode, text) in [(false, escaped.as_slice()), (true, utf8.as_slice())] {
|
||||
let ascii = [b" 1\r\n".as_slice(), text, b"\r\n"].concat();
|
||||
let binary = [SENTINEL, b"\x01\0", text, b"\0"].concat();
|
||||
assert_eq!(write_ascii_as(&tags, unicode).as_bytes(), ascii, "utf8={unicode}");
|
||||
assert_eq!(write_binary(&tags, unicode), binary, "utf8={unicode}");
|
||||
assert_eq!(parse(&ascii).unwrap(), tags);
|
||||
assert_eq!(parse(&binary).unwrap(), tags);
|
||||
}
|
||||
// Pre-2007 code-page bytes are independent of the writer, which emits Unicode escapes.
|
||||
let ascii = b"0\nSECTION\n2\nHEADER\n9\n$ACADVER\n1\nAC1015\n9\n$DWGCODEPAGE\n3\nANSI_1252\n0\nENDSEC\n1\n\x80\xe9\n0\nEOF\n";
|
||||
let binary = [
|
||||
SENTINEL,
|
||||
b"\0\0SECTION\0\x02\0HEADER\0\x09\0$ACADVER\0\x01\0AC1015\0\x09\0$DWGCODEPAGE\0\x03\0ANSI_1252\0\0\0ENDSEC\0\x01\0\x80\xe9\0\0\0EOF\0",
|
||||
]
|
||||
.concat();
|
||||
for bytes in [ascii.as_slice(), binary.as_slice()] {
|
||||
let tags = parse(bytes).unwrap();
|
||||
assert_eq!(tags[7], Tag::s(1, "ێ"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ascii_accepts_line_endings_padding_and_no_final_newline() {
|
||||
let expected = [Tag::i(70, -7), Tag::s(1, " text "), Tag::s(0, "EOF")];
|
||||
for newline in ["\n", "\r\n"] {
|
||||
for final_newline in [false, true] {
|
||||
let mut text = [" 70 ", " -7 ", "1", " text ", "0", "EOF"].join(newline);
|
||||
if final_newline {
|
||||
text.push_str(newline);
|
||||
}
|
||||
assert_eq!(parse(text.as_bytes()).unwrap(), expected, "newline={newline:?} final={final_newline}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_and_truncated_values_are_errors_not_panics() {
|
||||
assert!(matches!(parse(b""), Err(DxfError::NotDxf)));
|
||||
assert!(matches!(parse(b"oops\nvalue\n"), Err(DxfError::BadCode { line: 1, .. })));
|
||||
assert!(matches!(parse(b"10\n"), Err(DxfError::Eof)));
|
||||
assert!(matches!(parse(SENTINEL), Err(DxfError::NotDxf)));
|
||||
for (_, _, binary) in fixtures() {
|
||||
// Every cut inside the first record is an error, unlike cuts between complete records:
|
||||
// the tag API deliberately accepts streams without an EOF record.
|
||||
for cut in 1..binary.len() {
|
||||
let bytes = [SENTINEL, &binary[..cut]].concat();
|
||||
assert!(matches!(parse(&bytes), Err(DxfError::Eof)), "binary={binary:?} cut={cut}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_limit_accepts_the_exact_boundary_and_rejects_one_less() {
|
||||
let ascii = b"1\nA\n70\n-7\n0\nEOF\n";
|
||||
let binary = [SENTINEL, b"\x01\0A\0\x46\0\xf9\xff\0\0EOF\0"].concat();
|
||||
for bytes in [ascii.as_slice(), binary.as_slice()] {
|
||||
for limit in 0..3 {
|
||||
assert!(matches!(parse_with_limit(bytes, limit), Err(DxfError::TooLarge { limit: actual, .. }) if actual == limit));
|
||||
}
|
||||
for limit in [3, 4] {
|
||||
assert_eq!(parse_with_limit(bytes, limit).unwrap(), [Tag::s(1, "A"), Tag::i(70, -7), Tag::s(0, "EOF")]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
//! Hex payload conformance is distinct from binary framing capacity: DXF groups 310–319 and
|
||||
//! 1004 specify at most 127 payload bytes per chunk, even though a length byte represents 255.
|
||||
//! Inspect writer bytes without our parser; accept any compliant partition preserving payload,
|
||||
//! code and following-record framing. Oversized binary read fixtures test tolerance only.
|
||||
//! Pending writer regressions extend the chunk-size finding in issue #1025.
|
||||
//! Public DXF Reference, Group Codes in Numerical Order and Binary DXF Files:
|
||||
//! https://help.autodesk.com/cloudhelp/2025/ENU/AutoCAD-DXF/files/GUID-3F0380A5-1C15-464D-BC66-2C5F094BCFB9.htm
|
||||
//! https://help.autodesk.com/cloudhelp/2025/ENU/AutoCAD-DXF/files/GUID-FC1C3C69-DBC2-49E4-893A-000D6538C0FE.htm
|
||||
|
||||
use cadcraft_dxf::{Tag, Value, parse, write_ascii_as, write_binary};
|
||||
|
||||
const SENTINEL: &[u8] = b"AutoCAD Binary DXF\r\n\x1a\0";
|
||||
const FOLLOWING_BINARY: &[u8] = b"\x46\0\xf9\xff"; // group 70, signed i16 -7
|
||||
|
||||
fn payload(length: usize) -> Vec<u8> {
|
||||
(0..length).map(|index| u8::try_from(index * 37 % 256).unwrap()).collect()
|
||||
}
|
||||
|
||||
fn hex(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|byte| format!("{byte:02X}")).collect()
|
||||
}
|
||||
|
||||
fn input(code: i32, bytes: &[u8]) -> [Tag; 2] {
|
||||
[Tag::new(code, Value::Hex(hex(bytes))), Tag::i(70, -7)]
|
||||
}
|
||||
|
||||
fn check_ascii(code: i32, bytes: &[u8], utf8: bool) {
|
||||
let wire = write_ascii_as(&input(code, bytes), utf8);
|
||||
let lines: Vec<_> = wire.lines().collect();
|
||||
let (chunks, following) = lines.split_at(lines.len().checked_sub(2).unwrap());
|
||||
assert_eq!(following[0].trim(), "70");
|
||||
assert_eq!(following[1], "-7");
|
||||
assert!(!chunks.is_empty(), "an empty payload must still have a tag");
|
||||
let (records, remainder) = chunks.as_chunks::<2>();
|
||||
assert!(remainder.is_empty(), "incomplete group/value pair");
|
||||
let context = format!("ASCII code={code} bytes={} utf8={utf8}", bytes.len());
|
||||
let mut back = Vec::new();
|
||||
for record in records {
|
||||
assert_eq!(record[0].trim().parse::<i32>().unwrap(), code, "{context}");
|
||||
let text = record[1];
|
||||
assert!(text.len() <= 254, "{context}: {} hex characters exceed 127 payload bytes", text.len());
|
||||
assert!(text.bytes().all(|b| b.is_ascii_hexdigit()), "{context}: invalid hex");
|
||||
let (pairs, remainder) = text.as_bytes().as_chunks::<2>();
|
||||
assert!(remainder.is_empty(), "{context}: partial byte");
|
||||
back.extend(pairs.iter().map(|pair| u8::from_str_radix(std::str::from_utf8(pair).unwrap(), 16).unwrap()));
|
||||
}
|
||||
assert_eq!(back, bytes, "{context}: complete payload and byte order");
|
||||
}
|
||||
|
||||
fn check_binary(code: i32, bytes: &[u8], utf8: bool) {
|
||||
let wire = write_binary(&input(code, bytes), utf8);
|
||||
let mut chunks = wire.strip_prefix(SENTINEL).unwrap().strip_suffix(FOLLOWING_BINARY).unwrap();
|
||||
assert!(!chunks.is_empty(), "an empty payload must still have a tag");
|
||||
let context = format!("binary code={code} bytes={} utf8={utf8}", bytes.len());
|
||||
let mut back = Vec::new();
|
||||
while !chunks.is_empty() {
|
||||
let (&[lo, hi, length], rest) = chunks.split_first_chunk::<3>().unwrap();
|
||||
assert_eq!(i32::from(u16::from_le_bytes([lo, hi])), code, "{context}");
|
||||
assert!(length <= 127, "{context}: {length} bytes exceed the documented group limit");
|
||||
let (chunk, next) = rest.split_at(usize::from(length));
|
||||
back.extend_from_slice(chunk);
|
||||
chunks = next;
|
||||
}
|
||||
assert_eq!(back, bytes, "{context}: complete payload and byte order");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conforming_payloads_have_independent_ascii_and_binary_wire_checks() {
|
||||
for code in (310..=319).chain([1004]) {
|
||||
for length in [0, 1, 2, 126, 127] {
|
||||
for utf8 in [false, true] {
|
||||
let bytes = payload(length);
|
||||
check_ascii(code, &bytes, utf8);
|
||||
check_binary(code, &bytes, utf8);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_reader_tolerates_the_full_length_byte_domain() {
|
||||
// This is deliberately permissive input, not an assertion that oversized chunks conform
|
||||
// or that the writer should produce them. The following record catches framing mistakes.
|
||||
for code in (310_u16..=319).chain([1004]) {
|
||||
for length in 0..=u8::MAX {
|
||||
let bytes = payload(usize::from(length));
|
||||
let mut wire = SENTINEL.to_vec();
|
||||
wire.extend_from_slice(&code.to_le_bytes());
|
||||
wire.push(length);
|
||||
wire.extend_from_slice(&bytes);
|
||||
wire.extend_from_slice(FOLLOWING_BINARY);
|
||||
assert_eq!(parse(&wire).unwrap(), input(i32::from(code), &bytes), "code={code} length={length}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "Confirmed ASCII DXF writer nonconformance: hex payloads above 127 bytes are emitted as one oversized chunk"]
|
||||
fn ascii_writer_splits_large_payloads_into_conforming_chunks() {
|
||||
for code in (310..=319).chain([1004]) {
|
||||
for length in [128, 254, 255, 256, 1024] {
|
||||
for utf8 in [false, true] {
|
||||
check_ascii(code, &payload(length), utf8);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "Confirmed binary DXF writer nonconformance: 255-byte partitioning exceeds the documented 127-byte group limit"]
|
||||
fn binary_writer_splits_large_payloads_into_conforming_chunks() {
|
||||
for code in (310..=319).chain([1004]) {
|
||||
for length in [128, 254, 255, 256, 1024] {
|
||||
for utf8 in [false, true] {
|
||||
check_binary(code, &payload(length), utf8);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
//! Deterministic tag preservation through the public byte API, in both encodings and every
|
||||
//! supported version. Inputs have the group code's type and representable values; lossy
|
||||
//! coercions are not accepted as round-trip equality. Real values must retain their bits.
|
||||
|
||||
use cadcraft_dxf::{Tag, Value, Version, is_binary, parse, write_ascii_as, write_binary};
|
||||
|
||||
fn check(case: &str, body: &[Tag]) {
|
||||
for version in Version::ALL {
|
||||
let mut original = vec![
|
||||
Tag::s(0, "SECTION"),
|
||||
Tag::s(2, "HEADER"),
|
||||
Tag::s(9, "$ACADVER"),
|
||||
Tag::s(1, version.code()),
|
||||
Tag::s(9, "$DWGCODEPAGE"),
|
||||
Tag::s(3, "ANSI_1252"),
|
||||
Tag::s(0, "ENDSEC"),
|
||||
Tag::s(0, "SECTION"),
|
||||
Tag::s(2, "ENTITIES"),
|
||||
];
|
||||
original.extend_from_slice(body);
|
||||
original.extend([Tag::s(0, "ENDSEC"), Tag::s(0, "EOF")]);
|
||||
for binary in [false, true] {
|
||||
let mut current = original.clone();
|
||||
for pass in 1..=2 {
|
||||
let context = format!("{case} · {version} · binary={binary} · pass={pass}");
|
||||
let bytes = if binary { write_binary(¤t, version.utf8()) } else { write_ascii_as(¤t, version.utf8()).into_bytes() };
|
||||
assert_eq!(is_binary(&bytes), binary, "{context}");
|
||||
let back = parse(&bytes).unwrap_or_else(|e| panic!("{context}: {e}"));
|
||||
assert_eq!(back.len(), original.len(), "{context}: tag count");
|
||||
for (index, (expected, actual)) in original.iter().zip(&back).enumerate() {
|
||||
assert_eq!(actual, expected, "{context}: tag[{index}]");
|
||||
if let (Value::Real(a), Value::Real(b)) = (&expected.value, &actual.value) {
|
||||
assert_eq!(b.to_bits(), a.to_bits(), "{context}: tag[{index}] real bits");
|
||||
}
|
||||
}
|
||||
assert_eq!(back, current, "{context}: no additional changes on resave");
|
||||
current = back;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integer_width_boundaries() {
|
||||
// Explicit reference ranges, not obtained from the codec's kind_of().
|
||||
let widths = [
|
||||
(vec![60..=79, 170..=179, 270..=289, 370..=389, 400..=409, 1060..=1070], i64::from(i16::MIN), i64::from(i16::MAX)),
|
||||
(vec![90..=99, 420..=429, 440..=459, 1071..=1071], i64::from(i32::MIN), i64::from(i32::MAX)),
|
||||
(std::iter::once(160..=169).collect(), i64::MIN, i64::MAX),
|
||||
];
|
||||
for (ranges, min, max) in widths {
|
||||
for value in [min, min + 1, -1, 0, 1, max - 1, max] {
|
||||
let tags: Vec<_> = ranges.iter().cloned().flatten().map(|code| Tag::i(code, value)).collect();
|
||||
check(&format!("integer/{min}..{max}/{value}"), &tags);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finite_reals_keep_all_bits() {
|
||||
let values = [
|
||||
0.0,
|
||||
-0.0,
|
||||
f64::from_bits(1),
|
||||
-f64::from_bits(1),
|
||||
f64::MIN_POSITIVE,
|
||||
f64::MIN_POSITIVE.next_down(),
|
||||
0.1,
|
||||
-3.25,
|
||||
1.0_f64.next_down(),
|
||||
1.0,
|
||||
1.0_f64.next_up(),
|
||||
1e-100,
|
||||
-1e100,
|
||||
f64::MAX,
|
||||
f64::MIN,
|
||||
];
|
||||
for value in values {
|
||||
let tags: Vec<_> =
|
||||
[10..=59, 110..=149, 210..=239, 460..=469, 1010..=1059].into_iter().flatten().map(|code| Tag::new(code, Value::Real(value))).collect();
|
||||
check(&format!("real/{:016X}", value.to_bits()), &tags);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn booleans() {
|
||||
for value in [false, true] {
|
||||
let tags: Vec<_> = (290..=299).map(|code| Tag::new(code, Value::Bool(value))).collect();
|
||||
check(&format!("bool/{value}"), &tags);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strings_preserve_whitespace_unicode_and_literal_escapes() {
|
||||
// No CR/LF or NUL: those are line/string delimiters, not losslessly writable payloads.
|
||||
let values = [
|
||||
("empty", ""),
|
||||
("spaces", " leading and trailing "),
|
||||
("tabs", "\tindented\t"),
|
||||
("unicode", "Café Ø 图号 Україна 😀 e\u{301}"),
|
||||
("literal-escapes", r"\U+0041 \M+183A4 \U+D83D\U+DE00 \\U+005C \P %%c"),
|
||||
("malformed-escapes", r"\U+ \U+ZZZZ \U+D800 \U+DC00 \M+9FFFF"),
|
||||
];
|
||||
for (case, value) in values {
|
||||
let tags: Vec<_> =
|
||||
[0..=9, 100..=100, 102..=102, 105..=105, 300..=309, 320..=369, 410..=419, 430..=439, 470..=479, 480..=481, 1000..=1003, 1005..=1005]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|code| Tag::s(code, value))
|
||||
.collect();
|
||||
check(case, &tags);
|
||||
}
|
||||
for length in [1, 254, 255, 256, 1024] {
|
||||
check(&format!("string/length={length}"), &[Tag::s(1, "x".repeat(length))]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hex_chunks_keep_bytes_and_order() {
|
||||
// DXF groups 310–319 and 1004 permit 127 payload bytes per chunk. A binary length
|
||||
// byte can represent more, but that is reader tolerance, not writer conformance.
|
||||
// Larger payloads need chunk-size/reassembly checks rather than exact tag equality;
|
||||
// see hex_chunks.rs for the independent wire checks and pending writer regressions.
|
||||
for length in [0, 1, 2, 126, 127] {
|
||||
let hex: String = (0..length).map(|i| format!("{:02X}", (i * 37) % 256)).collect();
|
||||
let tags: Vec<_> = (310..=319).chain([1004]).map(|code| Tag::new(code, Value::Hex(hex.clone()))).collect();
|
||||
check(&format!("hex/bytes={length}"), &tags);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_values_and_repeated_codes_keep_order() {
|
||||
check(
|
||||
"mixed/repeated-codes",
|
||||
&[
|
||||
Tag::s(0, "TEST"),
|
||||
Tag::f(10, 1.25),
|
||||
Tag::s(1, "first"),
|
||||
Tag::f(10, -2.5),
|
||||
Tag::i(70, -7),
|
||||
Tag::s(1, "second"),
|
||||
Tag::new(290, Value::Bool(true)),
|
||||
Tag::new(310, Value::Hex("00FF80".into())),
|
||||
Tag::i(160, i64::MAX),
|
||||
Tag::s(1, ""),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -30,19 +30,29 @@ const MAX_TRIES: u32 = 32;
|
||||
/// are written through the retained handle and flushed, and the temporary is then renamed over
|
||||
/// `path`. On any failure the temporary is removed and the original `path` is left untouched.
|
||||
pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
|
||||
// A symlinked destination is resolved so the link stays a link and its target is replaced.
|
||||
let resolved = resolve_symlink(path)?;
|
||||
let path = resolved.as_deref().unwrap_or(path);
|
||||
let permissions = match std::fs::symlink_metadata(path) {
|
||||
Ok(meta) if meta.is_file() => Some(meta.permissions()),
|
||||
Ok(_) => None,
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => None,
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
write_atomic_with(path, permissions, |file| file.write_all(bytes))
|
||||
}
|
||||
|
||||
/// [`write_atomic`] filling the temporary file through `fill` (streamed rather than one buffer),
|
||||
/// with `permissions` given to it from its creation (`None`: the usual creation mode). A symlink
|
||||
/// at `path` is replaced by the new file, never written through.
|
||||
pub fn write_atomic_with(path: &Path, permissions: Option<Permissions>, fill: impl FnOnce(&mut File) -> io::Result<()>) -> io::Result<()> {
|
||||
let dir = path.parent().filter(|p| !p.as_os_str().is_empty()).map_or_else(|| PathBuf::from("."), Path::to_path_buf);
|
||||
let (mut file, tmp) = create_temp(&dir, permissions.as_ref())?;
|
||||
let write = (|| {
|
||||
if let Some(permissions) = permissions {
|
||||
file.set_permissions(permissions)?;
|
||||
}
|
||||
file.write_all(bytes)?;
|
||||
fill(&mut file)?;
|
||||
file.flush()?;
|
||||
file.sync_all()
|
||||
})();
|
||||
@@ -55,9 +65,57 @@ pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
return Err(e);
|
||||
}
|
||||
sync_dir(&dir);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Flush `dir` so a rename just made in it survives a crash or power loss (POSIX records a
|
||||
/// rename in the directory, not in the file). Best effort: some file systems (network shares)
|
||||
/// refuse to sync a directory, and the file itself is already on disk.
|
||||
fn sync_dir(dir: &Path) {
|
||||
#[cfg(unix)]
|
||||
if let Ok(d) = File::open(dir) {
|
||||
let _ = d.sync_all();
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
let _ = dir;
|
||||
}
|
||||
|
||||
/// Resolve `path` when it is a symlink, or return `None` if it is not one.
|
||||
///
|
||||
/// A dangling link (for example into an unavailable mount) cannot be canonicalized, so its target
|
||||
/// is followed by hand with `read_link`, joining relative targets onto the link's directory.
|
||||
fn resolve_symlink(path: &Path) -> io::Result<Option<PathBuf>> {
|
||||
match std::fs::symlink_metadata(path) {
|
||||
Ok(meta) if meta.file_type().is_symlink() => {}
|
||||
_ => return Ok(None),
|
||||
}
|
||||
if let Ok(target) = std::fs::canonicalize(path) {
|
||||
return Ok(Some(target));
|
||||
}
|
||||
let mut current = path.to_path_buf();
|
||||
for _ in 0..40 {
|
||||
let target = std::fs::read_link(¤t)?;
|
||||
current = if target.is_absolute() { target } else { current.parent().map_or(target.clone(), |p| p.join(&target)) };
|
||||
match std::fs::symlink_metadata(¤t) {
|
||||
Ok(meta) if meta.file_type().is_symlink() => {}
|
||||
Ok(_) => return finish_target(¤t),
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => return finish_target(¤t),
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
Err(io::Error::new(io::ErrorKind::InvalidInput, "too many levels of symbolic links"))
|
||||
}
|
||||
|
||||
/// Build the write path for a resolved, non-link (possibly missing) target.
|
||||
fn finish_target(current: &Path) -> io::Result<Option<PathBuf>> {
|
||||
// The target's directory must exist; otherwise report the error rather than replacing the link.
|
||||
let parent = current.parent().filter(|p| !p.as_os_str().is_empty()).map_or_else(|| PathBuf::from("."), Path::to_path_buf);
|
||||
let parent = std::fs::canonicalize(parent)?;
|
||||
let name = current.file_name().ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "symlink target has no file name"))?;
|
||||
Ok(Some(parent.join(name)))
|
||||
}
|
||||
|
||||
/// Create a fresh, exclusively-owned temporary file inside `dir`, returning its handle and path.
|
||||
///
|
||||
/// `create_new(true)` opens with `O_CREAT | O_EXCL`, which refuses to follow or reuse an existing
|
||||
@@ -116,6 +174,18 @@ mod tests {
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// A write into a folder whose entries can be listed syncs that folder without failing, and
|
||||
/// a folder that can't be opened (gone) is no error for the sync itself.
|
||||
#[test]
|
||||
fn the_folder_is_synced_after_the_rename() {
|
||||
let dir = std::env::temp_dir().join(format!("cadcraft-aw-sync-{}-{}", std::process::id(), SEQ.fetch_add(1, Ordering::Relaxed)));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
write_atomic(&dir.join("a.bin"), b"x").unwrap();
|
||||
assert_eq!(std::fs::read(dir.join("a.bin")).unwrap(), b"x");
|
||||
sync_dir(&dir.join("missing"));
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn does_not_follow_a_planted_temp_symlink() {
|
||||
@@ -147,6 +217,52 @@ mod tests {
|
||||
assert_eq!(std::fs::read(&dest).unwrap(), b"private report");
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn symlinked_destination_updates_the_target() {
|
||||
use std::os::unix::fs::{PermissionsExt, symlink};
|
||||
let dir = std::env::temp_dir().join(format!("cadcraft-aw-link-{}-{}", std::process::id(), SEQ.fetch_add(1, Ordering::Relaxed)));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let real = dir.join("real.dxf");
|
||||
let link = dir.join("link.dxf");
|
||||
std::fs::write(&real, b"old").unwrap();
|
||||
std::fs::set_permissions(&real, std::fs::Permissions::from_mode(0o600)).unwrap();
|
||||
symlink(&real, &link).unwrap();
|
||||
write_atomic(&link, b"new").unwrap();
|
||||
assert!(std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink(), "symlink was replaced by a regular file");
|
||||
assert_eq!(std::fs::read(&real).unwrap(), b"new");
|
||||
assert_eq!(std::fs::metadata(&real).unwrap().permissions().mode() & 0o777, 0o600);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn dangling_symlink_destination_creates_the_target() {
|
||||
use std::os::unix::fs::symlink;
|
||||
let dir = std::env::temp_dir().join(format!("cadcraft-aw-dangle-{}-{}", std::process::id(), SEQ.fetch_add(1, Ordering::Relaxed)));
|
||||
std::fs::create_dir_all(dir.join("mnt")).unwrap();
|
||||
let real = dir.join("mnt").join("real.dxf");
|
||||
let link = dir.join("link.dxf");
|
||||
symlink("mnt/real.dxf", &link).unwrap();
|
||||
write_atomic(&link, b"new").unwrap();
|
||||
assert!(std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink(), "symlink was replaced by a regular file");
|
||||
assert_eq!(std::fs::read(&real).unwrap(), b"new");
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn dangling_symlink_with_missing_parent_errors_and_keeps_the_link() {
|
||||
use std::os::unix::fs::symlink;
|
||||
let dir = std::env::temp_dir().join(format!("cadcraft-aw-nomnt-{}-{}", std::process::id(), SEQ.fetch_add(1, Ordering::Relaxed)));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let link = dir.join("link.dxf");
|
||||
symlink("missing/real.dxf", &link).unwrap();
|
||||
assert!(write_atomic(&link, b"new").is_err());
|
||||
assert!(std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink());
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn private_temp_is_restricted_from_creation() {
|
||||
|
||||
@@ -460,17 +460,13 @@ fn references(d: &mut Drawing, out: &mut Vec<Issue>) {
|
||||
m.style = "Standard".into();
|
||||
}
|
||||
}
|
||||
EntityKind::Table(t) => {
|
||||
if !has_name(&d.table_styles, &t.style, |s| &s.name) {
|
||||
note("table style", &t.style);
|
||||
t.style = "Standard".into();
|
||||
}
|
||||
EntityKind::Table(t) if !has_name(&d.table_styles, &t.style, |s| &s.name) => {
|
||||
note("table style", &t.style);
|
||||
t.style = "Standard".into();
|
||||
}
|
||||
EntityKind::Leader(l) => {
|
||||
if !ok_dim(d, &l.style) {
|
||||
note("dimension style", &l.style);
|
||||
l.style = "Standard".into();
|
||||
}
|
||||
EntityKind::Leader(l) if !ok_dim(d, &l.style) => {
|
||||
note("dimension style", &l.style);
|
||||
l.style = "Standard".into();
|
||||
}
|
||||
EntityKind::Dimension(dim) => {
|
||||
if !ok_dim(d, &dim.style) {
|
||||
|
||||
@@ -34,6 +34,18 @@ pub const STALE_LOCK_SECS: u64 = 5 * 60;
|
||||
const HEARTBEAT_SECS: f64 = 60.0;
|
||||
|
||||
static SAVETIME: AtomicU32 = AtomicU32::new(DEFAULT_SAVETIME);
|
||||
static ISAVEBAK: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true);
|
||||
|
||||
/// ISAVEBAK: whether saving over a drawing keeps the previous file as `<name>.bak` (on by
|
||||
/// default, as in AutoCAD). A profile setting like SAVETIME.
|
||||
pub fn isavebak() -> bool {
|
||||
ISAVEBAK.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Turn ISAVEBAK on or off.
|
||||
pub fn set_isavebak(on: bool) {
|
||||
ISAVEBAK.store(on, Ordering::Relaxed);
|
||||
}
|
||||
static SAVEFILEPATH: Mutex<String> = Mutex::new(String::new());
|
||||
|
||||
/// SAVETIME: minutes between autosaves; 0 turns autosave off.
|
||||
@@ -59,10 +71,22 @@ pub fn set_savefilepath(path: &str) {
|
||||
|
||||
/// The autosave file name for drawing `uid` titled `title`, written by app instance `instance`.
|
||||
pub fn file_name(title: &str, instance: &str, uid: u64) -> String {
|
||||
let stem: String = title.chars().map(|c| if c.is_alphanumeric() || " .-_()".contains(c) { c } else { '-' }).take(80).collect();
|
||||
// File systems limit a name to 255 bytes, so the stem gets what the suffix leaves, cut at a character boundary.
|
||||
let suffix = format!("_{instance}_{uid}.{EXT}");
|
||||
let budget = 255usize.saturating_sub(suffix.len());
|
||||
let mut used = 0;
|
||||
let stem: String = title
|
||||
.chars()
|
||||
.map(|c| if c.is_alphanumeric() || " .-_()".contains(c) { c } else { '-' })
|
||||
.take(80)
|
||||
.take_while(|c| {
|
||||
used += c.len_utf8();
|
||||
used <= budget
|
||||
})
|
||||
.collect();
|
||||
let stem = stem.trim().trim_matches('.');
|
||||
let stem = if stem.is_empty() { "Drawing" } else { stem };
|
||||
format!("{stem}_{instance}_{uid}.{EXT}")
|
||||
format!("{stem}{suffix}")
|
||||
}
|
||||
|
||||
/// Whether `s` has the form of an app instance id (`<process id>-<start time in hex>`, see
|
||||
@@ -478,3 +502,20 @@ mod native {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn file_name_fits_255_bytes_for_multibyte_titles() {
|
||||
let instance = "12345-abcdef0123456789";
|
||||
for title in ["图".repeat(100), "😀".repeat(100), "aé图😀".repeat(40), "a".repeat(300)] {
|
||||
let name = file_name(&title, instance, u64::MAX);
|
||||
assert!(name.len() <= 255, "{} bytes", name.len());
|
||||
let (t, i, u) = parse_file_name(&name).expect("round trips");
|
||||
assert_eq!((i, u), (instance, u64::MAX));
|
||||
assert!(!t.is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ pub mod plotters;
|
||||
mod props;
|
||||
pub use draw2::breakline::BreaklineOptions;
|
||||
pub use props::MatchProps;
|
||||
pub(crate) use props::UNIT_RANGES;
|
||||
pub(crate) use props::{current_color_value, set_current_color_value};
|
||||
mod qselect;
|
||||
mod selecttools;
|
||||
|
||||
@@ -48,6 +48,19 @@ pub(super) fn add_dim(s: &mut Session, k: EntityKind) -> Result<Handle> {
|
||||
{
|
||||
dm.assoc = crate::assoc::reassociate(s.doc()?, &s.space(), dm);
|
||||
}
|
||||
// The measurement is what the DXF/DWG writer computes from the geometry (group 42); filling it
|
||||
// in now means queries show it before the drawing is saved and reopened (#2421).
|
||||
if let EntityKind::Dimension(dm) = &mut k
|
||||
&& let Ok(d) = s.doc()
|
||||
{
|
||||
let style = d.dim_style(&dm.style).cloned().unwrap_or_default();
|
||||
let g = cadcraft_render::dimension_geometry(dm, &style, d.header.f64("DIMSCALE", 1.0));
|
||||
dm.measurement = g.measurement;
|
||||
// The text position the writer puts in group 11 when the user has not placed the text.
|
||||
if !dm.user_text_pos {
|
||||
dm.text_mid = g.text_pos.to3(0.0);
|
||||
}
|
||||
}
|
||||
let h = s.add_entity(k)?;
|
||||
s.last_dim = Some(h);
|
||||
Ok(h)
|
||||
@@ -94,7 +107,7 @@ pub(super) fn hex_param(p: &Value, key: &str) -> Option<Handle> {
|
||||
|
||||
pub(super) fn pick_at(s: &Session, p: Vec2) -> Option<Handle> {
|
||||
let ap = s.pixel_size() * s.settings.pickbox.max(1.0) * 1.5;
|
||||
crate::select::pick(s.doc().ok()?, &s.space(), p, ap)
|
||||
s.pick_object(&s.space(), p, ap).ok().flatten()
|
||||
}
|
||||
|
||||
/// The straight or curved piece of `h` picked at `p`: a line, an arc, or the polyline segment nearest `p`.
|
||||
|
||||
@@ -328,16 +328,19 @@ fn run_array(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
(spec.run)(s, p)
|
||||
}
|
||||
|
||||
/// A count parameter (at least 1).
|
||||
fn count_or(p: &Value, key: &str, default: u64) -> u64 {
|
||||
p.get(key).and_then(Value::as_u64).unwrap_or(default).max(1)
|
||||
/// A count parameter (at least 1); a missing key gives the default, an invalid value is refused.
|
||||
fn count_or(cmd: &str, p: &Value, key: &str, default: u64) -> Result<u64> {
|
||||
match p.get(key) {
|
||||
None | Some(Value::Null) => Ok(default),
|
||||
Some(v) => v.as_u64().filter(|n| *n >= 1).ok_or_else(|| bad(cmd, format!("`{key}` must be a whole number of at least 1"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn run_arrayrect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let hs = targets(s, p)?;
|
||||
let rows = count_or(p, "rows", 3);
|
||||
let cols = count_or(p, "cols", 4);
|
||||
let levels = count_or(p, "levels", 1);
|
||||
let rows = count_or("arrayrect", p, "rows", 3)?;
|
||||
let cols = count_or("arrayrect", p, "cols", 4)?;
|
||||
let levels = count_or("arrayrect", p, "levels", 1)?;
|
||||
check_size(s, rows.saturating_mul(cols).saturating_mul(levels), hs.len())?;
|
||||
let kind = ArrayKind::Rect(RectArray {
|
||||
cols: count32(cols),
|
||||
@@ -360,9 +363,9 @@ fn run_arrayrect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn run_arraypolar(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let hs = targets(s, p)?;
|
||||
let c = point_req("arraypolar", p, "center")?;
|
||||
let n = count_or(p, "count", 6);
|
||||
let rows = count_or(p, "rows", 1);
|
||||
let levels = count_or(p, "levels", 1);
|
||||
let n = count_or("arraypolar", p, "count", 6)?;
|
||||
let rows = count_or("arraypolar", p, "rows", 1)?;
|
||||
let levels = count_or("arraypolar", p, "levels", 1)?;
|
||||
check_size(s, n.saturating_mul(rows).saturating_mul(levels), hs.len())?;
|
||||
let base = point_param(p, "base").or_else(|| s.doc().ok().and_then(|d| first_center(d, &hs))).unwrap_or(c);
|
||||
let row_spacing = f64_or(p, "rowSpacing", 1.0);
|
||||
|
||||
+105
-16
@@ -202,13 +202,13 @@ pub(crate) fn insert(s: &mut Session, name: &str, at: Vec2, scale: f64, rotation
|
||||
insert_with(s, name, at, Vec2::new(scale, scale), rotation, None, values)
|
||||
}
|
||||
|
||||
/// The block reference INSERT places: X/Y scale factors, and an optional base point (in block
|
||||
/// The block reference INSERT places: X/Y/Z scale factors, and an optional base point (in block
|
||||
/// coordinates) that lands on `at` instead of the block's own base point.
|
||||
fn insert_entity(blk: &Block, at: Vec2, scale: Vec2, rotation: f64, base: Option<Vec2>) -> Insert {
|
||||
fn insert_entity(blk: &Block, at: Vec2, scale: Vec3, rotation: f64, base: Option<Vec2>) -> Insert {
|
||||
let mut ins = Insert {
|
||||
block: blk.name.clone(),
|
||||
insert: v3(at),
|
||||
scale: Vec3::new(scale.x, scale.y, scale.x),
|
||||
scale,
|
||||
rotation,
|
||||
attribs: Vec::new(),
|
||||
cols: 1,
|
||||
@@ -223,7 +223,8 @@ fn insert_entity(blk: &Block, at: Vec2, scale: Vec2, rotation: f64, base: Option
|
||||
ins
|
||||
}
|
||||
|
||||
/// [`insert`] with separate X/Y scale factors and an optional base point (see [`insert_entity`]).
|
||||
/// [`insert`] with separate X/Y scale factors (Z as X) and an optional base point (see
|
||||
/// [`insert_entity`]).
|
||||
pub(crate) fn insert_with(
|
||||
s: &mut Session,
|
||||
name: &str,
|
||||
@@ -232,6 +233,19 @@ pub(crate) fn insert_with(
|
||||
rotation: f64,
|
||||
base: Option<Vec2>,
|
||||
values: &serde_json::Map<String, Value>,
|
||||
) -> Result<Handle> {
|
||||
insert_scaled(s, name, at, Vec3::new(scale.x, scale.y, scale.x), rotation, base, values)
|
||||
}
|
||||
|
||||
/// [`insert_with`] with a Z scale factor of its own.
|
||||
fn insert_scaled(
|
||||
s: &mut Session,
|
||||
name: &str,
|
||||
at: Vec2,
|
||||
scale: Vec3,
|
||||
rotation: f64,
|
||||
base: Option<Vec2>,
|
||||
values: &serde_json::Map<String, Value>,
|
||||
) -> Result<Handle> {
|
||||
let blk = s.doc()?.block(name).cloned().ok_or_else(|| EngineError::Other(format!("Block \"{name}\" not found.")))?;
|
||||
let mut ins = insert_entity(&blk, at, scale, rotation, base);
|
||||
@@ -566,15 +580,19 @@ enum InsStage {
|
||||
/// A file whose block already exists: redefine it from the file?
|
||||
Redefine,
|
||||
Point,
|
||||
/// Basepoint: a new base point, picked on the block shown at its definition position.
|
||||
/// Basepoint: a new base point, picked on the block dropped where it was (at the cursor).
|
||||
BasePoint,
|
||||
/// Scale preset before the insertion point.
|
||||
PresetScale,
|
||||
/// X, Y or Z scale preset (0, 1, 2) before the insertion point: that axis alone.
|
||||
PresetAxis(usize),
|
||||
/// Rotate preset before the insertion point.
|
||||
PresetRotation,
|
||||
XScale,
|
||||
Corner,
|
||||
YScale,
|
||||
/// XYZ: the Z scale factor, after X and Y.
|
||||
ZScale,
|
||||
Rotation,
|
||||
Attribs,
|
||||
}
|
||||
@@ -587,6 +605,15 @@ struct InsertM {
|
||||
base: Option<Vec2>,
|
||||
x_scale: Option<f64>,
|
||||
scale: Option<Vec2>,
|
||||
/// The Z scale factor when given (a preset or XYZ); otherwise the X factor's size. AutoCAD
|
||||
/// keeps a Z factor typed at XYZ's Z prompt as given, and stores the size of any other.
|
||||
z_scale: Option<f64>,
|
||||
/// X/Y/Z presets given so far.
|
||||
axes: [Option<f64>; 3],
|
||||
/// XYZ at the X scale prompt: X, Y and Z are asked in turn.
|
||||
xyz: bool,
|
||||
/// Where the block was dropped to pick a Basepoint on it.
|
||||
dropped: Option<Vec2>,
|
||||
rotation: Option<f64>,
|
||||
attdefs: Vec<(String, String, String)>,
|
||||
values: serde_json::Map<String, Value>,
|
||||
@@ -610,7 +637,7 @@ impl InsertM {
|
||||
&& self.values.len() >= self.attdefs.len()
|
||||
&& let (Some(n), Some(a), Some(sc), Some(r)) = (self.name.clone(), self.at, self.scale, self.rotation)
|
||||
{
|
||||
let h = insert_with(s, &n, a, sc, r, self.base, &self.values)?;
|
||||
let h = insert_scaled(s, &n, a, self.scale3(sc), r, self.base, &self.values)?;
|
||||
if self.explode {
|
||||
insert_file::explode_insert(s, h)?;
|
||||
}
|
||||
@@ -656,6 +683,23 @@ impl InsertM {
|
||||
}
|
||||
}
|
||||
|
||||
/// X/Y scale factors with Z: as given, else the X factor's size (AutoCAD 2024: X -1 with Y
|
||||
/// as X stores Z +1).
|
||||
/// X and Y from a corner: XYZ still asks Z, which defaults to the size of X.
|
||||
fn corner_scale(&mut self, s: &mut Session, sc: Vec2) -> Result<Step> {
|
||||
self.scale = Some(sc);
|
||||
if self.xyz {
|
||||
self.x_scale = Some(sc.x);
|
||||
self.stage = InsStage::ZScale;
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
self.advance(s)
|
||||
}
|
||||
|
||||
fn scale3(&self, sc: Vec2) -> Vec3 {
|
||||
Vec3::new(sc.x, sc.y, self.z_scale.unwrap_or(sc.x.abs()))
|
||||
}
|
||||
|
||||
/// Scale factors from an opposite corner: the X and Y distances from the insertion point.
|
||||
fn corner(a: Vec2, p: Vec2) -> Result<Vec2> {
|
||||
let d = p - a;
|
||||
@@ -686,15 +730,18 @@ impl Interactive for InsertM {
|
||||
let name = self.file.as_ref().map(|f| f.name.clone()).unwrap_or_default();
|
||||
Prompt::new(format!("Block \"{name}\" already exists. Redefine it?"), super::curves::KW).kw(&["Yes", "No"]).default("N")
|
||||
}
|
||||
InsStage::Point => Prompt::new("Specify insertion point", Accept::POINT).kw(&["Basepoint", "Scale", "Rotate"]),
|
||||
InsStage::Point => Prompt::new("Specify insertion point", Accept::POINT).kw(&["Basepoint", "Scale", "X", "Y", "Z", "Rotate"]),
|
||||
InsStage::BasePoint => Prompt::new("Specify base point", Accept::POINT),
|
||||
InsStage::PresetScale => Prompt::new("Specify scale factor for XYZ axes", Accept::NUMBER).default("1"),
|
||||
InsStage::PresetAxis(i) => Prompt::new(format!("Specify {} scale factor", ["X", "Y", "Z"][i.min(2)]), Accept::NUMBER).default("1"),
|
||||
InsStage::PresetRotation => Prompt::new("Specify rotation angle", Accept::NUMBER).default("0"),
|
||||
InsStage::XScale if self.xyz => Prompt::new("Specify X scale factor", Accept::POINT_OR_NUMBER).kw(&["Corner"]).default("1").base(at),
|
||||
InsStage::XScale => {
|
||||
Prompt::new("Enter X scale factor, specify opposite corner", Accept::POINT_OR_NUMBER).kw(&["Corner"]).default("1").base(at)
|
||||
Prompt::new("Enter X scale factor, specify opposite corner", Accept::POINT_OR_NUMBER).kw(&["Corner", "XYZ"]).default("1").base(at)
|
||||
}
|
||||
InsStage::Corner => Prompt::new("Specify opposite corner", Accept::POINT).base(at),
|
||||
InsStage::YScale => Prompt::new("Enter Y scale factor", Accept::NUMBER).default("use X scale factor"),
|
||||
InsStage::ZScale => Prompt::new("Specify Z scale factor or", Accept::NUMBER).default("use X scale factor"),
|
||||
InsStage::Rotation => Prompt::new("Specify rotation angle", Accept::POINT_OR_NUMBER).default("0").base(at),
|
||||
InsStage::Attribs => {
|
||||
let (_, prompt, default) = self.attdefs.get(self.values.len()).cloned().unwrap_or_default();
|
||||
@@ -742,14 +789,25 @@ impl Interactive for InsertM {
|
||||
self.at = Some(p);
|
||||
return self.advance(s);
|
||||
}
|
||||
Input::Keyword(k) if k == "Basepoint" => self.stage = InsStage::BasePoint,
|
||||
Input::Keyword(k) if k == "Basepoint" => {
|
||||
// AutoCAD drops the block where it is being dragged, at the cursor (the origin
|
||||
// in a script: 2024 inserted (15, 5) for base point (612350.678, 2398770.432)
|
||||
// and insertion point (612365.678, 2398775.432)).
|
||||
self.dropped = Some(s.cursor);
|
||||
self.stage = InsStage::BasePoint;
|
||||
}
|
||||
Input::Keyword(k) if k == "Scale" => self.stage = InsStage::PresetScale,
|
||||
Input::Keyword(k) if k == "X" => self.stage = InsStage::PresetAxis(0),
|
||||
Input::Keyword(k) if k == "Y" => self.stage = InsStage::PresetAxis(1),
|
||||
Input::Keyword(k) if k == "Z" => self.stage = InsStage::PresetAxis(2),
|
||||
Input::Keyword(k) if k == "Rotate" => self.stage = InsStage::PresetRotation,
|
||||
_ => {}
|
||||
},
|
||||
InsStage::BasePoint => {
|
||||
if let Input::Point(p) = i {
|
||||
self.base = Some(p);
|
||||
// From the dropped block to its own coordinates.
|
||||
let def = s.doc()?.block(self.name.as_deref().unwrap_or_default()).map_or(Vec2::ZERO, |b| b.base.xy());
|
||||
self.base = Some(def + (p - self.dropped.unwrap_or(def)));
|
||||
self.stage = InsStage::Point;
|
||||
}
|
||||
}
|
||||
@@ -760,12 +818,30 @@ impl Interactive for InsertM {
|
||||
_ => return Ok(Step::Continue),
|
||||
};
|
||||
self.scale = Some(Vec2::new(f, f));
|
||||
self.z_scale = Some(f.abs());
|
||||
// An axis preset after it changes that axis alone (Scale -3 then X 2: 2/-3/3).
|
||||
self.axes = [Some(f), Some(f), Some(f.abs())];
|
||||
// Exploded insertion asks its one scale factor after the insertion point.
|
||||
if self.at.is_some() {
|
||||
return self.advance(s);
|
||||
}
|
||||
self.stage = InsStage::Point;
|
||||
}
|
||||
// One axis preset: the others are 1, and no scale factors are asked after the point.
|
||||
InsStage::PresetAxis(ax) => {
|
||||
let f = match i {
|
||||
Input::Text(t) => nonzero(&t)?,
|
||||
Input::Enter => 1.0,
|
||||
_ => return Ok(Step::Continue),
|
||||
};
|
||||
if let Some(a) = self.axes.get_mut(ax) {
|
||||
*a = Some(f);
|
||||
}
|
||||
let [x, y, z] = self.axes.map(|a| a.unwrap_or(1.0));
|
||||
self.scale = Some(Vec2::new(x, y));
|
||||
self.z_scale = Some(z.abs());
|
||||
self.stage = InsStage::Point;
|
||||
}
|
||||
InsStage::PresetRotation => {
|
||||
self.rotation = Some(match i {
|
||||
Input::Text(t) => s.angle_settings().direction(&t).ok_or_else(|| EngineError::Other("Requires an angle.".into()))?,
|
||||
@@ -776,9 +852,10 @@ impl Interactive for InsertM {
|
||||
}
|
||||
InsStage::XScale => match i {
|
||||
Input::Keyword(k) if k == "Corner" => self.stage = InsStage::Corner,
|
||||
Input::Keyword(k) if k == "XYZ" => self.xyz = true,
|
||||
Input::Point(p) => {
|
||||
self.scale = Some(Self::corner(self.at.unwrap_or_default(), p)?);
|
||||
return self.advance(s);
|
||||
let sc = Self::corner(self.at.unwrap_or_default(), p)?;
|
||||
return self.corner_scale(s, sc);
|
||||
}
|
||||
Input::Text(t) => {
|
||||
self.x_scale = Some(nonzero(&t)?);
|
||||
@@ -792,8 +869,8 @@ impl Interactive for InsertM {
|
||||
},
|
||||
InsStage::Corner => {
|
||||
if let Input::Point(p) = i {
|
||||
self.scale = Some(Self::corner(self.at.unwrap_or_default(), p)?);
|
||||
return self.advance(s);
|
||||
let sc = Self::corner(self.at.unwrap_or_default(), p)?;
|
||||
return self.corner_scale(s, sc);
|
||||
}
|
||||
}
|
||||
InsStage::YScale => {
|
||||
@@ -804,6 +881,18 @@ impl Interactive for InsertM {
|
||||
_ => return Ok(Step::Continue),
|
||||
};
|
||||
self.scale = Some(Vec2::new(x, y));
|
||||
if self.xyz {
|
||||
self.stage = InsStage::ZScale;
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
return self.advance(s);
|
||||
}
|
||||
InsStage::ZScale => {
|
||||
self.z_scale = Some(match i {
|
||||
Input::Text(t) => nonzero(&t)?,
|
||||
Input::Enter => self.x_scale.unwrap_or(1.0).abs(),
|
||||
_ => return Ok(Step::Continue),
|
||||
});
|
||||
return self.advance(s);
|
||||
}
|
||||
InsStage::Rotation => {
|
||||
@@ -832,7 +921,7 @@ impl Interactive for InsertM {
|
||||
let Some(b) = d.block(n) else { return Vec::new() };
|
||||
// Picking a new base point: the block sits at its definition position.
|
||||
if self.stage == InsStage::BasePoint {
|
||||
return vec![EntityKind::Insert(insert_entity(b, b.base.xy(), Vec2::new(1.0, 1.0), 0.0, None))];
|
||||
return vec![EntityKind::Insert(insert_entity(b, self.dropped.unwrap_or(b.base.xy()), Vec3::new(1.0, 1.0, 1.0), 0.0, None))];
|
||||
}
|
||||
let at = self.at.unwrap_or(c);
|
||||
let scale = self.scale.unwrap_or_else(|| match (self.stage, self.x_scale) {
|
||||
@@ -841,7 +930,7 @@ impl Interactive for InsertM {
|
||||
_ => Vec2::new(1.0, 1.0),
|
||||
});
|
||||
let rot = self.rotation.unwrap_or(if self.stage == InsStage::Rotation { at.angle_to(c) } else { 0.0 });
|
||||
vec![EntityKind::Insert(insert_entity(b, at, scale, rot, self.base))]
|
||||
vec![EntityKind::Insert(insert_entity(b, at, self.scale3(scale), rot, self.base))]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! time or by replacing a string in all of them. Attribute tags match in any case, as in AutoCAD.
|
||||
|
||||
use cadcraft_doc::color::Color;
|
||||
use cadcraft_doc::{AttribProps, Common, EntityKind, Handle};
|
||||
use cadcraft_doc::{AttribProps, Common, EntityKind, HAlign, Handle, Text, VAlign};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use crate::cmd::curves::KW;
|
||||
@@ -67,7 +67,7 @@ fn run(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let EntityKind::Insert(i) = &mut e.kind {
|
||||
for a in &mut i.attribs {
|
||||
if let Some(v) = value_for(&vals, &a.tag) {
|
||||
a.text.value = v;
|
||||
set_value(&mut a.text, v);
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
@@ -130,13 +130,29 @@ fn edit(s: &mut Session, (h, k): (Handle, usize), f: impl FnOnce(&str) -> Option
|
||||
&& let Some(a) = ins.attribs.get_mut(k)
|
||||
&& let Some(v) = f(&a.text.value).filter(|v| *v != a.text.value)
|
||||
{
|
||||
a.text.value = v;
|
||||
set_value(&mut a.text, v);
|
||||
changed = true;
|
||||
}
|
||||
})?;
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
/// Refresh the derived baseline after a value change, even one within the writer's
|
||||
/// imported-font tolerance. Equal XY points request recomputation on export; retain
|
||||
/// both elevations and the independent endpoints used by Aligned/Fit text.
|
||||
fn set_value(text: &mut Text, value: String) {
|
||||
if text.value == value {
|
||||
return;
|
||||
}
|
||||
text.value = value;
|
||||
if !matches!((text.halign, text.valign), (HAlign::Left, VAlign::Baseline) | (HAlign::Aligned | HAlign::Fit, _))
|
||||
&& let Some(anchor) = text.align_pt
|
||||
{
|
||||
text.insert.x = anchor.x;
|
||||
text.insert.y = anchor.y;
|
||||
}
|
||||
}
|
||||
|
||||
/// Change the properties set on attribute `k` of block reference `h`.
|
||||
fn set_props(s: &mut Session, (h, k): (Handle, usize), f: impl FnOnce(&mut AttribProps)) -> Result<()> {
|
||||
s.doc_mut()?.modify_entity(h, |e| {
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
//! 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.
|
||||
//! BEDIT while the Block Editor is open switches it to another block: changes not saved yet are
|
||||
//! saved or discarded first (asked at the command line; `save` in JSON).
|
||||
//! The state is [`crate::blockedit`].
|
||||
|
||||
use cadcraft_doc::{Drawing, EntityKind, Handle};
|
||||
@@ -16,10 +18,9 @@ pub(super) fn specs() -> Vec<CommandSpec> {
|
||||
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)
|
||||
.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), save?: bool (in the Block Editor, switching to another block: save (true) or discard (false) the changes not saved yet; required when there are some), check?: bool (in the Block Editor: only check that the block can be switched to)} → {block, new, saved?}")
|
||||
.noundo()
|
||||
.interactive(|_| Ok(Box::new(BeditM))),
|
||||
.interactive(|_| Ok(Box::new(BeditM::default()))),
|
||||
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}")
|
||||
@@ -53,16 +54,6 @@ fn editing_a_block(s: &Session) -> std::result::Result<(), String> {
|
||||
}
|
||||
}
|
||||
|
||||
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",
|
||||
@@ -106,8 +97,53 @@ fn dynamic_definition(d: &Drawing, name: &str) -> Option<String> {
|
||||
(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> {
|
||||
/// Whether the open Block Editor can switch to block `name`: the block is there (or `create` makes
|
||||
/// it) and isn't one the editor refuses. Checked before anything is saved or closed, or asked.
|
||||
fn check_target(s: &Session, name: &str, create: bool) -> Result<()> {
|
||||
let host = s.state()?.block_edit.as_ref().map(|b| b.host.doc.clone()).ok_or_else(|| bad("bedit", "the Block Editor is not open"))?;
|
||||
if name.trim().eq_ignore_ascii_case(crate::blockedit::CURRENT_DRAWING) {
|
||||
return Ok(());
|
||||
}
|
||||
match host.block(name) {
|
||||
None if !create => Err(bad("bedit", format!("Block \"{name}\" not found."))),
|
||||
None => Ok(()),
|
||||
Some(_) => crate::blockedit::refusal(&host, name).map_or(Ok(()), |why| Err(bad("bedit", why))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the Block Editor on block `name` and frame its objects. While it is open on another
|
||||
/// block it switches: the changes not saved yet are saved first with `save` Some(true),
|
||||
/// discarded with Some(false); with None there must be none. The block to switch to is checked
|
||||
/// before anything is saved or closed, so a refused switch leaves the editor as it was.
|
||||
fn open(s: &mut Session, name: &str, create: bool, save: Option<bool>) -> Result<Value> {
|
||||
let mut saved = None;
|
||||
if let Some(current) = s.state()?.block_editor().map(str::to_string) {
|
||||
if current.eq_ignore_ascii_case(name.trim()) {
|
||||
s.echo(format!("The Block Editor is already editing \"{current}\"."));
|
||||
return Ok(json!({ "block": current, "new": false }));
|
||||
}
|
||||
check_target(s, name, create)?;
|
||||
let save = match (s.state()?.block_unsaved(), save) {
|
||||
(false, _) => false,
|
||||
(true, Some(save)) => save,
|
||||
(true, None) => {
|
||||
return Err(bad(
|
||||
"bedit",
|
||||
format!("Block \"{current}\" has changes not saved; pass `save: true` to save them or `save: false` to discard them"),
|
||||
));
|
||||
}
|
||||
};
|
||||
saved = close(s, save)?.get("saved").cloned();
|
||||
}
|
||||
let mut r = open_closed(s, name, create)?;
|
||||
if let (Some(v), Some(o)) = (saved, r.as_object_mut()) {
|
||||
o.insert("saved".into(), v);
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// [`open`] with the Block Editor closed.
|
||||
fn open_closed(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();
|
||||
@@ -153,7 +189,15 @@ fn run_bedit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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)
|
||||
if p.get("save").is_some_and(|v| !v.is_null() && !v.is_boolean()) {
|
||||
return Err(bad("bedit", "`save` must be true or false"));
|
||||
}
|
||||
// Only whether the Block Editor, when open, can switch to the block: nothing opens or closes.
|
||||
if bool_or(p, "check", false) && s.state()?.block_editor().is_some() {
|
||||
check_target(s, name, create)?;
|
||||
return Ok(json!({ "block": name, "checked": true }));
|
||||
}
|
||||
open(s, name, create, p.get("save").and_then(Value::as_bool))
|
||||
}
|
||||
|
||||
fn run_bsave(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
@@ -206,7 +250,27 @@ fn run_bclose(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// ---------------- interactive ----------------
|
||||
|
||||
/// BEDIT: the selected block reference's block, else a block name typed or a reference picked.
|
||||
struct BeditM;
|
||||
/// In the Block Editor with changes not saved yet, it asks whether to save them before switching.
|
||||
#[derive(Default)]
|
||||
struct BeditM {
|
||||
/// The block to switch to, waiting for the answer to "Save changes?".
|
||||
pending: Option<String>,
|
||||
}
|
||||
|
||||
impl BeditM {
|
||||
/// Go to block `name`: at once, or after asking about the changes not saved yet.
|
||||
fn go(&mut self, s: &mut Session, name: &str) -> Result<Step> {
|
||||
let st = s.state()?;
|
||||
let other = st.block_editor().is_some_and(|b| !b.eq_ignore_ascii_case(name.trim()));
|
||||
if other && st.block_unsaved() {
|
||||
// A block it can't switch to is refused before the question, which would trap the user.
|
||||
check_target(s, name.trim(), false)?;
|
||||
self.pending = Some(name.trim().to_string());
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
open(s, name, false, Some(false)).map(|_| Step::Done)
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for BeditM {
|
||||
fn name(&self) -> &'static str {
|
||||
@@ -214,14 +278,30 @@ impl Interactive for BeditM {
|
||||
}
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
match selected_reference(s) {
|
||||
Some(name) => open(s, &name, false).map(|_| Step::Done),
|
||||
Some(name) => self.go(s, &name),
|
||||
None => Ok(Step::Continue),
|
||||
}
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
fn prompt(&self, s: &Session) -> Prompt {
|
||||
if self.pending.is_some() {
|
||||
let name = s.state().ok().and_then(|st| st.block_editor().map(str::to_string)).unwrap_or_default();
|
||||
return Prompt::new(format!("Save changes to block \"{name}\"?"), KW).kw(&["Yes", "No"]).default("Y");
|
||||
}
|
||||
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> {
|
||||
if let Some(name) = self.pending.clone() {
|
||||
let answer = match i {
|
||||
Input::Enter => "Y".to_string(),
|
||||
Input::Keyword(k) | Input::Text(k) => k,
|
||||
_ => return Ok(Step::Continue),
|
||||
};
|
||||
return match answer.trim().chars().next().map(|c| c.to_ascii_uppercase()) {
|
||||
Some('Y') => open(s, &name, false, Some(true)).map(|_| Step::Done),
|
||||
Some('N') => open(s, &name, false, Some(false)).map(|_| Step::Done),
|
||||
_ => Err(EngineError::Other("Enter Yes or No.".into())),
|
||||
};
|
||||
}
|
||||
match i {
|
||||
Input::Text(t) if t.trim() == "?" => {
|
||||
let names: Vec<String> = {
|
||||
@@ -231,14 +311,14 @@ impl Interactive for BeditM {
|
||||
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::Text(t) | Input::Keyword(t) => self.go(s, t.trim()),
|
||||
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),
|
||||
Some(n) => self.go(s, &n),
|
||||
None => Err(EngineError::Other("Select a block reference.".into())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -461,7 +461,7 @@ impl Interactive for GcM {
|
||||
}
|
||||
Input::Point(p) => {
|
||||
let space = s.space();
|
||||
let Some(h) = crate::select::pick(s.doc()?, &space, p, aperture(s)) else {
|
||||
let Some(h) = s.pick_object(&space, p, aperture(s))? else {
|
||||
s.echo("*Invalid selection*");
|
||||
return Ok(Step::Continue);
|
||||
};
|
||||
@@ -706,11 +706,11 @@ impl Interactive for DcM {
|
||||
Input::Point(p) => {
|
||||
let space = s.space();
|
||||
let ap = aperture(s);
|
||||
let d = s.doc()?;
|
||||
let Some(h) = crate::select::pick(d, &space, p, ap) else {
|
||||
let Some(h) = s.pick_object(&space, p, ap)? else {
|
||||
s.echo("*Invalid selection*");
|
||||
return Ok(Step::Continue);
|
||||
};
|
||||
let d = s.doc()?;
|
||||
let want = match dc {
|
||||
Dc::Radius | Dc::Diameter => Want::Curve,
|
||||
Dc::Angular if self.refs.is_empty() => {
|
||||
|
||||
@@ -18,10 +18,9 @@ pub fn pick_aperture(s: &Session) -> f64 {
|
||||
s.pixel_size() * s.settings.pickbox.max(1.0) * 1.5
|
||||
}
|
||||
|
||||
/// The object under `p` in the current space.
|
||||
/// The object under `p` in the current space (or the one named for `p`: [`Session::pick_object`]).
|
||||
pub fn pick_at(s: &Session, p: Vec2) -> Option<Handle> {
|
||||
let space = s.space();
|
||||
crate::select::pick(s.doc().ok()?, &space, p, pick_aperture(s))
|
||||
s.pick_object(&s.space(), p, pick_aperture(s)).ok().flatten()
|
||||
}
|
||||
|
||||
pub fn entity(s: &Session, h: Handle) -> Result<Entity> {
|
||||
@@ -479,6 +478,19 @@ impl Chain {
|
||||
}
|
||||
best.1
|
||||
}
|
||||
/// The curve parameter of the point on the chain nearest `p`: the index of its segment plus
|
||||
/// the fraction along it (AutoCAD's polyline parameter; 0..1 along a line or an arc).
|
||||
pub fn param_of(&self, p: Vec2) -> f64 {
|
||||
let mut best = (f64::INFINITY, 0.0);
|
||||
for (i, s) in self.segs.iter().enumerate() {
|
||||
let c = s.closest(p);
|
||||
let dd = c.dist(p);
|
||||
if dd < best.0 {
|
||||
best = (dd, i as f64 + seg_param(s, c));
|
||||
}
|
||||
}
|
||||
best.1
|
||||
}
|
||||
/// Reversed copy.
|
||||
pub fn reversed(&self) -> Chain {
|
||||
Chain { segs: self.segs.iter().rev().map(Segment::reversed).collect(), closed: self.closed }
|
||||
@@ -506,6 +518,29 @@ impl Chain {
|
||||
}
|
||||
}
|
||||
|
||||
/// The polyline parameter of the point nearest `p` on spans `(start, end, bulge)` taken from the
|
||||
/// source vertices: the span's index plus the fraction along it. Unlike [`Chain::param_of`] it
|
||||
/// counts zero-length spans, which a polyline's parameter range includes (one per vertex) but its
|
||||
/// chain drops.
|
||||
pub fn vertex_param_of(spans: &[(Vec2, Vec2, f64)], p: Vec2) -> f64 {
|
||||
let mut best = (f64::INFINITY, 0.0);
|
||||
for (i, &(a, b, bulge)) in spans.iter().enumerate() {
|
||||
if a.near(b, EPS) {
|
||||
continue;
|
||||
}
|
||||
let seg = match cadcraft_geom::bulge_to_arc(a, b, bulge) {
|
||||
Some((arc, ccw)) => Segment::Arc { arc, ccw },
|
||||
None => Segment::Line(Line::new(a, b)),
|
||||
};
|
||||
let c = seg.closest(p);
|
||||
let dd = c.dist(p);
|
||||
if dd < best.0 {
|
||||
best = (dd, i as f64 + seg_param(&seg, c));
|
||||
}
|
||||
}
|
||||
best.1
|
||||
}
|
||||
|
||||
/// Parameter (0..1 along travel) of a point on a segment.
|
||||
pub fn seg_param(s: &Segment, p: Vec2) -> f64 {
|
||||
match s {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Draw commands: LINE, PLINE, CIRCLE, ARC, RECTANG, POLYGON, ELLIPSE, POINT, XLINE, RAY,
|
||||
//! SPLINE, DONUT, TEXT, MTEXT. Each has a JSON form and an interactive prompt sequence.
|
||||
//! SPLINE, DONUT, SOLID, TEXT, MTEXT. Each has a JSON form and an interactive prompt sequence.
|
||||
|
||||
use cadcraft_doc::{EntityKind, HAlign, MText, Point, RayLine, Text, VAlign};
|
||||
use cadcraft_geom::{Arc, Circle, Ellipse, PolyVertex, Polyline, Spline, Vec2, arc_to_bulge, bulge_to_arc};
|
||||
@@ -21,6 +21,7 @@ mod ellipse;
|
||||
mod line_pline;
|
||||
mod point;
|
||||
mod polygon;
|
||||
mod solid;
|
||||
mod spline;
|
||||
mod text;
|
||||
|
||||
@@ -30,6 +31,7 @@ use ellipse::{EllipseM, run_ellipse};
|
||||
use line_pline::{LineM, PlineM, run_line, run_pline};
|
||||
use point::{DonutM, PointM, RayM, run_donut, run_point, run_ray};
|
||||
use polygon::{PolygonM, run_polygon};
|
||||
use solid::{SolidM, run_solid};
|
||||
use spline::{SplineM, run_spline};
|
||||
use text::{MTextM, TextM, run_mtext, run_text};
|
||||
|
||||
@@ -100,6 +102,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.alias(&["do", "doughnut"])
|
||||
.params("{center, inside, outside}")
|
||||
.interactive(|_| Ok(Box::new(DonutM::default()))),
|
||||
CommandSpec::new("solid", "2D Solid", run_solid)
|
||||
.menu(&["Draw", "2D Solid"])
|
||||
.alias(&["so"])
|
||||
.params("{points: [[x,y],...]} (three or four for the first solid, two more for each next one, chained on its last two corners)")
|
||||
.interactive(|_| Ok(Box::new(SolidM::default()))),
|
||||
CommandSpec::new("text", "Single Line Text", run_text)
|
||||
.menu(&["Draw", "Text", "Single Line Text"])
|
||||
.alias(&["dt", "dtext"])
|
||||
|
||||
@@ -34,19 +34,24 @@ pub(super) struct EllipseM {
|
||||
/// The `Parameter` option: arc start/end values are ellipse parameters, not true angles.
|
||||
param: bool,
|
||||
start: Option<f64>,
|
||||
/// The `Included angle` option: the next value is the arc's angle from its start angle.
|
||||
included: bool,
|
||||
/// The Isocircle option (isometric snap style) in progress.
|
||||
iso: Option<crate::cmd::settings::iso::IsoCircle>,
|
||||
}
|
||||
|
||||
impl EllipseM {
|
||||
fn axis(&self) -> Option<(Vec2, Vec2)> {
|
||||
// (center, major vector)
|
||||
// (center, major vector): towards the first axis endpoint, as AutoCAD stores it.
|
||||
match (self.center_mode, self.pts.as_slice()) {
|
||||
(true, [c, e, ..]) => Some((*c, *e - *c)),
|
||||
(false, [a, b, ..]) => Some((a.mid(*b), *b - a.mid(*b))),
|
||||
(false, [a, b, ..]) => Some((a.mid(*b), *a - a.mid(*b))),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// The other half axis reaches as far as `other` is from the center (AutoCAD measures the
|
||||
/// picked point's distance from the center, not from the first axis). Longer than the
|
||||
/// first half axis, it becomes the major axis: the first axis turned 90° counterclockwise.
|
||||
fn build(&self, other: Vec2) -> Option<Ellipse> {
|
||||
let (c, m) = self.axis()?;
|
||||
let ml = m.len();
|
||||
@@ -54,7 +59,7 @@ impl EllipseM {
|
||||
return None;
|
||||
}
|
||||
let u = m / ml;
|
||||
let minor = ((other - c).dot(u.perp())).abs();
|
||||
let minor = (other - c).len();
|
||||
let (major, ratio) = if minor > ml { (u.perp() * minor, ml / minor) } else { (m, minor / ml) };
|
||||
(ratio > 1e-9).then_some(Ellipse::full(c, major, ratio.min(1.0)))
|
||||
}
|
||||
@@ -88,13 +93,16 @@ impl Interactive for EllipseM {
|
||||
}
|
||||
fn prompt(&self, s: &Session) -> Prompt {
|
||||
if let Some(e) = &self.ellipse {
|
||||
let msg = match (self.start.is_none(), self.param) {
|
||||
(true, false) => "Specify start angle",
|
||||
(false, false) => "Specify end angle",
|
||||
(true, true) => "Specify start parameter",
|
||||
(false, true) => "Specify end parameter",
|
||||
if self.included {
|
||||
return Prompt::new("Specify included angle for arc", Accept::POINT_OR_NUMBER).default("180").base(e.center);
|
||||
}
|
||||
let (msg, kw): (&str, &[&str]) = match (self.start.is_none(), self.param) {
|
||||
(true, false) => ("Specify start angle", &["Parameter"]),
|
||||
(false, false) => ("Specify end angle", &["Parameter", "Included angle"]),
|
||||
(true, true) => ("Specify start parameter", &["Angle"]),
|
||||
(false, true) => ("Specify end parameter", &["Angle"]),
|
||||
};
|
||||
return Prompt::new(msg, Accept::POINT_OR_NUMBER).kw(if self.param { &["Angle"] } else { &["Parameter"] }).base(e.center);
|
||||
return Prompt::new(msg, Accept::POINT_OR_NUMBER).kw(kw).base(e.center);
|
||||
}
|
||||
if let Some(iso) = &self.iso {
|
||||
return iso.prompt();
|
||||
@@ -112,11 +120,32 @@ impl Interactive for EllipseM {
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
if let Some(e) = self.ellipse {
|
||||
let ge = Ellipse { start: 0.0, end: cadcraft_geom::TAU, ..e };
|
||||
if self.included {
|
||||
let incl = match i {
|
||||
Input::Point(p) => (p - e.center).angle(),
|
||||
Input::Text(t) => s.angle_settings().amount(&t).ok_or_else(|| crate::EngineError::Other("Requires an angle.".into()))?,
|
||||
Input::Enter => std::f64::consts::PI,
|
||||
_ => return Ok(Step::Continue),
|
||||
};
|
||||
// A true angle added to the start angle (AutoCAD: start 214.659°, included
|
||||
// 232.716° ends the arc at 87.375° from the major axis).
|
||||
let st = self.start.unwrap_or(0.0);
|
||||
let end = ge.param_of(e.center + Vec2::from_angle((ge.at_param(st) - e.center).angle() + incl));
|
||||
s.add_entity(EntityKind::Ellipse(cadcraft_doc::Ellipse {
|
||||
center: v3(e.center),
|
||||
major: v3(e.major),
|
||||
ratio: e.ratio,
|
||||
start: st,
|
||||
end,
|
||||
}))?;
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
let ang = match i {
|
||||
Input::Keyword(k) => {
|
||||
match k.as_str() {
|
||||
"Parameter" => self.param = true,
|
||||
"Angle" => self.param = false,
|
||||
"Included angle" if self.start.is_some() && !self.param => self.included = true,
|
||||
_ => {}
|
||||
}
|
||||
return Ok(Step::Continue);
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
//! SOLID: filled triangles and quadrilaterals (2D solids), chained as AutoCAD 2024 chains them.
|
||||
//!
|
||||
//! The prompts are "Specify first point", "Specify second point", "Specify third point" and
|
||||
//! "Specify fourth point or <exit>"; Enter there makes a triangle (the fourth corner repeats the
|
||||
//! third). Then "Specify third point" and "Specify fourth point or <exit>" come again for the next
|
||||
//! solid, whose first two corners are the previous one's last two, until Enter at "Specify third
|
||||
//! point". Enter there right after the second point makes nothing.
|
||||
|
||||
use cadcraft_doc::Solid;
|
||||
|
||||
use super::{Accept, EntityKind, Input, Interactive, Prompt, Result, Session, Step, Value, Vec2, bad, json, points_param, v3};
|
||||
|
||||
/// A 2D solid from its corners in AutoCAD's order (the fourth is drawn opposite the first).
|
||||
fn solid(c: [Vec2; 4]) -> EntityKind {
|
||||
EntityKind::Solid(Solid { corners: c.map(v3) })
|
||||
}
|
||||
|
||||
/// `{points}`: the first solid takes three or four points, each further one two more, its first
|
||||
/// two corners being the previous one's last two; a solid given three points is a triangle.
|
||||
pub(super) fn run_solid(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let pts = points_param(p, "points").ok_or_else(|| bad("solid", "`points` is required"))?;
|
||||
let mut it = pts.into_iter();
|
||||
let (Some(mut a), Some(mut b), Some(_)) = (it.next(), it.next(), it.clone().next()) else {
|
||||
return Err(bad("solid", "`points` needs at least three points"));
|
||||
};
|
||||
let mut hs = Vec::new();
|
||||
while let Some(c) = it.next() {
|
||||
let d = it.next().unwrap_or(c);
|
||||
hs.push(s.add_entity(solid([a, b, c, d]))?.hex());
|
||||
(a, b) = (c, d);
|
||||
}
|
||||
Ok(json!({ "handles": hs }))
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct SolidM {
|
||||
/// The corners of the solid being drawn (the first two from the previous one after it).
|
||||
pts: Vec<Vec2>,
|
||||
}
|
||||
|
||||
impl Interactive for SolidM {
|
||||
fn name(&self) -> &'static str {
|
||||
"SOLID"
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
let last = self.pts.last().copied();
|
||||
match self.pts.len() {
|
||||
0 => Prompt::new("Specify first point", Accept::POINT),
|
||||
1 => Prompt::new("Specify second point", Accept::POINT).base_opt(last),
|
||||
2 => Prompt::new("Specify third point", Accept::POINT).base_opt(last),
|
||||
_ => Prompt::new("Specify fourth point or", Accept::POINT).default("exit").base_opt(last),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
match (i, self.pts.as_slice()) {
|
||||
(Input::Point(p), &[a, b, c]) => self.make(s, [a, b, c, p])?,
|
||||
(Input::Point(p), _) => self.pts.push(p),
|
||||
(Input::Enter, &[a, b, c]) => self.make(s, [a, b, c, c])?,
|
||||
(Input::Enter, _) => return Ok(Step::Done),
|
||||
_ => {}
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
fn preview(&self, _s: &Session, cursor: Vec2) -> Vec<EntityKind> {
|
||||
match *self.pts.as_slice() {
|
||||
[a, b] => vec![solid([a, b, cursor, cursor])],
|
||||
[a, b, c] => vec![solid([a, b, c, cursor])],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SolidM {
|
||||
fn make(&mut self, s: &mut Session, c: [Vec2; 4]) -> Result<()> {
|
||||
s.add_entity(solid(c))?;
|
||||
self.pts = vec![c[2], c[3]];
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -105,7 +105,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.interactive(|_| Ok(Box::new(TanCircleM::new(false)))),
|
||||
CommandSpec::new("ellipse.axis", "Axis, End", run_ellipse_axis)
|
||||
.menu(&["Draw", "Ellipse", "Axis, End"])
|
||||
.params("{p1, p2, distance} | {p1, p2, p3} (axis endpoints, then half the other axis)")
|
||||
.params("{p1, p2, distance} | {p1, p2, p3} (axis endpoints, then half the other axis: a distance, or a point that far from the center)")
|
||||
.interactive(|_| Ok(super::draw::ellipse_machine(false, false))),
|
||||
CommandSpec::new("ellipse.arc", "Elliptical Arc", run_ellipse_arc)
|
||||
.menu(&["Draw", "Ellipse", "Arc"])
|
||||
|
||||
@@ -74,12 +74,43 @@ pub(super) fn run_measure(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let block = str_param(p, "block").map(str::to_string);
|
||||
check_block(s, block.as_deref())?;
|
||||
if let Some(from) = point_param(p, "from")
|
||||
&& !c.closed
|
||||
&& let (Some(a), Some(b)) = (c.start(), c.end())
|
||||
&& from.dist(b) < from.dist(a)
|
||||
{
|
||||
c = c.reversed();
|
||||
// AutoCAD measures from the end the pick is nearer by the curve's parameter (segment index
|
||||
// plus the fraction along it), not by distance: an open curve from its end once the pick is
|
||||
// halfway or further, a closed one from its first vertex, backwards from halfway on. A
|
||||
// circle always runs counterclockwise from angle 0.
|
||||
if let Some(from) = point_param(p, "from") {
|
||||
let past_half = match curves::entity(s, h)?.kind {
|
||||
EntityKind::Circle(_) => false,
|
||||
// At an arc's centre, where a bare name given from AutoLISP stands (its DXF group 10
|
||||
// point), every point of it is as near: AutoCAD measures from its end.
|
||||
EntityKind::Arc(a) if from.dist(a.center.xy()) <= a.radius * 1e-9 => true,
|
||||
// A spline by its own parameter: its tessellation drops zero-length pieces, so the
|
||||
// segment count is not the spline's parameter range.
|
||||
EntityKind::Spline(sp) => {
|
||||
let (lo, hi) = sp.domain();
|
||||
curves::spline_param_of(&sp, from) >= (lo + hi) / 2.0
|
||||
}
|
||||
// Polylines by their vertices, coincident ones included (one parameter unit each,
|
||||
// as in AutoCAD), which the chain drops.
|
||||
EntityKind::LwPolyline(pl) => {
|
||||
let n = pl.vertices.len();
|
||||
let spans: Vec<_> = (0..if pl.closed { n } else { n.saturating_sub(1) })
|
||||
.filter_map(|i| Some((pl.vertices.get(i)?.p, pl.vertices.get((i + 1) % n)?.p, pl.vertices.get(i)?.bulge)))
|
||||
.collect();
|
||||
curves::vertex_param_of(&spans, from) >= spans.len() as f64 / 2.0
|
||||
}
|
||||
EntityKind::Polyline3d(pl) => {
|
||||
let n = pl.points.len();
|
||||
let spans: Vec<_> = (0..if pl.closed { n } else { n.saturating_sub(1) })
|
||||
.filter_map(|i| Some((pl.points.get(i)?.xy(), pl.points.get((i + 1) % n)?.xy(), 0.0)))
|
||||
.collect();
|
||||
curves::vertex_param_of(&spans, from) >= spans.len() as f64 / 2.0
|
||||
}
|
||||
_ => c.param_of(from) >= c.segs.len() as f64 / 2.0,
|
||||
};
|
||||
if past_half {
|
||||
c = c.reversed();
|
||||
}
|
||||
}
|
||||
let pts = measure_points(&c, step);
|
||||
let hs = place_markers(s, &pts, block.as_deref(), bool_or(p, "align", true))?;
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
use super::{Ellipse, EntityKind, Line, Result, Session, TAU, Value, Vec2, added, bad, f64_req, point_param, point_req, v3};
|
||||
use super::{Ellipse, EntityKind, Result, Session, TAU, Value, Vec2, added, bad, f64_req, point_param, point_req, v3};
|
||||
|
||||
// ---------------- ellipse entries ----------------
|
||||
|
||||
fn ellipse_from_axis(p1: Vec2, p2: Vec2, half: f64) -> Option<Ellipse> {
|
||||
let c = p1.mid(p2);
|
||||
let m = p2 - c;
|
||||
// The major axis points at the first endpoint, as AutoCAD stores it.
|
||||
let m = p1 - c;
|
||||
let ml = m.len();
|
||||
let half = half.abs();
|
||||
if ml < 1e-12 || half < 1e-12 || !half.is_finite() {
|
||||
@@ -17,10 +18,8 @@ fn axis_ellipse_params(cmd: &str, p: &Value) -> Result<Ellipse> {
|
||||
let p1 = point_req(cmd, p, "p1")?;
|
||||
let p2 = point_req(cmd, p, "p2")?;
|
||||
let half = match point_param(p, "p3") {
|
||||
Some(p3) => {
|
||||
let ax = Line::new(p1, p2);
|
||||
ax.project(p3).dist(p3)
|
||||
}
|
||||
// As at the prompt: the point's distance from the center, not from the first axis.
|
||||
Some(p3) => p3.dist(p1.mid(p2)),
|
||||
None => f64_req(cmd, p, "distance")?,
|
||||
};
|
||||
ellipse_from_axis(p1, p2, half).ok_or_else(|| bad(cmd, "degenerate ellipse"))
|
||||
|
||||
@@ -270,6 +270,118 @@ fn ellipse_arc_typed_angles_are_true_angles() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Typed ELLIPSE input against what AutoCAD 2024 stores for the same input (black-box: synthetic
|
||||
/// input in accoreconsole, read back with `entget`). The major axis points at the first axis
|
||||
/// endpoint; a point at the distance prompt gives its distance from the center; an other axis
|
||||
/// longer than the first becomes the major axis, the first turned 90° counterclockwise; the
|
||||
/// Included angle is added to the start angle.
|
||||
#[test]
|
||||
fn ellipse_axis_endpoints_match_autocad() {
|
||||
type Case = (&'static str, [f64; 2], [f64; 2], f64, Option<[f64; 2]>);
|
||||
// (input after ELLIPSE, center, major axis, ratio, arc start/end parameters)
|
||||
let cases: [Case; 10] = [
|
||||
// The other axis (27.02) is longer than the first half axis (26.42).
|
||||
(
|
||||
"16.459468652708743,28.120073391143574 -5.034438825478263,-20.14843889196848 27.021868057769268",
|
||||
[5.712514913615239, 3.985817249587547],
|
||||
[-24.6850622596004, 10.99222700689644],
|
||||
0.9776866627982613,
|
||||
None,
|
||||
),
|
||||
(
|
||||
"33.45015752893423,-27.997665254733153 1.0902141405158297,-9.876490849047837 _R 36.361957504949",
|
||||
[17.27018583472502, -18.93707805189049],
|
||||
[16.1799716942092, -9.060587202842658],
|
||||
0.8052876303433125,
|
||||
None,
|
||||
),
|
||||
(
|
||||
"0.5161247992610992,0.27380397971284964 0.49874786589184156,0.23294359917255586 0.0026647279687818256",
|
||||
[0.5074363325764704, 0.2533737894427027],
|
||||
[0.00868846668462886, 0.02043019027014691],
|
||||
0.1200276615332929,
|
||||
None,
|
||||
),
|
||||
// Arcs: the angles are taken from the major axis, so a reversed axis draws the arc
|
||||
// on the opposite side of the center.
|
||||
(
|
||||
"_A 612354.66928411,2398796.595432055 612364.9910379789,2398751.70959102 3.5361631006792016 259.40675054209663 162.0895061623204",
|
||||
[612359.8301610444, 2398774.152511537],
|
||||
[-5.160876934416592, 22.44292051764205],
|
||||
0.1535548572193783,
|
||||
Some([4.683678586016272, 2.014340486820176]),
|
||||
),
|
||||
(
|
||||
"_A -32.49789607178266,25.826299666765127 16.360636510348964,-6.670894598441254 9.326202777586586 _P 235.35128996877393 341.0349294763654",
|
||||
[-8.068629780716847, 9.577702534161936],
|
||||
[-24.42926629106581, 16.24859713260318],
|
||||
0.317871961092373,
|
||||
Some([4.107654908771007, 5.952182383669234]),
|
||||
),
|
||||
(
|
||||
"_A _C 15.895131938563559,-16.135236520373613 1.9672122380661037,-18.61293795336168 14.456880177932417 214.65903577683966 _I 232.7163597705933",
|
||||
[15.89513193856356, -16.13523652037361],
|
||||
[2.53204749973717, -14.23341561039267],
|
||||
0.9785367111972643,
|
||||
Some([3.756694588158375, 1.5259701966906878]),
|
||||
),
|
||||
(
|
||||
"_A _C 1.8822645373261935,-7.920292708078534 28.89115893105938,-23.280023918605742 38.7551404612689 136.20071263173944 _I 129.90755425686075",
|
||||
[1.882264537326193, -7.920292708078534],
|
||||
[19.15838502833398, 33.68853210335513],
|
||||
0.8017236937148501,
|
||||
Some([2.267136066480607, 4.657903136501946]),
|
||||
),
|
||||
// The other axis given by a point.
|
||||
(
|
||||
"-16.377480557612035,55.44699770062272 -15.579405511585826,-24.406726331471475 -4.27081078679767,42.69814401696656",
|
||||
[-15.97844303459893, 15.52013568457562],
|
||||
[-0.3990375230131046, 39.92686201604709],
|
||||
0.7411292927991227,
|
||||
None,
|
||||
),
|
||||
(
|
||||
"8.137326847228081,16.045209411091605 -2.4500932561568627,22.585405165312338 -2.4956573993017335,12.374292543621266",
|
||||
[2.843616795535609, 19.31530728820197],
|
||||
[4.602217644463162, 7.450176331143755],
|
||||
0.7105482899194384,
|
||||
None,
|
||||
),
|
||||
(
|
||||
"_C 15.418189586495892,-18.741133769301225 43.29367724019212,5.337090633133826 12.043993630141971,-7.0535727363182446",
|
||||
[15.41818958649589, -18.74113376930122],
|
||||
[27.87548765369622, 24.07822440243505],
|
||||
0.3302549104883693,
|
||||
None,
|
||||
),
|
||||
];
|
||||
use std::f64::consts::{PI, TAU};
|
||||
let same = |a: f64, b: f64| (a - b).abs() <= 1e-9 * a.abs().max(b.abs()).max(1.0);
|
||||
let same_angle = |a: f64, b: f64| {
|
||||
let d = (a - b).rem_euclid(TAU);
|
||||
d < 1e-9 || TAU - d < 1e-9
|
||||
};
|
||||
for (input, center, major, ratio, arc) in cases {
|
||||
let mut s = Session::new();
|
||||
s.cmdline(&format!("ellipse {input}")).unwrap();
|
||||
assert!(s.running.is_none(), "{input}: {}", s.prompt_text());
|
||||
let EntityKind::Ellipse(e) = last(&s) else { panic!("{input}: no ellipse") };
|
||||
assert!(same(e.center.x, center[0]) && same(e.center.y, center[1]), "{input}: center {:?}", e.center);
|
||||
assert!(same(e.major.x, major[0]) && same(e.major.y, major[1]), "{input}: major {:?}", e.major);
|
||||
assert!(same(e.ratio, ratio), "{input}: ratio {}", e.ratio);
|
||||
let [start, end] = arc.unwrap_or([0.0, TAU]);
|
||||
assert!(same_angle(e.start, start) && same_angle(e.end, end), "{input}: {} {}", e.start, e.end);
|
||||
}
|
||||
// Enter takes the default included angle, 180°: half the ellipse.
|
||||
let mut s = Session::new();
|
||||
s.cmdline("ellipse _A 10,0 -10,0 5 30 _I").unwrap();
|
||||
assert!(s.prompt_text().contains("included angle"), "{}", s.prompt_text());
|
||||
s.input(Input::Enter).unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
let EntityKind::Ellipse(e) = last(&s) else { panic!("no ellipse") };
|
||||
assert!(same_angle(e.end, e.start + PI), "{} {}", e.start, e.end);
|
||||
}
|
||||
|
||||
// ---------------- DIVIDE / MEASURE ----------------
|
||||
|
||||
#[test]
|
||||
@@ -302,6 +414,279 @@ fn divide_and_measure_json() {
|
||||
assert!(s.execute("measure", &json!({"handle": l, "length": 1e-300})).is_err());
|
||||
}
|
||||
|
||||
/// DIVIDE and MEASURE against the points AutoCAD 2024 places for the same object and pick
|
||||
/// (black-box: synthetic objects in accoreconsole, `(command "_.MEASURE" (list ename point) …)`,
|
||||
/// every POINT read back with `entget`). The pick chooses the end by the curve's parameter, not
|
||||
/// by distance; a closed polyline runs from its first vertex, a circle from angle 0.
|
||||
#[test]
|
||||
fn divide_and_measure_match_autocad() {
|
||||
type Case = ((&'static str, serde_json::Value), &'static str, f64, [f64; 2], usize, &'static [[f64; 2]]);
|
||||
// ((seed command, params), command, segments or length, pick, POINT count, AutoCAD points)
|
||||
let cases: Vec<Case> = vec![
|
||||
// DIVIDE-001: (list ename point).
|
||||
(
|
||||
("arc", json!({"center": [0.0, 0.0], "radius": 20.0, "start": 30.0, "end": 250.0})),
|
||||
"divide",
|
||||
5.0,
|
||||
[-2.090569265353071, 19.890437907365467],
|
||||
4,
|
||||
&[
|
||||
[-19.021130325903, 6.180339887499],
|
||||
[-17.975880925983, -8.767422935782],
|
||||
[-9.389431255718, 17.658951857179],
|
||||
[5.51274711634, 19.225233918766],
|
||||
],
|
||||
),
|
||||
// DIVIDE-002: (list ename point).
|
||||
(
|
||||
("circle", json!({"center": [0.0, 0.0], "radius": 15.0})),
|
||||
"divide",
|
||||
5.0,
|
||||
[-4.63525491562421, 14.265847744427305],
|
||||
5,
|
||||
&[
|
||||
[-12.135254915624, -8.816778784387],
|
||||
[-12.135254915624, 8.816778784387],
|
||||
[4.635254915624, -14.265847744427],
|
||||
[4.635254915624, 14.265847744427],
|
||||
[15.0, 0.0],
|
||||
],
|
||||
),
|
||||
// MEASURE-016: (list ename point).
|
||||
(
|
||||
("line", json!({"points": [[0.0, 0.0], [37.0, 13.0]]})),
|
||||
"measure",
|
||||
7.688652713279647,
|
||||
[25.9, 9.1],
|
||||
5,
|
||||
&[
|
||||
[0.730313187001, 0.256596525162],
|
||||
[7.984250549601, 2.80527722013],
|
||||
[15.2381879122, 5.353957915097],
|
||||
[22.4921252748, 7.902638610065],
|
||||
[29.7460626374, 10.451319305032],
|
||||
],
|
||||
),
|
||||
// MEASURE-024: (list ename point).
|
||||
(
|
||||
(
|
||||
"pline",
|
||||
json!({"vertices": [{"p": [0.0, 0.0], "bulge": 0.0}, {"p": [100.0, 0.0], "bulge": 0.0}, {"p": [100.0, 10.0], "bulge": 0.0}, {"p": [5.0, 10.0], "bulge": 0.0}], "closed": false}),
|
||||
),
|
||||
"measure",
|
||||
7.0,
|
||||
[90.0, 0.0],
|
||||
29,
|
||||
&[[7.0, 0.0], [7.0, 10.0], [14.0, 0.0], [98.0, 0.0], [98.0, 10.0], [100.0, 5.0]],
|
||||
),
|
||||
// MEASURE-026: (list ename point).
|
||||
(
|
||||
(
|
||||
"pline",
|
||||
json!({"vertices": [{"p": [0.0, 0.0], "bulge": 0.0}, {"p": [10.0, 0.0], "bulge": 0.0}, {"p": [10.0, 100.0], "bulge": 0.0}], "closed": false}),
|
||||
),
|
||||
"measure",
|
||||
7.0,
|
||||
[10.0, 30.0],
|
||||
15,
|
||||
&[[5.0, 0.0], [10.0, 2.0], [10.0, 9.0], [10.0, 79.0], [10.0, 86.0], [10.0, 93.0]],
|
||||
),
|
||||
// MEASURE-030: (list ename point).
|
||||
(
|
||||
(
|
||||
"pline",
|
||||
json!({"vertices": [{"p": [0.0, 0.0], "bulge": 0.0}, {"p": [30.0, 0.0], "bulge": 0.5}, {"p": [45.0, 20.0], "bulge": 0.0}, {"p": [10.0, 35.0], "bulge": 0.0}], "closed": true}),
|
||||
),
|
||||
"measure",
|
||||
7.5,
|
||||
[5.0, 17.5],
|
||||
17,
|
||||
&[
|
||||
[2.06040845923, 7.211429607306],
|
||||
[4.120816918461, 14.422859214612],
|
||||
[5.957390538273, 0.0],
|
||||
[41.854257779673, 5.445746712402],
|
||||
[45.137412221497, 19.497902817843],
|
||||
[45.215233654661, 12.07010340037],
|
||||
],
|
||||
),
|
||||
// DIVIDE-028: (list ename point).
|
||||
(
|
||||
(
|
||||
"pline",
|
||||
json!({"vertices": [{"p": [0.0, 0.0], "bulge": 0.0}, {"p": [30.0, 0.0], "bulge": 0.5}, {"p": [45.0, 20.0], "bulge": 0.0}, {"p": [10.0, 35.0], "bulge": 0.0}], "closed": true}),
|
||||
),
|
||||
"divide",
|
||||
5.0,
|
||||
[37.5, 10.0],
|
||||
5,
|
||||
&[
|
||||
[0.0, 0.0],
|
||||
[7.332712970983, 25.664495398441],
|
||||
[25.609294779385, 28.310302237406],
|
||||
[26.691478107655, 0.0],
|
||||
[45.58190593662, 14.465330916891],
|
||||
],
|
||||
),
|
||||
// MEASURE-041: (list ename point).
|
||||
(
|
||||
(
|
||||
"pline",
|
||||
json!({"vertices": [{"p": [0.0, 0.0], "bulge": 0.0}, {"p": [30.0, 0.0], "bulge": 0.5}, {"p": [45.0, 20.0], "bulge": 0.0}, {"p": [10.0, 35.0], "bulge": 0.0}], "closed": true}),
|
||||
),
|
||||
"measure",
|
||||
7.5,
|
||||
[45.54951565955276, 17.15901034980981],
|
||||
17,
|
||||
&[
|
||||
[1.636621047999, 5.728173667998],
|
||||
[3.69702950723, 12.939603275304],
|
||||
[5.75743796646, 20.15103288261],
|
||||
[42.799868254703, 6.663750217618],
|
||||
[44.060611317761, 20.402595149531],
|
||||
[45.491536690495, 13.587129550966],
|
||||
],
|
||||
),
|
||||
// MEASURE-042: (list ename point).
|
||||
(
|
||||
(
|
||||
"pline",
|
||||
json!({"vertices": [{"p": [0.0, 0.0], "bulge": 0.0}, {"p": [30.0, 0.0], "bulge": 0.5}, {"p": [45.0, 20.0], "bulge": 0.0}, {"p": [10.0, 35.0], "bulge": 0.0}], "closed": true}),
|
||||
),
|
||||
"measure",
|
||||
7.5,
|
||||
[44.0, 20.5],
|
||||
17,
|
||||
&[
|
||||
[2.06040845923, 7.211429607306],
|
||||
[4.120816918461, 14.422859214612],
|
||||
[5.957390538273, 0.0],
|
||||
[41.854257779673, 5.445746712402],
|
||||
[45.137412221497, 19.497902817843],
|
||||
[45.215233654661, 12.07010340037],
|
||||
],
|
||||
),
|
||||
];
|
||||
for ((seed, params), what, value, pick, count, want) in cases {
|
||||
let mut s = Session::new();
|
||||
let r = s.execute(seed, ¶ms).unwrap();
|
||||
let handle = r.get("handle").or_else(|| r["handles"].get(0)).and_then(serde_json::Value::as_str).unwrap().to_string();
|
||||
// AutoLISP's (list ename point): the object and where it was picked.
|
||||
let params = if what == "divide" {
|
||||
json!({"handle": handle, "segments": value as u64, "from": pick})
|
||||
} else {
|
||||
json!({"handle": handle, "length": value, "from": pick})
|
||||
};
|
||||
s.execute(what, ¶ms).unwrap();
|
||||
let pts: Vec<Vec2> =
|
||||
s.doc().unwrap().model.iter().filter_map(|e| if let EntityKind::Point(p) = &e.kind { Some(p.p.xy()) } else { None }).collect();
|
||||
assert_eq!(pts.len(), count, "{what} {pick:?}: {pts:?}");
|
||||
for w in want {
|
||||
let tol = 1e-9 * w[0].abs().max(w[1].abs()).max(1.0);
|
||||
assert!(pts.iter().any(|p| p.near(Vec2::new(w[0], w[1]), tol)), "{what} {pick:?}: no point at {w:?} in {pts:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A spline's end is chosen by its own parameter: this one spends half its parameter range on a
|
||||
/// zero-length first span, so a pick at x = 3 is already past its middle (parameter 1.3 of 0..2) and
|
||||
/// MEASURE runs from the end, though x = 3 is short of the middle of the drawn length.
|
||||
#[test]
|
||||
fn measure_spline_by_its_own_parameter() {
|
||||
let mut s = Session::new();
|
||||
let sp = h(&s.execute("spline", &json!({"control": [[0, 0], [0, 0], [10, 0]], "degree": 1})).unwrap());
|
||||
s.execute("measure", &json!({"handle": sp, "length": 3, "from": [3, 0]})).unwrap();
|
||||
let pts: Vec<Vec2> =
|
||||
s.doc().unwrap().model.iter().filter_map(|e| if let EntityKind::Point(p) = &e.kind { Some(p.p.xy()) } else { None }).collect();
|
||||
assert!(pts.len() == 3 && [7.0, 4.0, 1.0].iter().all(|x| pts.iter().any(|p| near(*p, *x, 0.0))), "{pts:?}");
|
||||
}
|
||||
|
||||
/// MEASURE counts every vertex in a polyline's parameter, coincident ones too (AutoCAD's polyline
|
||||
/// parameter is the vertex index; the chain drops zero-length spans), and picks an ellipse's end
|
||||
/// by the ellipse parameter like any other curve: AutoCAD 2024 runs both measured cases below from
|
||||
/// the end (black-box, accoreconsole, POINTs read back with `entget`).
|
||||
#[test]
|
||||
fn measure_counts_coincident_vertices_and_runs_ellipses_by_parameter() {
|
||||
let pts = |s: &Session| -> Vec<Vec2> {
|
||||
s.doc().unwrap().model.iter().filter_map(|e| if let EntityKind::Point(p) = &e.kind { Some(p.p.xy()) } else { None }).collect()
|
||||
};
|
||||
// (0,0) (1,0) (101,0) (101,0) picked at x = 1.1: parameter 1.001 of 3, before the middle, so
|
||||
// from the start (the chain alone, 1.001 of 2, ran it from the end).
|
||||
let mut s = Session::new();
|
||||
let pl = h(&s.execute("pline", &json!({"vertices": [[0, 0], [1, 0], [101, 0], [101, 0]]})).unwrap());
|
||||
s.execute("measure", &json!({"handle": pl, "length": 30, "from": [1.1, 0]})).unwrap();
|
||||
assert!(pts(&s).iter().any(|p| near(*p, 30.0, 0.0)), "{:?}", pts(&s));
|
||||
// (0,0) (0,0) (10,0) (20,0) picked at x = 7: parameter 1.7 of 3, past the middle, so from the
|
||||
// end; the same as a 3D polyline.
|
||||
for (cmd, params) in [
|
||||
("pline", json!({"vertices": [[0, 0], [0, 0], [10, 0], [20, 0]]})),
|
||||
("3dpoly", json!({"points": [[0, 0, 0], [0, 0, 0], [10, 0, 0], [20, 0, 0]]})),
|
||||
] {
|
||||
let mut s = Session::new();
|
||||
let pl = h(&s.execute(cmd, ¶ms).unwrap());
|
||||
s.execute("measure", &json!({"handle": pl, "length": 3, "from": [7, 0]})).unwrap();
|
||||
assert!(pts(&s).iter().any(|p| near(*p, 17.0, 0.0)), "{cmd}: {:?}", pts(&s));
|
||||
}
|
||||
// AutoCAD's points. CADCraft measures along a tessellation (1e-4 of the major axis), so its
|
||||
// points sit within 2e-4 of the axis of AutoCAD's; the other end is 4 to 200 units away.
|
||||
type Ellipse = ([f64; 2], [f64; 2], f64, f64, f64);
|
||||
let cases: [((Ellipse, f64, [f64; 2]), &[[f64; 2]]); 2] = [
|
||||
// MEASURE-032-TO: a full ellipse picked at t = pi.
|
||||
(
|
||||
(([-35000.0, 81000.0], [1000.0, 250.0], 0.5, 0.0, std::f64::consts::TAU), 325.0, [-36000.0, 80750.0]),
|
||||
&[
|
||||
[-36005.13895764338, 80852.59066530415],
|
||||
[-35871.99367437639, 80574.74515309861],
|
||||
[-35846.48143804769, 81129.79467385833],
|
||||
[-35585.0606965763, 81321.20806711384],
|
||||
[-35570.69066464184, 80460.16118260568],
|
||||
[-35288.01208649818, 81451.990340526],
|
||||
[-35246.80459288685, 80443.34080344986],
|
||||
[-34973.3365764063, 81531.8855660685],
|
||||
[-34925.28109250207, 80488.09750605426],
|
||||
[-34649.89308676404, 81558.94977503068],
|
||||
[-34615.08169799787, 80583.88008545575],
|
||||
[-34328.55263503237, 81517.06267451325],
|
||||
[-34326.49236487653, 80732.20222770158],
|
||||
[-34085.24055588816, 80947.67206458953],
|
||||
[-34052.78599900089, 81354.8654746607],
|
||||
],
|
||||
),
|
||||
// MEASURE-033: an elliptical arc picked past its middle.
|
||||
(
|
||||
(([0.0, 0.0], [20.0, 5.0], 0.5, 0.5, 4.0), 4.25, [-14.28970951093154, -10.073417468999274]),
|
||||
&[
|
||||
[-20.14815072114992, -3.432193431111516],
|
||||
[-18.74987859459168, -7.329883069345779],
|
||||
[-18.68559214389327, 0.5029121733397516],
|
||||
[-15.81338990685036, 3.617741906558092],
|
||||
[-15.26656288688172, -9.70206428611582],
|
||||
[-12.33992351852055, 6.057945492888583],
|
||||
[-8.547503008323163, 7.9699830580747],
|
||||
[-4.557798848614094, 9.428419587410612],
|
||||
[-0.4368018715850841, 10.45983604769949],
|
||||
[3.769519107506058, 11.05284388252232],
|
||||
[8.015496424961295, 11.15319778065308],
|
||||
[12.22912105170455, 10.6383262710487],
|
||||
[16.23125506912425, 9.247174221779819],
|
||||
],
|
||||
),
|
||||
];
|
||||
for (((center, major, ratio, t0, t1), length, from), want) in cases {
|
||||
let mut s = Session::new();
|
||||
let e = h(&s
|
||||
.execute("ellipse", &json!({"center": center, "major": major, "ratio": ratio, "start": t0.to_degrees(), "end": t1.to_degrees()}))
|
||||
.unwrap());
|
||||
s.execute("measure", &json!({"handle": e, "length": length, "from": from})).unwrap();
|
||||
let got = pts(&s);
|
||||
let tol = 2e-4 * major[0].hypot(major[1]);
|
||||
assert_eq!(got.len(), want.len(), "{got:?}");
|
||||
for w in want {
|
||||
assert!(got.iter().any(|p| p.dist(Vec2::new(w[0], w[1])) < tol), "no point near {w:?} in {got:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn divide_and_measure_command_line() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -754,19 +754,20 @@ mod tests {
|
||||
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() }));
|
||||
// `a^b.pdf` names one book per row, both in the folder of the path.
|
||||
let dir = crate::temp_dir::TempDir::new("pdfbook");
|
||||
let r = s.execute("pdfbook", &json!({ "path": dir.path_str("a^b.pdf") }));
|
||||
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 first = dir.join("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"));
|
||||
assert!(dir.join("b.pdf").is_file(), "the second row's book is written beside the first");
|
||||
// No frames: an error, not a panic.
|
||||
assert!(Session::new().execute("pdfbook", &json!({})).is_err());
|
||||
}
|
||||
|
||||
+204
-12
@@ -420,11 +420,20 @@ fn save_as(s: &mut Session, cmd: &str, path: &str, opts: &SaveAs) -> Result<usiz
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
/// Put saved bytes where the user gets them: natively the file at `path` (written atomically); on
|
||||
/// the web, which has no file system, a browser download named after `path` via [`deliver`].
|
||||
/// Put saved bytes where the user gets them: natively the file at `path`; on the web, which has
|
||||
/// no file system, a browser download named after `path` via [`deliver`].
|
||||
///
|
||||
/// Natively the file is replaced atomically ([`crate::atomic_write`]: a fresh temporary file
|
||||
/// beside it, renamed over it), so a crash leaves either the old file or the new one, never a
|
||||
/// truncated one. Saving over a drawing first keeps the previous file as `<name>.bak` (ISAVEBAK,
|
||||
/// #540).
|
||||
fn store(path: &str, bytes: &[u8]) -> Result<()> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
if is_drawing_file(path) && crate::autosave::isavebak() {
|
||||
backup(std::path::Path::new(path))
|
||||
.map_err(|e| bad("save", format!("{path}: the backup copy could not be written ({e}); nothing was saved")))?;
|
||||
}
|
||||
// Exclusively create a fresh temporary file and rename it over `path`, so a planted
|
||||
// `<path>.cadcraft-tmp` symlink cannot redirect the write onto another file (CWE-59).
|
||||
crate::atomic_write::write_atomic(std::path::Path::new(path), bytes).map_err(|e| bad("save", format!("{path}: {e}")))
|
||||
@@ -437,6 +446,180 @@ fn store(path: &str, bytes: &[u8]) -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The backup of the drawing file `path`: `<name>.bak` beside it, as AutoCAD names it.
|
||||
pub fn backup_path(path: &std::path::Path) -> std::path::PathBuf {
|
||||
path.with_extension("bak")
|
||||
}
|
||||
|
||||
/// Whether `bytes` read as a DWG or a DXF (ASCII or binary) by their start: what a save keeps as
|
||||
/// the backup. A file that doesn't (truncated, overwritten by another program) never replaces a
|
||||
/// good `.bak`.
|
||||
pub fn looks_like_drawing(bytes: &[u8]) -> bool {
|
||||
// DWG: a version tag like `AC1032` (R12 is `AC1009`).
|
||||
let dwg = bytes.len() >= 6 && bytes.starts_with(b"AC") && bytes[2..6].iter().all(|b| b.is_ascii_digit());
|
||||
if dwg || cadcraft_dxf::is_binary(bytes) {
|
||||
return true;
|
||||
}
|
||||
// ASCII DXF: optional comments (999), then group 0 SECTION.
|
||||
let text = String::from_utf8_lossy(&bytes[..bytes.len().min(4096)]);
|
||||
let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty());
|
||||
loop {
|
||||
match (lines.next(), lines.next()) {
|
||||
(Some("999"), Some(_)) => continue,
|
||||
(Some("0"), Some(v)) => return v.eq_ignore_ascii_case("SECTION"),
|
||||
_ => return false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How much of a drawing's end [`looks_complete`] reads.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const TAIL_BYTES: u64 = 256;
|
||||
|
||||
/// Whether a drawing that [`looks_like_drawing`] (`head`, its first bytes, at least 256 of them
|
||||
/// for a DWG) is whole, from its end (`tail`, its last bytes) and size `len`, without reading it
|
||||
/// all: a DXF ends with `0`/`EOF` (a binary one with `EOF` and its NUL); a DWG reaches the end its
|
||||
/// own header gives ([`dwg_complete`]). `None` when that can't be told (a 2007 DWG), so a file cut
|
||||
/// off by a failed save or another program doesn't replace a good backup.
|
||||
pub fn completeness(head: &[u8], tail: &[u8], len: u64) -> Option<bool> {
|
||||
let dwg = head.len() >= 6 && head.starts_with(b"AC") && head[2..6].iter().all(|b| b.is_ascii_digit());
|
||||
if dwg {
|
||||
// Too short for any DWG's header: cut off, not unknown.
|
||||
return if head.len() < 256 { Some(false) } else { dwg_complete(head, len) };
|
||||
}
|
||||
if cadcraft_dxf::is_binary(head) {
|
||||
return Some(tail.ends_with(b"EOF\0"));
|
||||
}
|
||||
let text = String::from_utf8_lossy(tail);
|
||||
let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty()).rev();
|
||||
Some(matches!((lines.next(), lines.next()), (Some(last), Some("0")) if last.eq_ignore_ascii_case("EOF")))
|
||||
}
|
||||
|
||||
/// [`completeness`] known to be whole.
|
||||
pub fn looks_complete(head: &[u8], tail: &[u8], len: u64) -> bool {
|
||||
completeness(head, tail, len) != Some(false)
|
||||
}
|
||||
|
||||
/// Whether a DWG of `len` bytes reaches the end its header gives (Open Design Specification for
|
||||
/// .dwg files): from 2004 on, the copy of the file header that ends the file (its address is in
|
||||
/// the encrypted header at 0x80, and the copy is 0x6C bytes); up to 2000, the end of every section
|
||||
/// the locator records give. `None` for another layout (2007, or before R13).
|
||||
fn dwg_complete(head: &[u8], len: u64) -> Option<bool> {
|
||||
let version = head.get(..6)?;
|
||||
match version {
|
||||
b"AC1018" | b"AC1024" | b"AC1027" | b"AC1032" => {
|
||||
// The header at 0x80 is XORed with the generator the specification gives.
|
||||
let enc = head.get(0x80..0x80 + 0x6C)?;
|
||||
let mut seed: u32 = 1;
|
||||
let h: Vec<u8> = enc
|
||||
.iter()
|
||||
.map(|b| {
|
||||
seed = seed.wrapping_mul(0x343FD).wrapping_add(0x0026_9EC3);
|
||||
b ^ (seed >> 16) as u8
|
||||
})
|
||||
.collect();
|
||||
if h.get(..12) != Some(&b"AcFssFcAJMB\0"[..]) {
|
||||
return Some(false);
|
||||
}
|
||||
let second = u64::from_le_bytes(h.get(0x34..0x3C)?.try_into().ok()?);
|
||||
Some(second >= 0x100 && second.checked_add(0x6C).is_some_and(|end| len >= end))
|
||||
}
|
||||
b"AC1012" | b"AC1014" | b"AC1015" => {
|
||||
let n = i32::from_le_bytes(head.get(0x15..0x19)?.try_into().ok()?);
|
||||
if !(1..=16).contains(&n) {
|
||||
return Some(false);
|
||||
}
|
||||
let mut end = 0u64;
|
||||
for i in 0..n as usize {
|
||||
let r = head.get(0x19 + i * 9..0x19 + i * 9 + 9)?;
|
||||
let seek = u64::from(u32::from_le_bytes(r.get(1..5)?.try_into().ok()?));
|
||||
let size = u64::from(u32::from_le_bytes(r.get(5..9)?.try_into().ok()?));
|
||||
end = end.max(seek + size);
|
||||
}
|
||||
Some(len >= end)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A file's first bytes, last bytes and length, left positioned just after the first bytes.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn ends(src: &mut std::fs::File) -> std::io::Result<(Vec<u8>, Vec<u8>, u64)> {
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
let mut head = Vec::with_capacity(4096);
|
||||
(&mut *src).take(4096).read_to_end(&mut head)?;
|
||||
// Its end too (a DXF's EOF), then on from where the start left off for the copy.
|
||||
let len = src.metadata()?.len();
|
||||
let mut tail = Vec::new();
|
||||
src.seek(SeekFrom::Start(len.saturating_sub(TAIL_BYTES)))?;
|
||||
(&mut *src).take(TAIL_BYTES).read_to_end(&mut tail)?;
|
||||
src.seek(SeekFrom::Start(head.len() as u64))?;
|
||||
Ok((head, tail, len))
|
||||
}
|
||||
|
||||
/// Whether saving replaces the `.bak` with this file. A file known to be cut off never replaces a
|
||||
/// backup already there. One that can't be told (a 2007 DWG) does: it is most likely fine, and it
|
||||
/// is about to be replaced. With no backup yet any file is kept, because the checks can be wrong
|
||||
/// about a real file and this save replaces the only copy.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn backup_replaced(head: &[u8], tail: &[u8], len: u64, backup_exists: bool) -> bool {
|
||||
(looks_like_drawing(head) && completeness(head, tail, len) != Some(false)) || (len > 0 && !backup_exists)
|
||||
}
|
||||
|
||||
/// Whether a save over `path` now would leave the current file in its `.bak`, for a warning that
|
||||
/// must not promise a backup the save won't write.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn save_keeps_current(path: &std::path::Path) -> bool {
|
||||
let Ok(mut src) = std::fs::File::open(path) else { return false };
|
||||
crate::autosave::isavebak() && ends(&mut src).is_ok_and(|(head, tail, len)| backup_replaced(&head, &tail, len, backup_path(path).exists()))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn save_keeps_current(_path: &std::path::Path) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Before `path` is replaced: copy it to its `.bak`, streamed and written atomically too
|
||||
/// ([`crate::atomic_write::write_atomic_with`]). Nothing
|
||||
/// to keep when there is no file yet. A file that doesn't look like a drawing
|
||||
/// ([`looks_like_drawing`]) is not backed up, and the `.bak` already there is left alone.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn backup(path: &std::path::Path) -> std::io::Result<()> {
|
||||
let mut src = match std::fs::File::open(path) {
|
||||
Ok(f) if f.metadata()?.is_file() => f,
|
||||
Ok(_) => return Ok(()),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
let (head, tail, len) = ends(&mut src)?;
|
||||
let bak = backup_path(path);
|
||||
if !backup_replaced(&head, &tail, len, bak.exists()) {
|
||||
log::warn!("save: {} doesn't look like a whole drawing; its backup is left as it was", path.display());
|
||||
return Ok(());
|
||||
}
|
||||
// The backup is no more readable than the drawing, nor than the .bak it replaces (a private
|
||||
// 0600 drawing or .bak stays private). The mode is set as the new file is created, so a
|
||||
// failed write leaves the old .bak as it was; a symlink at the .bak is replaced, never
|
||||
// written through.
|
||||
#[cfg(unix)]
|
||||
let permissions = {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mut mode = src.metadata()?.permissions().mode() & 0o777;
|
||||
if let Ok(m) = std::fs::symlink_metadata(&bak)
|
||||
&& m.is_file()
|
||||
{
|
||||
mode &= m.permissions().mode();
|
||||
}
|
||||
Some(std::fs::Permissions::from_mode(mode))
|
||||
};
|
||||
#[cfg(not(unix))]
|
||||
let permissions = None;
|
||||
crate::atomic_write::write_atomic_with(&bak, permissions, |out| {
|
||||
std::io::Write::write_all(out, &head)?;
|
||||
std::io::copy(&mut src, out).map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
/// Store the bytes, then mark the drawing saved under `path`. If storing fails the drawing keeps
|
||||
/// its unsaved changes, file name and title. On the web a started download counts as saved: the
|
||||
/// browser owns the file from there (we can't see whether the user keeps it), and the name stays so
|
||||
@@ -564,6 +747,17 @@ pub fn not_saved_message(lost: &std::collections::BTreeMap<String, usize>) -> Op
|
||||
Some(format!("Not saved in the DWG file (its writer can't write these object types yet; DXF keeps them): {}", list.join(", ")))
|
||||
}
|
||||
|
||||
/// What saving the active drawing to `path` would leave out, before anything is written: empty
|
||||
/// when the format keeps everything. The interactive save asks with this; [`save_reporting`]
|
||||
/// reports the same list afterwards to callers that never get a dialog.
|
||||
pub fn loss_on_save(s: &Session, path: &str) -> std::collections::BTreeMap<String, usize> {
|
||||
if !drops_unknown(path) {
|
||||
return Default::default();
|
||||
}
|
||||
let version = drawing_version(s, path);
|
||||
s.doc().map(|d| not_saved_as(d, version)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Whether saving to `path` leaves out the entities [`not_saved`] counts: DWG does (the DWG
|
||||
/// bridge drops types it doesn't know); DXF writes them back as read.
|
||||
pub fn drops_unknown(path: impl AsRef<std::path::Path>) -> bool {
|
||||
@@ -652,20 +846,16 @@ mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
use crate::temp_dir::TempDir;
|
||||
|
||||
fn active_title(s: &Session) -> String {
|
||||
s.state().unwrap().title.clone()
|
||||
}
|
||||
|
||||
fn temp_script(name: &str, text: &str) -> String {
|
||||
let path = std::env::temp_dir().join(format!("cadcraft-script-{}-{name}", std::process::id()));
|
||||
std::fs::write(&path, text).unwrap();
|
||||
path.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_command_runs_a_scr_file() {
|
||||
let path = temp_script("run.scr", "; a comment\ncircle 50,25 20\nrectang 0,0 100,60\n");
|
||||
let dir = TempDir::new("script");
|
||||
let path = dir.file("run.scr", "; a comment\ncircle 50,25 20\nrectang 0,0 100,60\n");
|
||||
let mut s = Session::new();
|
||||
let r = s.execute("script", &json!({ "path": path })).unwrap();
|
||||
assert_eq!(r["lines"], 3);
|
||||
@@ -683,8 +873,9 @@ mod tests {
|
||||
/// A script that runs itself must be refused, not recurse until the stack dies.
|
||||
#[test]
|
||||
fn a_script_cannot_start_a_script() {
|
||||
let inner = temp_script("inner.scr", "circle 0,0 1\n");
|
||||
let outer = temp_script("outer.scr", &format!("script {}\ncircle 0,0 2\n", json!({ "path": inner })));
|
||||
let dir = TempDir::new("script");
|
||||
let inner = dir.file("inner.scr", "circle 0,0 1\n");
|
||||
let outer = dir.file("outer.scr", format!("script {}\ncircle 0,0 2\n", json!({ "path": inner })));
|
||||
let mut s = Session::new();
|
||||
let e = s.execute("script", &json!({ "path": outer })).unwrap_err().to_string();
|
||||
assert!(e.contains("already running"), "{e}");
|
||||
@@ -692,7 +883,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn typed_script_prompts_for_the_file_then_runs_or_reports_it() {
|
||||
let good = temp_script("typed.scr", "circle 0,0 1\n");
|
||||
let dir = TempDir::new("script");
|
||||
let good = dir.file("typed.scr", "circle 0,0 1\n");
|
||||
let mut s = Session::new();
|
||||
s.cmdline("script").unwrap();
|
||||
assert!(s.running.is_some(), "SCRIPT asks for the file name");
|
||||
|
||||
+100
-18
@@ -550,6 +550,9 @@ struct HatchM {
|
||||
/// Loops by pick (one hatch each when separate).
|
||||
loops: Vec<Vec<HatchLoop>>,
|
||||
asking: Option<HatchAsk>,
|
||||
/// The pattern was asked by the Pattern option: its scale and angle follow, as at AutoCAD's
|
||||
/// -HATCH Properties.
|
||||
properties: bool,
|
||||
/// "Select objects" mode instead of "Pick internal point"; the next HATCH starts in it again.
|
||||
selecting: bool,
|
||||
}
|
||||
@@ -561,6 +564,7 @@ impl HatchM {
|
||||
settings: HatchSettings::from(s, &Value::Null, gradient),
|
||||
loops: Vec::new(),
|
||||
asking: None,
|
||||
properties: false,
|
||||
selecting: s.last_used.hatch_select,
|
||||
}
|
||||
}
|
||||
@@ -582,11 +586,13 @@ impl HatchM {
|
||||
h.set_i64("HPORIGINMODE", mode.index());
|
||||
Ok(())
|
||||
}
|
||||
fn answer(&mut self, s: &mut Session, a: HatchAsk, t: &str) -> Result<()> {
|
||||
/// Take an answer to sub-prompt `a`; false when it was refused.
|
||||
fn answer(&mut self, s: &mut Session, a: HatchAsk, t: &str) -> Result<bool> {
|
||||
match a {
|
||||
HatchAsk::Pattern => {
|
||||
let up = match patfile::typed_file(s, t.trim()) {
|
||||
Some(n) if n.is_empty() => return Ok(()),
|
||||
// Nothing loaded from it: the pattern is asked again.
|
||||
Some(n) if n.is_empty() => return Ok(false),
|
||||
Some(n) => n,
|
||||
None => t.trim().to_ascii_uppercase(),
|
||||
};
|
||||
@@ -595,20 +601,19 @@ impl HatchM {
|
||||
s.doc_mut()?.header.set_str("HPNAME", &up);
|
||||
} else {
|
||||
s.echo(format!("Unknown pattern {up}. Available: {}", patfile::names().join(", ")));
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
HatchAsk::Scale => {
|
||||
if let Some(v) = super::machines::number(t).filter(|v| v.is_finite() && *v > 0.0) {
|
||||
self.settings.scale = v;
|
||||
s.doc_mut()?.header.set_f64("HPSCALE", v);
|
||||
}
|
||||
let Some(v) = super::machines::number(t).filter(|v| v.is_finite() && *v > 0.0) else { return Ok(false) };
|
||||
self.settings.scale = v;
|
||||
s.doc_mut()?.header.set_f64("HPSCALE", v);
|
||||
}
|
||||
HatchAsk::Angle => {
|
||||
// Turns the pattern from the X axis (not from ANGBASE), clockwise when ANGDIR is 1.
|
||||
if let Some(a) = s.angle_settings().rotation(t).filter(|a| a.is_finite()) {
|
||||
self.settings.angle = a;
|
||||
s.doc_mut()?.header.set_f64("HPANG", a);
|
||||
}
|
||||
let Some(a) = s.angle_settings().rotation(t).filter(|a| a.is_finite()) else { return Ok(false) };
|
||||
self.settings.angle = a;
|
||||
s.doc_mut()?.header.set_f64("HPANG", a);
|
||||
}
|
||||
HatchAsk::Origin => {
|
||||
let mode = match t {
|
||||
@@ -622,7 +627,22 @@ impl HatchM {
|
||||
self.set_origin(s, None, mode)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// The sub-prompt that follows `a` (`taken`: its answer was taken). The Pattern option goes
|
||||
/// on, as AutoCAD 2024's -HATCH Properties does, from the pattern name to its scale and then
|
||||
/// its angle (a solid fill has neither), and asks again for a pattern or a value it refused.
|
||||
fn after(&self, a: HatchAsk, taken: bool) -> Option<HatchAsk> {
|
||||
if !self.properties {
|
||||
return None;
|
||||
}
|
||||
match a {
|
||||
_ if !taken => Some(a),
|
||||
HatchAsk::Pattern if self.settings.pattern != "SOLID" => Some(HatchAsk::Scale),
|
||||
HatchAsk::Scale => Some(HatchAsk::Angle),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -643,9 +663,10 @@ impl Interactive for HatchM {
|
||||
// The sub-prompts offer the current value: Enter keeps it.
|
||||
match self.asking {
|
||||
Some(HatchAsk::Pattern) => {
|
||||
return Prompt::new("Enter a pattern name", Accept::TEXT)
|
||||
let p = Prompt::new("Enter a pattern name", Accept::TEXT)
|
||||
.default(self.settings.pattern.clone())
|
||||
.picker(Picker::HatchPattern(self.settings.pattern.clone()));
|
||||
return if self.gradient { p } else { p.kw(&["Solid"]) };
|
||||
}
|
||||
Some(HatchAsk::Scale) => {
|
||||
return Prompt::new("Specify a scale for the hatch pattern", Accept::NUMBER).default(format!("{:.4}", self.settings.scale));
|
||||
@@ -686,11 +707,19 @@ impl Interactive for HatchM {
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
if let Some(a) = self.asking.take() {
|
||||
match &i {
|
||||
Input::Point(p) if a == HatchAsk::Origin => self.set_origin(s, Some(*p), OriginMode::Current)?,
|
||||
let taken = match &i {
|
||||
Input::Point(p) if a == HatchAsk::Origin => {
|
||||
self.set_origin(s, Some(*p), OriginMode::Current)?;
|
||||
true
|
||||
}
|
||||
Input::Keyword(k) if a == HatchAsk::Pattern && k == "Solid" => self.answer(s, a, "SOLID")?,
|
||||
Input::Text(t) | Input::Keyword(t) => self.answer(s, a, t)?,
|
||||
_ => {}
|
||||
}
|
||||
// Enter keeps the current value; a point or a pick answers none of these prompts.
|
||||
Input::Enter => true,
|
||||
_ => false,
|
||||
};
|
||||
self.asking = self.after(a, taken);
|
||||
self.properties &= self.asking.is_some();
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
match i {
|
||||
@@ -723,7 +752,10 @@ impl Interactive for HatchM {
|
||||
"Select objects" => self.set_mode(s, true),
|
||||
"picK internal point" => self.set_mode(s, false),
|
||||
"Undo" => self.loops.clear(),
|
||||
"Pattern" | "seTtings" => self.asking = Some(HatchAsk::Pattern),
|
||||
"Pattern" | "seTtings" => {
|
||||
self.asking = Some(HatchAsk::Pattern);
|
||||
self.properties = !self.gradient;
|
||||
}
|
||||
"sCale" => self.asking = Some(HatchAsk::Scale),
|
||||
"Angle" => self.asking = Some(HatchAsk::Angle),
|
||||
"Origin" => self.asking = Some(HatchAsk::Origin),
|
||||
@@ -738,7 +770,7 @@ impl Interactive for HatchM {
|
||||
}
|
||||
// A pattern chosen in the picker while boundaries are picked.
|
||||
Input::Text(t) if !self.gradient => {
|
||||
self.answer(s, HatchAsk::Pattern, &t)?;
|
||||
let _ = self.answer(s, HatchAsk::Pattern, &t)?;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
// Enter ends boundary picking in either mode and creates the hatch.
|
||||
@@ -838,6 +870,9 @@ mod tests {
|
||||
s.input(Input::Keyword("Pattern".into())).unwrap();
|
||||
assert!(s.current_prompt().unwrap().picker.is_some());
|
||||
s.input(Input::Text("BRICK".into())).unwrap();
|
||||
// Its scale and angle follow; Enter keeps them.
|
||||
s.input(Input::Enter).unwrap();
|
||||
s.input(Input::Enter).unwrap();
|
||||
s.input(Input::Point(Vec2::new(5.0, 5.0))).unwrap();
|
||||
s.input(Input::Enter).unwrap();
|
||||
assert_eq!(only_hatch(&s).1.pattern, "BRICK");
|
||||
@@ -851,6 +886,53 @@ mod tests {
|
||||
assert!(s.current_prompt().unwrap().picker.is_none());
|
||||
}
|
||||
|
||||
/// -HATCH's Properties as AutoCAD 2024 takes them from AutoLISP's `command` (accoreconsole, the
|
||||
/// hatch read back with `entget`): a pattern name, then its scale and angle (Enter keeps them);
|
||||
/// Solid (`S`, `_S` or SOLID) asks neither; an unknown name is asked again.
|
||||
#[test]
|
||||
fn hatch_properties_ask_pattern_scale_and_angle_as_autocad() {
|
||||
// (properties given, pattern, solid, scale, angle in degrees)
|
||||
let cases: [(&str, &str, bool, f64, f64); 7] = [
|
||||
("\"ANSI31\" 2 45 \"_S\" (entlast) \"\"", "ANSI31", false, 2.0, 45.0),
|
||||
("\"_S\" \"_S\" (entlast) \"\"", "SOLID", true, 1.0, 0.0),
|
||||
("\"ANSI37\" 0.5 30 '(5.0 5.0)", "ANSI37", false, 0.5, 30.0),
|
||||
("\"ANSI31\" \"\" \"\" '(5.0 5.0)", "ANSI31", false, 1.0, 0.0),
|
||||
("\"S\" '(5.0 5.0)", "SOLID", true, 1.0, 0.0),
|
||||
("\"SOLID\" '(5.0 5.0)", "SOLID", true, 1.0, 0.0),
|
||||
("\"XYZ\" \"ANSI31\" 1 0 '(5.0 5.0)", "ANSI31", false, 1.0, 0.0),
|
||||
];
|
||||
for (props, pattern, solid, scale, angle) in cases {
|
||||
let mut s = Session::new();
|
||||
s.execute("circle", &json!({ "center": [20, 15], "radius": 8 })).unwrap();
|
||||
s.execute("rectang", &json!({ "p1": [0, 0], "p2": [40, 30] })).unwrap();
|
||||
s.cmdline("(setvar \"HPNAME\" \"ANSI31\")").unwrap();
|
||||
s.cmdline("(setvar \"HPSCALE\" 1.0)").unwrap();
|
||||
s.cmdline("(setvar \"HPANG\" 0.0)").unwrap();
|
||||
s.cmdline(&format!("(command \"_.-HATCH\" \"_P\" {props} \"\")")).unwrap();
|
||||
assert!(s.running.is_none(), "{props}: {}", s.prompt_text());
|
||||
let h = only_hatch(&s).1;
|
||||
assert!(
|
||||
h.pattern == pattern && h.solid == solid && (h.scale - scale).abs() < 1e-12 && (h.angle - angle.to_radians()).abs() < 1e-12,
|
||||
"{props}: {} {} {} {}",
|
||||
h.pattern,
|
||||
h.solid,
|
||||
h.scale,
|
||||
h.angle.to_degrees()
|
||||
);
|
||||
}
|
||||
// An object (an entity name from LISP) answers none of these prompts: each is asked again.
|
||||
let mut s = Session::new();
|
||||
s.execute("rectang", &json!({ "p1": [0, 0], "p2": [40, 30] })).unwrap();
|
||||
let h = s.doc().unwrap().model.handles()[0];
|
||||
s.cmdline("(command \"_.-HATCH\" \"_P\")").unwrap();
|
||||
s.input(crate::Input::Pick(vec![h])).unwrap();
|
||||
assert!(s.prompt_text().contains("Enter a pattern name"), "{}", s.prompt_text());
|
||||
s.cmdline("ANSI31").unwrap();
|
||||
s.input(crate::Input::Pick(vec![h])).unwrap();
|
||||
assert!(s.prompt_text().contains("Specify a scale for the hatch pattern"), "{}", s.prompt_text());
|
||||
s.cancel();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_pattern_error_and_catalog_name_the_pat_file_route() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -840,11 +840,9 @@ impl Interactive for DistM {
|
||||
let model = model_points(&self.sources);
|
||||
s.echo(format!("Distance = {}", fmt_d(s, path_len(model.as_deref().unwrap_or(pts), &self.bulges))));
|
||||
}
|
||||
Input::Keyword(k) if k == "Undo" => {
|
||||
if pts.len() > 1 {
|
||||
self.arcs.undo(pts, &mut self.bulges);
|
||||
self.sources.pop();
|
||||
}
|
||||
Input::Keyword(k) if k == "Undo" && pts.len() > 1 => {
|
||||
self.arcs.undo(pts, &mut self.bulges);
|
||||
self.sources.pop();
|
||||
}
|
||||
Input::Keyword(k) if k == "Length" => self.length = true,
|
||||
Input::Keyword(k) if k == "Total" => return self.total(s, false),
|
||||
@@ -852,10 +850,8 @@ impl Interactive for DistM {
|
||||
self.arcs.close(pts, &mut self.bulges)?;
|
||||
return self.total(s, true);
|
||||
}
|
||||
Input::Keyword(k) => {
|
||||
if !self.arcs.keyword(&k, pts) {
|
||||
s.echo(format!("{k}: not available yet"));
|
||||
}
|
||||
Input::Keyword(k) if !self.arcs.keyword(&k, pts) => {
|
||||
s.echo(format!("{k}: not available yet"));
|
||||
}
|
||||
Input::Enter => return self.total(s, false),
|
||||
_ => {}
|
||||
|
||||
@@ -250,6 +250,7 @@ fn run_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
repoint(&mut std::sync::Arc::make_mut(block).entities, &old, &nn);
|
||||
}
|
||||
}
|
||||
merge::rename_in_viewports(d, &old, &nn);
|
||||
if cur.eq_ignore_ascii_case(&old) {
|
||||
d.header.set_str("CLAYER", &nn);
|
||||
}
|
||||
|
||||
@@ -115,6 +115,39 @@ pub(super) fn forget_in_viewports(d: &mut Drawing, set: &HashSet<String>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Point every layout viewport's frozen list and colour overrides at a renamed layer.
|
||||
pub(super) fn rename_in_viewports(d: &mut Drawing, old: &str, new: &str) {
|
||||
for layout in &mut d.layouts {
|
||||
let hs: Vec<Handle> = layout
|
||||
.entities
|
||||
.iter()
|
||||
.filter(|e| match &e.kind {
|
||||
EntityKind::Viewport(v) => {
|
||||
v.frozen_layers.iter().any(|l| l.eq_ignore_ascii_case(old)) || v.layer_colors.iter().any(|(l, _)| l.eq_ignore_ascii_case(old))
|
||||
}
|
||||
_ => false,
|
||||
})
|
||||
.map(|e| e.handle)
|
||||
.collect();
|
||||
for h in hs {
|
||||
layout.entities.modify(h, |e| {
|
||||
if let EntityKind::Viewport(v) = &mut e.kind {
|
||||
for l in &mut v.frozen_layers {
|
||||
if l.eq_ignore_ascii_case(old) {
|
||||
*l = new.to_string();
|
||||
}
|
||||
}
|
||||
for (l, _) in &mut v.layer_colors {
|
||||
if l.eq_ignore_ascii_case(old) {
|
||||
*l = new.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn lower_set(names: &[String]) -> HashSet<String> {
|
||||
names.iter().map(|n| n.to_ascii_lowercase()).collect()
|
||||
}
|
||||
|
||||
@@ -250,7 +250,7 @@ impl Interactive for BreakM {
|
||||
(None, Input::Point(p)) => {
|
||||
let ap = s.pixel_size() * s.settings.pickbox.max(1.0) * 1.5;
|
||||
let space = s.space();
|
||||
match crate::select::pick(s.doc()?, &space, p, ap) {
|
||||
match s.pick_object(&space, p, ap)? {
|
||||
Some(h) => {
|
||||
self.obj = Some((h, p));
|
||||
s.set_selection(vec![h]);
|
||||
|
||||
@@ -118,7 +118,11 @@ pub(super) fn run_chamfer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
(None, None) => {
|
||||
let d1 = f64_or(p, "d1", s.doc()?.header.f64("CHAMFERA", 0.0));
|
||||
ChamferCut::Dist(d1, f64_or(p, "d2", d1))
|
||||
let d2 = f64_or(p, "d2", d1);
|
||||
if !(d1.is_finite() && d1 >= 0.0 && d2.is_finite() && d2 >= 0.0) {
|
||||
return Err(bad("chamfer", "`d1` and `d2` must be >= 0"));
|
||||
}
|
||||
ChamferCut::Dist(d1, d2)
|
||||
}
|
||||
_ => return Err(bad("chamfer", "`length` (>= 0) and `angle` (degrees, 0 to 180) go together")),
|
||||
};
|
||||
@@ -338,7 +342,7 @@ impl Interactive for FilletM {
|
||||
Input::Point(p) => {
|
||||
let ap = s.pixel_size() * s.settings.pickbox.max(1.0) * 1.5;
|
||||
let space = s.space();
|
||||
let Some(h) = crate::select::pick(s.doc()?, &space, p, ap) else {
|
||||
let Some(h) = s.pick_object(&space, p, ap)? else {
|
||||
s.echo("*Invalid selection*");
|
||||
return Ok(Step::Continue);
|
||||
};
|
||||
|
||||
@@ -197,6 +197,9 @@ fn join_point(a: &Segment, b: &Segment) -> Option<Vec2> {
|
||||
pub(super) fn run_offset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let h = targets(s, p)?.first().copied().ok_or_else(|| bad("offset", "`handle` is required"))?;
|
||||
let dist = f64_req("offset", p, "distance")?;
|
||||
if dist == 0.0 {
|
||||
return Err(bad("offset", "Requires a positive distance."));
|
||||
}
|
||||
let side = point_req("offset", p, "side")?;
|
||||
let e = s.doc()?.entity(h).map(|e| (**e).clone()).ok_or_else(|| bad("offset", "no such object"))?;
|
||||
let k = offset_kind(&e.kind, dist.abs(), side).ok_or_else(|| bad("offset", "cannot offset that object"))?;
|
||||
@@ -442,7 +445,7 @@ impl Interactive for OffsetM {
|
||||
(None, Input::Point(p)) => {
|
||||
let ap = s.pixel_size() * s.settings.pickbox.max(1.0) * 1.5;
|
||||
let space = s.space();
|
||||
match crate::select::pick(s.doc()?, &space, p, ap) {
|
||||
match s.pick_object(&space, p, ap)? {
|
||||
Some(h) => {
|
||||
self.picked = Some(h);
|
||||
s.set_selection(vec![h]);
|
||||
|
||||
@@ -66,7 +66,10 @@ pub(super) fn run_move(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
pub(super) fn run_copy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let hs = targets(s, p)?;
|
||||
let d = delta_of("copy", p)?;
|
||||
let n = p.get("count").and_then(Value::as_u64).unwrap_or(1).clamp(1, 10_000);
|
||||
let n = match p.get("count") {
|
||||
None => 1,
|
||||
Some(v) => v.as_u64().filter(|n| (1..=10_000).contains(n)).ok_or_else(|| bad("copy", "`count` must be an integer from 1 to 10000"))?,
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for k in 1..=n {
|
||||
out.extend(transform_entities(s, &hs, &Mat3::translate(d * k as f64), true)?.into_iter().map(|h| h.hex()));
|
||||
|
||||
@@ -122,6 +122,7 @@ pub(crate) fn as_lwpoly(k: &EntityKind) -> Option<LwPolyline> {
|
||||
}
|
||||
}
|
||||
|
||||
mod pedit_multi;
|
||||
mod pedit_vertex;
|
||||
|
||||
// =====================================================================================
|
||||
|
||||
@@ -164,7 +164,7 @@ pub(crate) fn pedit_join(s: &mut Session, h: Handle, others: &[Handle], fuzz: f6
|
||||
Ok(joined.len())
|
||||
}
|
||||
|
||||
fn pedit_apply(s: &mut Session, h: Handle, opt: &str, p: &Value) -> Result<Value> {
|
||||
pub(super) fn pedit_apply(s: &mut Session, h: Handle, opt: &str, p: &Value) -> Result<Value> {
|
||||
let e = curves::entity(s, h)?;
|
||||
if curves::is_locked(s, h) {
|
||||
return Err(other("The object is on a locked layer."));
|
||||
@@ -287,6 +287,13 @@ pub(super) enum PPhase {
|
||||
/// The second marker of Break / Straighten.
|
||||
VMark,
|
||||
VTangent,
|
||||
/// Multiple ([`super::pedit_multi`]): selecting the objects, converting lines and arcs, the
|
||||
/// options, the width for all, Join's fuzz distance.
|
||||
MSelect,
|
||||
MConvert,
|
||||
MMain,
|
||||
MWidth,
|
||||
MFuzz,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -305,6 +312,34 @@ pub(super) struct PeditM {
|
||||
pub(super) tangents: Vec<(Vec2, Vec2)>,
|
||||
/// Pieces split off by Break, with the history length after it (erased again by Undo).
|
||||
pub(super) split_off: Vec<(usize, Vec<Handle>)>,
|
||||
/// Multiple: the polylines being edited, their selection, and the drawing before each option
|
||||
/// (with those polylines and their Decurve record) for Undo.
|
||||
pub(super) multi: Vec<Handle>,
|
||||
pub(super) msel: SelectPhase,
|
||||
pub(super) mundo: Vec<(cadcraft_doc::Drawing, Vec<Handle>, std::collections::HashMap<Handle, Vec<PolyVertex>>)>,
|
||||
/// Multiple: each polyline's vertices before its first Fit or Spline, which Decurve restores.
|
||||
pub(super) morig: std::collections::HashMap<Handle, Vec<PolyVertex>>,
|
||||
}
|
||||
|
||||
/// A Yes/No answer: Enter is Yes, anything but Y/Yes/N/No is refused so the question is asked again.
|
||||
pub(super) fn yes_no(i: &Input) -> Result<bool> {
|
||||
match i {
|
||||
Input::Enter => Ok(true),
|
||||
Input::Keyword(k) | Input::Text(k) => match k.trim().to_ascii_lowercase().as_str() {
|
||||
"y" | "yes" => Ok(true),
|
||||
"n" | "no" => Ok(false),
|
||||
_ => Err(other("Enter Yes or No.")),
|
||||
},
|
||||
_ => Err(other("Enter Yes or No.")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Width sets one width for the whole polyline, so the vertices Decurve gives back take it too.
|
||||
pub(super) fn clear_widths(vs: &mut [PolyVertex]) {
|
||||
for v in vs {
|
||||
v.start_width = 0.0;
|
||||
v.end_width = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
impl PeditM {
|
||||
@@ -339,6 +374,10 @@ impl Interactive for PeditM {
|
||||
}
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
let sel = s.selection();
|
||||
// Several objects selected first: straight into Multiple, as in AutoCAD.
|
||||
if sel.len() > 1 && s.settings.pickfirst {
|
||||
return self.start_multi(s);
|
||||
}
|
||||
if let [h] = sel.as_slice() {
|
||||
let k = curves::entity(s, *h)?.kind;
|
||||
match k {
|
||||
@@ -358,6 +397,7 @@ impl Interactive for PeditM {
|
||||
}
|
||||
fn prompt(&self, s: &Session) -> Prompt {
|
||||
match self.phase {
|
||||
PPhase::MSelect | PPhase::MConvert | PPhase::MMain | PPhase::MWidth | PPhase::MFuzz => self.multi_prompt(),
|
||||
PPhase::Select => Prompt::new("Select polyline or", Accept::POINT).kw(&["Multiple"]),
|
||||
PPhase::Convert => {
|
||||
Prompt::new("Object selected is not a polyline. Do you want to turn it into one?", curves::KW).kw(&["Yes", "No"]).default("Y")
|
||||
@@ -398,6 +438,7 @@ impl Interactive for PeditM {
|
||||
_ => None,
|
||||
};
|
||||
match self.phase {
|
||||
PPhase::MSelect | PPhase::MConvert | PPhase::MMain | PPhase::MWidth | PPhase::MFuzz => self.multi_input(s, i),
|
||||
PPhase::Select => match i {
|
||||
Input::Point(p) => {
|
||||
let h = curves::pick_at(s, p).ok_or_else(|| other("*Invalid selection*"))?;
|
||||
@@ -417,13 +458,13 @@ impl Interactive for PeditM {
|
||||
s.set_selection(vec![h]);
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Input::Keyword(_) => Err(other("Select one polyline (Multiple is available through the JSON form).")),
|
||||
Input::Keyword(k) if k.eq_ignore_ascii_case("multiple") || k.eq_ignore_ascii_case("m") => self.start_multi(s),
|
||||
Input::Keyword(_) => Err(other("Invalid option keyword.")),
|
||||
Input::Enter => Ok(Step::Cancel),
|
||||
_ => Ok(Step::Continue),
|
||||
},
|
||||
PPhase::Convert => {
|
||||
let no = kw(&i).is_some_and(|k| k.starts_with('n'));
|
||||
if no {
|
||||
if !yes_no(&i)? {
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
let h = self.h.ok_or_else(|| other("No object."))?;
|
||||
@@ -487,6 +528,9 @@ impl Interactive for PeditM {
|
||||
if let Input::Text(t) = &i {
|
||||
let w = number(t).filter(|w| *w >= 0.0).ok_or_else(|| other("Requires a non-negative width."))?;
|
||||
self.apply(s, "width", json!({ "width": w }))?;
|
||||
if let Some(o) = &mut self.original {
|
||||
clear_widths(o);
|
||||
}
|
||||
}
|
||||
self.phase = PPhase::Main;
|
||||
Ok(Step::Continue)
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
//! PEDIT Multiple: edit several polylines (and lines and arcs turned into polylines) at once, as
|
||||
//! AutoCAD does after `M` at "Select polyline". Join joins the selected objects with each other.
|
||||
|
||||
use cadcraft_doc::{Drawing, EntityKind, Handle};
|
||||
use cadcraft_geom::PolyVertex;
|
||||
use serde_json::json;
|
||||
|
||||
use super::super::curves;
|
||||
use super::super::machines::{SelOutcome, SelectPhase, number};
|
||||
use super::pedit::{PPhase, PeditM, clear_widths, pedit_apply, pedit_join, yes_no};
|
||||
use super::{as_lwpoly, other, set_kind};
|
||||
use crate::{Accept, Input, Prompt, Result, Session, Step};
|
||||
|
||||
/// Most option steps Undo can take back.
|
||||
const MAX_UNDO: usize = 64;
|
||||
|
||||
/// The options of the Multiple menu.
|
||||
const OPTIONS: [&str; 10] = ["Close", "Open", "Join", "Width", "Fit", "Spline", "Decurve", "Ltype gen", "Reverse", "Undo"];
|
||||
|
||||
impl PeditM {
|
||||
/// Start Multiple: select the objects to edit, or take the ones selected first.
|
||||
pub(super) fn start_multi(&mut self, s: &mut Session) -> Result<Step> {
|
||||
self.phase = PPhase::MSelect;
|
||||
self.msel = SelectPhase::begin(s);
|
||||
if self.msel.done {
|
||||
let hs = std::mem::take(&mut self.msel.picked);
|
||||
return self.take_multi(s, hs);
|
||||
}
|
||||
s.set_selection(Vec::new());
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
|
||||
/// The objects selected for Multiple: polylines, lines and arcs not on locked layers.
|
||||
fn take_multi(&mut self, s: &mut Session, hs: Vec<Handle>) -> Result<Step> {
|
||||
let d = s.doc()?;
|
||||
let kinds = |h: &Handle| d.entity(*h).map(|e| e.kind.clone());
|
||||
let editable: Vec<Handle> = hs
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|h| matches!(kinds(h), Some(EntityKind::LwPolyline(_) | EntityKind::Line(_) | EntityKind::Arc(_))))
|
||||
.filter(|h| !curves::is_locked(s, *h))
|
||||
.collect();
|
||||
let convert = editable.iter().any(|h| matches!(kinds(h), Some(EntityKind::Line(_) | EntityKind::Arc(_))));
|
||||
self.multi = editable;
|
||||
if self.multi.is_empty() {
|
||||
s.echo("No polylines, lines or arcs selected.");
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
self.phase = if convert { PPhase::MConvert } else { PPhase::MMain };
|
||||
s.set_selection(self.multi.clone());
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
|
||||
/// The prompt of a Multiple phase.
|
||||
pub(super) fn multi_prompt(&self) -> Prompt {
|
||||
match self.phase {
|
||||
PPhase::MConvert => Prompt::new("Convert Lines and Arcs to polylines", curves::KW).kw(&["Yes", "No"]).default("Y"),
|
||||
PPhase::MMain => Prompt::new("Enter an option", curves::KW).kw(&OPTIONS),
|
||||
PPhase::MWidth => Prompt::new("Specify new width for all segments", Accept::NUMBER),
|
||||
PPhase::MFuzz => Prompt::new("Enter fuzz distance", Accept::NUMBER).default("0.0000"),
|
||||
_ => self.msel.prompt(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Input in a Multiple phase.
|
||||
pub(super) fn multi_input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
let kw = |i: &Input| match i {
|
||||
Input::Keyword(k) | Input::Text(k) => Some(k.trim().to_ascii_lowercase()),
|
||||
_ => None,
|
||||
};
|
||||
match self.phase {
|
||||
PPhase::MSelect => match self.msel.feed(s, &i)? {
|
||||
SelOutcome::More => Ok(Step::Continue),
|
||||
SelOutcome::Empty => Ok(Step::Done),
|
||||
SelOutcome::Done(hs) => self.take_multi(s, hs),
|
||||
},
|
||||
PPhase::MConvert => {
|
||||
let no = !yes_no(&i)?;
|
||||
let mut kept = Vec::new();
|
||||
for h in std::mem::take(&mut self.multi) {
|
||||
let kind = curves::entity(s, h)?.kind;
|
||||
match kind {
|
||||
EntityKind::LwPolyline(_) => kept.push(h),
|
||||
_ if no => {}
|
||||
k => {
|
||||
if let Some(pl) = as_lwpoly(&k) {
|
||||
set_kind(s, h, EntityKind::LwPolyline(pl))?;
|
||||
kept.push(h);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.multi = kept;
|
||||
if self.multi.is_empty() {
|
||||
s.echo("No polylines to edit.");
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
s.set_selection(self.multi.clone());
|
||||
self.phase = PPhase::MMain;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
PPhase::MMain => {
|
||||
let Some(k) = kw(&i) else {
|
||||
if i == Input::Enter {
|
||||
s.set_selection(Vec::new());
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
return Ok(Step::Continue);
|
||||
};
|
||||
match k.as_str() {
|
||||
"close" | "c" => self.apply_all(s, "close", &json!({}))?,
|
||||
"open" | "o" => self.apply_all(s, "open", &json!({}))?,
|
||||
"join" | "j" => self.phase = PPhase::MFuzz,
|
||||
"width" | "w" => self.phase = PPhase::MWidth,
|
||||
"fit" | "f" => self.curve_all(s, "fit")?,
|
||||
"spline" | "s" => self.curve_all(s, "spline")?,
|
||||
"decurve" | "d" => self.decurve_all(s)?,
|
||||
"ltype gen" | "l" => {
|
||||
// One setting for all: the opposite of the first one's.
|
||||
let on = self.multi.first().and_then(|h| curves::entity(s, *h).ok()).is_some_and(|e| match e.kind {
|
||||
EntityKind::LwPolyline(p) => !p.plinegen,
|
||||
_ => true,
|
||||
});
|
||||
self.apply_all(s, "ltypegen", &json!({ "on": on }))?;
|
||||
}
|
||||
"reverse" | "r" => self.apply_all(s, "reverse", &json!({}))?,
|
||||
"undo" | "u" => match self.mundo.pop() {
|
||||
Some((d, multi, morig)) => {
|
||||
*s.doc_mut()? = d;
|
||||
self.multi = multi;
|
||||
self.morig = morig;
|
||||
s.set_selection(self.multi.clone());
|
||||
}
|
||||
None => s.echo("Nothing to undo."),
|
||||
},
|
||||
_ => return Err(other("Invalid option keyword.")),
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
PPhase::MWidth => {
|
||||
if let Input::Text(t) = &i {
|
||||
let w = number(t).filter(|w| *w >= 0.0).ok_or_else(|| other("Requires a non-negative width."))?;
|
||||
self.apply_all(s, "width", &json!({ "width": w }))?;
|
||||
self.morig.values_mut().for_each(|o| clear_widths(o));
|
||||
}
|
||||
self.phase = PPhase::MMain;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
PPhase::MFuzz => {
|
||||
let fuzz = match &i {
|
||||
Input::Text(t) => number(t).filter(|f| *f >= 0.0 && f.is_finite()).ok_or_else(|| other("Requires a non-negative distance."))?,
|
||||
_ => 0.0,
|
||||
};
|
||||
self.join_all(s, fuzz)?;
|
||||
self.phase = PPhase::MMain;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
_ => Ok(Step::Continue),
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep the drawing as it is for Undo (and the polylines being edited, with their Decurve
|
||||
/// record).
|
||||
fn remember(&mut self, s: &Session) -> Result<()> {
|
||||
let d: Drawing = s.doc()?.clone();
|
||||
if self.mundo.len() >= MAX_UNDO {
|
||||
self.mundo.remove(0);
|
||||
}
|
||||
self.mundo.push((d, self.multi.clone(), self.morig.clone()));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Apply option `opt` to every polyline being edited.
|
||||
fn apply_all(&mut self, s: &mut Session, opt: &str, p: &serde_json::Value) -> Result<()> {
|
||||
self.remember(s)?;
|
||||
self.multi.retain(|h| s.doc().is_ok_and(|d| d.entity(*h).is_some()));
|
||||
for h in self.multi.clone() {
|
||||
pedit_apply(s, h, opt, p)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fit or Spline all of them, first keeping each one's vertices for Decurve.
|
||||
fn curve_all(&mut self, s: &mut Session, opt: &str) -> Result<()> {
|
||||
for h in &self.multi {
|
||||
if !self.morig.contains_key(h)
|
||||
&& let Ok(EntityKind::LwPolyline(p)) = curves::entity(s, *h).map(|e| e.kind)
|
||||
{
|
||||
self.morig.insert(*h, p.vertices);
|
||||
}
|
||||
}
|
||||
self.apply_all(s, opt, &json!({}))
|
||||
}
|
||||
|
||||
/// Decurve all of them: one fitted or splined here gets back its vertices from before (as
|
||||
/// single-polyline PEDIT does), the others lose their arcs.
|
||||
fn decurve_all(&mut self, s: &mut Session) -> Result<()> {
|
||||
self.apply_all(s, "decurve", &json!({}))?;
|
||||
for h in self.multi.clone() {
|
||||
if let Some(orig) = self.morig.get(&h)
|
||||
&& let Ok(EntityKind::LwPolyline(mut p)) = curves::entity(s, h).map(|e| e.kind)
|
||||
{
|
||||
p.vertices = orig.iter().map(|v| PolyVertex { bulge: 0.0, ..*v }).collect();
|
||||
set_kind(s, h, EntityKind::LwPolyline(p))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Join the polylines being edited with each other, ends within `fuzz`: each one still open
|
||||
/// takes the others that meet it, and those go into it.
|
||||
fn join_all(&mut self, s: &mut Session, fuzz: f64) -> Result<()> {
|
||||
self.remember(s)?;
|
||||
let mut rest: Vec<Handle> = self.multi.iter().copied().filter(|h| s.doc().is_ok_and(|d| d.entity(*h).is_some())).collect();
|
||||
let before = rest.len();
|
||||
let mut kept = Vec::new();
|
||||
while !rest.is_empty() {
|
||||
let h = rest.remove(0);
|
||||
let open = matches!(curves::entity(s, h)?.kind, EntityKind::LwPolyline(p) if !p.closed);
|
||||
if open {
|
||||
// A polyline that took on segments no longer goes back past the Join on Decurve.
|
||||
if pedit_join(s, h, &rest, fuzz)? > 0 {
|
||||
self.morig.remove(&h);
|
||||
}
|
||||
rest.retain(|o| s.doc().is_ok_and(|d| d.entity(*o).is_some()));
|
||||
}
|
||||
kept.push(h);
|
||||
}
|
||||
let added = before - kept.len();
|
||||
s.echo(format!("{added} segments added to polyline"));
|
||||
self.multi = kept;
|
||||
s.set_selection(self.multi.clone());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
//! Properties: per-object property edits, current properties, MATCHPROP, linetypes, units, RENAME.
|
||||
//! Properties: per-object property edits, current properties, CHPROP, MATCHPROP, linetypes, units,
|
||||
//! RENAME.
|
||||
|
||||
mod chprop;
|
||||
mod matchprop;
|
||||
mod rename;
|
||||
|
||||
@@ -18,6 +20,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("properties", "Properties", run_get).menu(&["Modify", "Properties"]).alias(&["pr", "props", "ch"]).params("{handles?} → properties of the selection").noundo(),
|
||||
CommandSpec::new("properties.set", "Set Properties", run_set).params("{handles?, layer?, color?, linetype?, lineweight?, ltscale?, thickness?, transparency?: \"ByLayer\" | \"ByBlock\" | 0..90, visible?, <geometry fields: radius, center, start, end, text, height, rotation…>; dimensions: textOverride, dimStyle, textHeight, arrowSize, dimScale, precision (per-dimension overrides)}"),
|
||||
CommandSpec::new("chprop", "Change Properties", chprop::run_chprop)
|
||||
.params("as properties.set, leaving out objects on locked layers → {changed, locked} (the command line asks Color, LAyer, LType, ltScale, LWeight, Thickness and TRansparency in turn)")
|
||||
.interactive(|_| Ok(Box::new(chprop::ChpropM::new()))),
|
||||
CommandSpec::new("matchprop", "Match Properties", matchprop::run_matchprop)
|
||||
.menu(&["Modify", "Match Properties"])
|
||||
.alias(&["ma", "painter"])
|
||||
@@ -153,7 +158,9 @@ fn run_get(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn parse_color(v: &Value) -> Option<Color> {
|
||||
v.as_str().and_then(Color::parse).or_else(|| v.as_i64().and_then(|i| i16::try_from(i).ok()).map(Color::from_aci))
|
||||
v.as_str()
|
||||
.and_then(Color::parse)
|
||||
.or_else(|| v.as_i64().filter(|i| (0..=256).contains(i)).and_then(|i| i16::try_from(i).ok()).map(Color::from_aci))
|
||||
}
|
||||
|
||||
/// A transparency as JSON, in the form `properties.set` takes: "ByLayer", "ByBlock" or a percentage.
|
||||
@@ -645,9 +652,12 @@ impl Interactive for ThicknessM {
|
||||
}
|
||||
}
|
||||
|
||||
/// The unit variables (lower-case name, lowest, highest value) shared by UNITS and SETVAR.
|
||||
pub(crate) const UNIT_RANGES: [(&str, i64, i64); 5] = [("lunits", 1, 5), ("luprec", 0, 8), ("aunits", 0, 4), ("auprec", 0, 8), ("insunits", 0, 24)];
|
||||
|
||||
fn run_units(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let d = s.doc_mut()?;
|
||||
for (k, lo, hi) in [("lunits", 1, 5), ("luprec", 0, 8), ("aunits", 0, 4), ("auprec", 0, 8), ("insunits", 0, 24)] {
|
||||
for (k, lo, hi) in UNIT_RANGES {
|
||||
if let Some(v) = p.get(k).and_then(Value::as_i64) {
|
||||
d.header.set_i64(&k.to_ascii_uppercase(), v.clamp(lo, hi));
|
||||
}
|
||||
@@ -1218,6 +1228,24 @@ mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn properties_set_rejects_out_of_range_colour() {
|
||||
let mut s = Session::new();
|
||||
s.execute("line", &json!({ "points": [[0, 0], [50, 0]] })).unwrap();
|
||||
let h = s.doc().unwrap().model.last().unwrap().handle.hex();
|
||||
s.execute("properties.set", &json!({ "handles": [h], "color": 1 })).unwrap();
|
||||
let col = |s: &Session| s.doc().unwrap().model.last().unwrap().common.color;
|
||||
for bad in [300, -5, 257] {
|
||||
let e = s.execute("properties.set", &json!({ "handles": [h], "color": bad })).unwrap_err();
|
||||
assert!(e.to_string().contains("bad colour"), "{e}");
|
||||
assert_eq!(col(&s), Color::Index(1));
|
||||
}
|
||||
s.execute("properties.set", &json!({ "handles": [h], "color": 256 })).unwrap();
|
||||
assert_eq!(col(&s), Color::ByLayer);
|
||||
s.execute("properties.set", &json!({ "handles": [h], "color": 0 })).unwrap();
|
||||
assert_eq!(col(&s), Color::ByBlock);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn properties_set_rejects_unloaded_linetype() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
//! CHPROP: change object properties at the command line, as AutoCAD 2024 asks for them.
|
||||
//!
|
||||
//! "Enter property to change [Color/LAyer/LType/ltScale/LWeight/Thickness/TRansparency]" comes
|
||||
//! back after each answer until Enter; each property asks for its value and applies it at once
|
||||
//! (through `properties.set`). Measured from AutoLISP's `command` (accoreconsole, every object read
|
||||
//! back with `entget`):
|
||||
//! - a colour is 1–255, a name (red … white), BYLAYER or BYBLOCK, or Truecolor and then "r,g,b";
|
||||
//! 0, 256 and 300 are refused with "Invalid color number.";
|
||||
//! - a layer must exist: `Cannot find layer "KHONGCO".`;
|
||||
//! - a linetype not loaded yet is loaded from the standard ones (PHANTOM2);
|
||||
//! - a lineweight goes to the nearest standard one, with a note: 0.33 gives "Lineweight rounded to
|
||||
//! nearest valid value of 0.35 mm";
|
||||
//! - `L` alone is "Ambiguous response, please clarify...";
|
||||
//! - objects on locked layers are left out at "Select objects", which says how many after what was
|
||||
//! found ("1 was on a locked layer.", "2 were on a locked layer."); with none left, Enter ends
|
||||
//! the command.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use cadcraft_color::Color;
|
||||
use cadcraft_doc::{Entity, Handle, Lineweight};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::super::curves::{KW, is_locked};
|
||||
use super::super::machines::{SelOutcome, SelectPhase, number};
|
||||
use super::super::targets;
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
const PROPERTIES: [&str; 7] = ["Color", "LAyer", "LType", "ltScale", "LWeight", "Thickness", "TRansparency"];
|
||||
|
||||
/// What CHPROP asks for.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Ask {
|
||||
Property,
|
||||
Color,
|
||||
TrueColor,
|
||||
Layer,
|
||||
Linetype,
|
||||
LtScale,
|
||||
Lineweight,
|
||||
Thickness,
|
||||
Transparency,
|
||||
}
|
||||
|
||||
pub(super) struct ChpropM {
|
||||
select: SelectPhase,
|
||||
handles: Vec<Handle>,
|
||||
/// `None` while objects are selected.
|
||||
ask: Option<Ask>,
|
||||
}
|
||||
|
||||
impl ChpropM {
|
||||
pub(super) fn new() -> Self {
|
||||
ChpropM { select: SelectPhase::default(), handles: Vec::new(), ask: None }
|
||||
}
|
||||
|
||||
/// The selection's common value of a property, or "varies".
|
||||
fn current(&self, s: &Session, f: impl Fn(&Entity) -> String) -> String {
|
||||
let Ok(d) = s.doc() else { return String::new() };
|
||||
let mut vals = self.handles.iter().filter_map(|h| d.entity(*h)).map(|e| f(e));
|
||||
let first = vals.next().unwrap_or_default();
|
||||
if vals.all(|v| v == first) { first } else { "varies".into() }
|
||||
}
|
||||
|
||||
/// Apply `props` to the selection through `properties.set`.
|
||||
fn apply(&self, s: &mut Session, props: Value) -> Result<()> {
|
||||
let mut p = json!({ "handles": self.handles.iter().map(|h| h.hex()).collect::<Vec<_>>() });
|
||||
if let (Some(p), Value::Object(props)) = (p.as_object_mut(), props) {
|
||||
p.extend(props);
|
||||
}
|
||||
super::run_set(s, &p).map(|_| ())
|
||||
}
|
||||
|
||||
/// Take an answer to `ask`; `false` when it was refused (the prompt comes again).
|
||||
fn answer(&mut self, s: &mut Session, ask: Ask, t: &str) -> Result<bool> {
|
||||
let t = t.trim();
|
||||
let word = t.strip_prefix('_').unwrap_or(t);
|
||||
match ask {
|
||||
Ask::Color => match color(word) {
|
||||
Some(c) => self.apply(s, json!({ "color": color_json(c) }))?,
|
||||
None => {
|
||||
s.echo("Invalid color number.");
|
||||
return Ok(false);
|
||||
}
|
||||
},
|
||||
Ask::TrueColor => match Color::parse(word) {
|
||||
Some(c @ Color::True(_)) => self.apply(s, json!({ "color": color_json(c) }))?,
|
||||
_ => {
|
||||
s.echo("Requires three integers from 0 to 255, separated by commas.");
|
||||
return Ok(false);
|
||||
}
|
||||
},
|
||||
Ask::Layer => match s.doc()?.layer(word).map(|l| l.name.clone()) {
|
||||
Some(name) => self.apply(s, json!({ "layer": name }))?,
|
||||
None => {
|
||||
s.echo(format!("Cannot find layer \"{word}\"."));
|
||||
return Ok(false);
|
||||
}
|
||||
},
|
||||
Ask::Linetype => {
|
||||
let by = ["ByLayer", "ByBlock"].into_iter().find(|b| b.eq_ignore_ascii_case(word));
|
||||
let name = match by {
|
||||
Some(b) => Some(b.to_string()),
|
||||
None => {
|
||||
// Not loaded yet: load it from the standard linetypes, as AutoCAD does.
|
||||
if s.doc()?.linetype(word).is_none() {
|
||||
let _ = super::run_linetype(s, &json!({ "load": word }));
|
||||
}
|
||||
s.doc()?.linetype(word).map(|l| l.name.clone())
|
||||
}
|
||||
};
|
||||
match name {
|
||||
Some(n) => self.apply(s, json!({ "linetype": n }))?,
|
||||
None => {
|
||||
s.echo(format!("Cannot find linetype \"{word}\"."));
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ask::LtScale | Ask::Thickness => {
|
||||
let v = number(word).filter(|v| v.is_finite() && (ask == Ask::Thickness || *v > 0.0));
|
||||
match v {
|
||||
Some(v) => self.apply(s, if ask == Ask::LtScale { json!({ "ltscale": v }) } else { json!({ "thickness": v }) })?,
|
||||
None => {
|
||||
s.echo(if ask == Ask::LtScale { "Requires a positive number." } else { "Requires a number." });
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ask::Lineweight => match lineweight(word) {
|
||||
Some((lw, rounded)) => {
|
||||
if let (true, Lineweight::Mm100(v)) = (rounded, lw) {
|
||||
s.echo(format!("Lineweight rounded to nearest valid value of {:.2} mm", f64::from(v) / 100.0));
|
||||
}
|
||||
let v = match lw {
|
||||
Lineweight::Mm100(v) => json!(f64::from(v) / 100.0),
|
||||
Lineweight::ByLayer => json!("ByLayer"),
|
||||
Lineweight::ByBlock => json!("ByBlock"),
|
||||
Lineweight::Default => json!("Default"),
|
||||
};
|
||||
self.apply(s, json!({ "lineweight": v }))?;
|
||||
}
|
||||
None => {
|
||||
s.echo("Invalid lineweight.");
|
||||
return Ok(false);
|
||||
}
|
||||
},
|
||||
Ask::Transparency => {
|
||||
if self.apply(s, json!({ "transparency": word })).is_err() {
|
||||
s.echo("Requires ByLayer, ByBlock or an integer from 0 to 90.");
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
Ask::Property => {}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
/// The objects among `hs` on locked layers.
|
||||
fn locked(s: &Session, hs: &[Handle]) -> HashSet<Handle> {
|
||||
hs.iter().copied().filter(|h| is_locked(s, *h)).collect()
|
||||
}
|
||||
|
||||
fn say_locked(s: &mut Session, n: usize) {
|
||||
match n {
|
||||
0 => {}
|
||||
1 => s.echo("1 was on a locked layer."),
|
||||
n => s.echo(format!("{n} were on a locked layer.")),
|
||||
}
|
||||
}
|
||||
|
||||
/// CHPROP's JSON form: `properties.set` on the objects that are not on locked layers, which the
|
||||
/// command leaves out too; "locked" says how many were.
|
||||
pub(super) fn run_chprop(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let hs = targets(s, p)?;
|
||||
let locked = locked(s, &hs);
|
||||
if locked.is_empty() {
|
||||
return super::run_set(s, p).map(|r| with_locked(r, 0));
|
||||
}
|
||||
let free: Vec<Handle> = hs.into_iter().filter(|h| !locked.contains(h)).collect();
|
||||
let r = if free.is_empty() {
|
||||
json!({ "changed": 0 })
|
||||
} else {
|
||||
let mut q = p.as_object().cloned().unwrap_or_default();
|
||||
q.insert("handles".into(), json!(free.iter().map(|h| h.hex()).collect::<Vec<_>>()));
|
||||
super::run_set(s, &Value::Object(q))?
|
||||
};
|
||||
Ok(with_locked(r, locked.len()))
|
||||
}
|
||||
|
||||
/// `r` with how many objects were left out on locked layers.
|
||||
fn with_locked(mut r: Value, n: usize) -> Value {
|
||||
if let Some(o) = r.as_object_mut() {
|
||||
o.insert("locked".into(), json!(n));
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// A colour typed at CHPROP: 1–255, a name, BYLAYER or BYBLOCK (not 0 or 256, which COLOR takes).
|
||||
fn color(t: &str) -> Option<Color> {
|
||||
if t.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return t.parse::<u16>().ok().filter(|i| (1..=255).contains(i)).and_then(|i| u8::try_from(i).ok()).map(Color::Index);
|
||||
}
|
||||
Color::parse(t).filter(|c| !matches!(c, Color::True(_)))
|
||||
}
|
||||
|
||||
fn color_json(c: Color) -> Value {
|
||||
match c {
|
||||
Color::Index(i) => json!(i),
|
||||
Color::True(rgb) => json!(format!("{},{},{}", rgb.0, rgb.1, rgb.2)),
|
||||
other => json!(other.name()),
|
||||
}
|
||||
}
|
||||
|
||||
fn lineweight_name(lw: Lineweight) -> String {
|
||||
match lw {
|
||||
Lineweight::Mm100(v) => format!("{:.2} mm", f64::from(v) / 100.0),
|
||||
Lineweight::ByLayer => "ByLayer".into(),
|
||||
Lineweight::ByBlock => "ByBlock".into(),
|
||||
Lineweight::Default => "Default".into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A lineweight typed in millimetres (the nearest standard one, and whether that rounded it), or
|
||||
/// ByLayer, ByBlock or Default.
|
||||
fn lineweight(t: &str) -> Option<(Lineweight, bool)> {
|
||||
match t.to_ascii_lowercase().as_str() {
|
||||
"bylayer" => return Some((Lineweight::ByLayer, false)),
|
||||
"byblock" => return Some((Lineweight::ByBlock, false)),
|
||||
"default" => return Some((Lineweight::Default, false)),
|
||||
_ => {}
|
||||
}
|
||||
let mm = number(t).filter(|v| v.is_finite() && *v >= 0.0)?;
|
||||
let want = mm * 100.0;
|
||||
let near = Lineweight::STANDARD.iter().copied().min_by(|a, b| (f64::from(*a) - want).abs().total_cmp(&(f64::from(*b) - want).abs()))?;
|
||||
Some((Lineweight::Mm100(near), (f64::from(near) - want).abs() > 1e-9))
|
||||
}
|
||||
|
||||
impl Interactive for ChpropM {
|
||||
fn name(&self) -> &'static str {
|
||||
"CHPROP"
|
||||
}
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
self.select = SelectPhase::begin(s);
|
||||
if self.select.done {
|
||||
let locked = locked(s, &self.select.picked);
|
||||
if !locked.is_empty() {
|
||||
self.select.picked.retain(|h| !locked.contains(h));
|
||||
say_locked(s, locked.len());
|
||||
s.set_selection(self.select.picked.clone());
|
||||
// Nothing left of the pickfirst selection: ask for objects instead.
|
||||
self.select.done = !self.select.picked.is_empty();
|
||||
}
|
||||
}
|
||||
if self.select.done {
|
||||
self.handles = self.select.picked.clone();
|
||||
self.ask = Some(Ask::Property);
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
fn prompt(&self, s: &Session) -> Prompt {
|
||||
let Some(ask) = self.ask else { return self.select.prompt() };
|
||||
match ask {
|
||||
Ask::Property => Prompt::new("Enter property to change", Accept::TEXT).kw(&PROPERTIES),
|
||||
Ask::Color => {
|
||||
Prompt::new("New color", Accept::TEXT).kw(&["Truecolor"]).default(self.current(s, |e| e.common.color.name().to_ascii_uppercase()))
|
||||
}
|
||||
Ask::TrueColor => Prompt::new("Red,Green,Blue", Accept::TEXT),
|
||||
Ask::Layer => Prompt::new("Enter new layer name", Accept::TEXT).default(self.current(s, |e| e.common.layer.clone())),
|
||||
Ask::Linetype => Prompt::new("Enter new linetype name", Accept::TEXT).default(self.current(s, |e| e.common.linetype.clone())),
|
||||
Ask::LtScale => {
|
||||
Prompt::new("Specify new linetype scale", Accept::NUMBER).default(self.current(s, |e| format!("{:.4}", e.common.ltscale)))
|
||||
}
|
||||
Ask::Lineweight => Prompt::new("Enter new lineweight", Accept::TEXT).default(self.current(s, |e| lineweight_name(e.common.lineweight))),
|
||||
Ask::Thickness => Prompt::new("Specify new thickness", Accept::NUMBER).default(self.current(s, |e| format!("{:.4}", e.common.thickness))),
|
||||
Ask::Transparency => Prompt::new("Enter transparency value", KW).default(self.current(s, |e| e.common.transparency.name())),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
let Some(ask) = self.ask else {
|
||||
// Objects on locked layers are left out, and said after what was found.
|
||||
let (i, n) = match i {
|
||||
Input::Pick(hs) if !self.select.removing => {
|
||||
let locked = locked(s, &hs);
|
||||
(Input::Pick(hs.into_iter().filter(|h| !locked.contains(h)).collect()), locked.len())
|
||||
}
|
||||
i => (i, 0),
|
||||
};
|
||||
let outcome = self.select.feed(s, &i)?;
|
||||
say_locked(s, n);
|
||||
return Ok(match outcome {
|
||||
SelOutcome::More => Step::Continue,
|
||||
SelOutcome::Done(hs) => {
|
||||
self.handles = hs;
|
||||
self.ask = Some(Ask::Property);
|
||||
Step::Continue
|
||||
}
|
||||
SelOutcome::Empty => {
|
||||
s.set_selection(Vec::new());
|
||||
Step::Done
|
||||
}
|
||||
});
|
||||
};
|
||||
match (ask, i) {
|
||||
(Ask::Property, Input::Enter) => {
|
||||
s.set_selection(Vec::new());
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
(Ask::Property, Input::Keyword(k)) => {
|
||||
self.ask = Some(match k.as_str() {
|
||||
"Color" => Ask::Color,
|
||||
"LAyer" => Ask::Layer,
|
||||
"LType" => Ask::Linetype,
|
||||
"ltScale" => Ask::LtScale,
|
||||
"LWeight" => Ask::Lineweight,
|
||||
"Thickness" => Ask::Thickness,
|
||||
_ => Ask::Transparency,
|
||||
});
|
||||
}
|
||||
(Ask::Property, Input::Text(t)) => {
|
||||
let t = t.trim().trim_start_matches('_').to_ascii_lowercase();
|
||||
let n = PROPERTIES.iter().filter(|k| k.to_ascii_lowercase().starts_with(&t)).count();
|
||||
s.echo(if n > 1 { "Ambiguous response, please clarify..." } else { "Invalid option keyword." });
|
||||
}
|
||||
(Ask::Color, Input::Keyword(k)) if k == "Truecolor" => self.ask = Some(Ask::TrueColor),
|
||||
// Enter keeps the value.
|
||||
(_, Input::Enter) => self.ask = Some(Ask::Property),
|
||||
(ask, Input::Text(t) | Input::Keyword(t)) => {
|
||||
// A refused answer asks again.
|
||||
let taken = self.answer(s, ask, &t)?;
|
||||
self.ask = Some(if taken { Ask::Property } else { ask });
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
}
|
||||
@@ -474,7 +474,7 @@ impl Interactive for TrimM {
|
||||
Input::Point(p) => {
|
||||
let ap = s.pixel_size() * s.settings.pickbox.max(1.0) * 1.5;
|
||||
let space = s.space();
|
||||
let Some(h) = crate::select::pick(s.doc()?, &space, p, ap) else {
|
||||
let Some(h) = s.pick_object(&space, p, ap)? else {
|
||||
// A pick in empty space opens a crossing window there.
|
||||
self.stage = Stage::Crossing(Some(p));
|
||||
return Ok(Step::Continue);
|
||||
|
||||
@@ -239,7 +239,7 @@ pub fn stretch_grip(kind: &EntityKind, index: usize, to: Vec2) -> Option<EntityK
|
||||
v.width = w;
|
||||
v.height = h;
|
||||
}
|
||||
EntityKind::Wipeout(w) => *w.boundary.get_mut(index)? = to,
|
||||
EntityKind::Wipeout(w) => w.set_vertex(index, to)?,
|
||||
EntityKind::Unknown(_) => return None,
|
||||
}
|
||||
Some(k)
|
||||
|
||||
@@ -169,6 +169,21 @@ fn other_entity_grips() {
|
||||
assert!(matches!(kind(&s, pt), EntityKind::Point(p) if near(p.p.xy(), 2.0, 2.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wipeout_grips_reject_the_closing_marker_and_hostile_input() {
|
||||
let kind = EntityKind::Wipeout(cadcraft_doc::Wipeout { boundary: vec![v(0.0, 0.0), v(7.0, 1.0), v(3.0, 7.0), v(0.0, 0.0)] });
|
||||
for (index, to) in [(3, v(2.0, 2.0)), (usize::MAX, v(2.0, 2.0)), (0, v(f64::NAN, 0.0)), (0, v(0.0, f64::INFINITY))] {
|
||||
assert!(stretch_grip(&kind, index, to).is_none());
|
||||
}
|
||||
for boundary in [vec![], vec![v(0.0, 0.0)], vec![v(0.0, 0.0), v(0.0, 0.0)]] {
|
||||
let kind = EntityKind::Wipeout(cadcraft_doc::Wipeout { boundary });
|
||||
for index in [0, 1, usize::MAX] {
|
||||
let result = stretch_grip(&kind, index, v(2.0, 2.0));
|
||||
assert_eq!(result.is_some(), index < kind.grips().len());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grip_modes() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -105,10 +105,51 @@ impl Interactive for Guarded {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
use crate::Running;
|
||||
|
||||
thread_local! {
|
||||
static PANIC_AFTER_COMMAND: Cell<bool> = const { Cell::new(false) };
|
||||
}
|
||||
|
||||
/// Called at the start of the passes after a command, which nothing can make panic on demand.
|
||||
pub(crate) fn panic_if_armed() {
|
||||
assert!(!PANIC_AFTER_COMMAND.get(), "a pass after the command panicked");
|
||||
}
|
||||
|
||||
/// A panic in the passes after a JSON command: an error, and the drawing as it was.
|
||||
#[test]
|
||||
fn a_panic_after_a_command_restores_the_drawing() {
|
||||
let mut s = Session::new();
|
||||
s.execute("line", &serde_json::json!({"points": [[0, 0], [1, 0]]})).unwrap();
|
||||
let undo = s.state().unwrap().undo.len();
|
||||
PANIC_AFTER_COMMAND.set(true);
|
||||
let r = s.execute("circle", &serde_json::json!({"center": [0, 0], "radius": 1}));
|
||||
PANIC_AFTER_COMMAND.set(false);
|
||||
assert!(matches!(&r, Err(EngineError::Internal(id, m)) if id == "circle" && m.contains("after the command")), "{r:?}");
|
||||
assert_eq!(points(&s), 1, "the circle is gone with the failed command");
|
||||
assert_eq!(s.state().unwrap().undo.len(), undo, "no undo step for it");
|
||||
s.execute("circle", &serde_json::json!({"center": [0, 0], "radius": 1})).unwrap();
|
||||
assert_eq!(points(&s), 2, "the session still works");
|
||||
}
|
||||
|
||||
/// The same at the end of an interactive command, where nothing returns the error to a caller.
|
||||
#[test]
|
||||
fn a_panic_after_an_interactive_command_restores_the_drawing() {
|
||||
let mut s = Session::new();
|
||||
s.cmdline("line 0,0 1,0").unwrap();
|
||||
PANIC_AFTER_COMMAND.set(true);
|
||||
let r = s.input(Input::Enter);
|
||||
PANIC_AFTER_COMMAND.set(false);
|
||||
assert!(r.is_ok(), "{r:?}");
|
||||
assert!(s.running.is_none());
|
||||
assert_eq!(points(&s), 0, "the drawing is restored");
|
||||
assert!(s.log.iter().any(|l| l.contains("Internal error in line")), "{:?}", s.log);
|
||||
s.cmdline("line 0,0 1,0").unwrap();
|
||||
assert!(s.running.is_some(), "the session still works");
|
||||
}
|
||||
|
||||
fn dot(p: Vec2) -> EntityKind {
|
||||
EntityKind::Point(cadcraft_doc::Point { p: p.to3(0.0), angle: 0.0 })
|
||||
}
|
||||
|
||||
@@ -142,6 +142,10 @@ pub struct Session {
|
||||
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>,
|
||||
/// The object named with a point for the input a prompt picking one object is taking
|
||||
/// ([`Session::input_object`]): a pick at exactly that point picks that object
|
||||
/// ([`Session::pick_object`]).
|
||||
pick_hint: Option<(Handle, Vec2)>,
|
||||
}
|
||||
|
||||
impl Default for Session {
|
||||
@@ -192,6 +196,7 @@ impl Session {
|
||||
lisp: lisp::LispState::default(),
|
||||
lisp_last: None,
|
||||
hatch_picks: None,
|
||||
pick_hint: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -324,3 +329,7 @@ impl Session {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../tests/support/temp_dir.rs"]
|
||||
mod temp_dir;
|
||||
|
||||
@@ -64,8 +64,10 @@ fn items(p: &PickSet) -> Vec<Handle> {
|
||||
|
||||
/// `(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.
|
||||
/// selection set picks objects (a name, alone or with a point as `entsel` gives it, also answers
|
||||
/// a prompt picking one object: [`Session::input_object`]), 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() {
|
||||
@@ -108,14 +110,14 @@ pub fn command(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
|
||||
}
|
||||
}
|
||||
}
|
||||
Value::Ename(h) => feed(ctx.s, Input::Pick(vec![*h])),
|
||||
Value::Ename(h) => named(ctx.s, *h, None),
|
||||
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]));
|
||||
named(ctx.s, h, arg.cdr().car().as_point().map(|p| Vec2::new(p[0], p[1])));
|
||||
} else {
|
||||
return Err(LispError::bad_arg("command argument", arg));
|
||||
}
|
||||
@@ -134,6 +136,14 @@ fn feed(s: &mut Session, input: Input) {
|
||||
}
|
||||
}
|
||||
|
||||
fn named(s: &mut Session, h: Handle, at: Option<Vec2>) {
|
||||
if s.running.is_some()
|
||||
&& let Err(e) = s.input_object(h, at)
|
||||
{
|
||||
s.echo(e.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- system variables ----------------
|
||||
|
||||
/// A JSON value as LISP: integers, reals, strings, lists (points).
|
||||
@@ -181,10 +191,22 @@ fn to_json_bounded(v: &Value, depth: usize, budget: &mut u64) -> Option<serde_js
|
||||
})
|
||||
}
|
||||
|
||||
/// Angle variables CADCraft reports and takes in degrees, as the SETVAR command shows them, which
|
||||
/// AutoLISP's `getvar` and `setvar` give and take in radians, as AutoCAD 2024's do: each of these
|
||||
/// set to 30 by the SETVAR command, or to `(/ pi 6)` by `setvar`, reads 0.5235987755982987.
|
||||
const RADIAN_VARS: [&str; 6] = ["ANGBASE", "CHAMFERD", "DIMJOGANG", "HPANG", "POLARANG", "SNAPANG"];
|
||||
|
||||
fn in_radians(name: &str) -> bool {
|
||||
RADIAN_VARS.iter().any(|v| v.eq_ignore_ascii_case(name.trim()))
|
||||
}
|
||||
|
||||
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()))
|
||||
Ok(Flow::Value(match crate::sysvars::get(ctx.s, &name) {
|
||||
Some(serde_json::Value::Number(n)) if in_radians(&name) => Value::Real(n.as_f64().unwrap_or(0.0).to_radians()),
|
||||
v => v.map(|v| from_json(&v)).unwrap_or_default(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Set a system variable after bounded JSON conversion, reporting invalid or oversized values as LISP errors.
|
||||
@@ -195,6 +217,10 @@ pub fn setvar(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
|
||||
let Some(json) = to_json_bounded(&a[1], 0, &mut budget) else {
|
||||
return Err(LispError::new(format!("AutoCAD variable setting rejected: {} value too large", to_text(&a[0], true))));
|
||||
};
|
||||
let json = match json {
|
||||
serde_json::Value::Number(n) if in_radians(&name) => serde_json::json!(n.as_f64().unwrap_or(0.0).to_degrees()),
|
||||
other => other,
|
||||
};
|
||||
match crate::sysvars::set(ctx.s, &name, &json) {
|
||||
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)))),
|
||||
@@ -1029,7 +1055,7 @@ pub fn rtos(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
|
||||
}
|
||||
|
||||
fn decimal(v: f64, prec: usize, dimzin: i64) -> String {
|
||||
let mut s = format!("{v:.prec$}");
|
||||
let mut s = crate::units::fixed(v, prec);
|
||||
if s.starts_with("-0") && s.trim_start_matches(['-', '0', '.']).is_empty() {
|
||||
s = s.trim_start_matches('-').to_string();
|
||||
}
|
||||
|
||||
@@ -405,7 +405,7 @@ fn entity_lists_hold_angles_in_radians() {
|
||||
// MTEXT: 50 before the column type (75) is the rotation, after it a column height.
|
||||
ok(
|
||||
&mut s,
|
||||
"(entmake (list '(0 . \"MTEXT\") '(10 0.0 0.0 0.0) '(40 . 2.5) '(41 . 10.0) '(1 . \"abc\") (cons 50 (/ pi 3)) '(75 . 2) '(76 . 2) '(79 . 0) '(48 . 10.0) '(49 . 1.0) '(50 . 30.0) '(50 . 20.0)))",
|
||||
"(entmake (list '(0 . \"MTEXT\") '(10 0.0 0.0 0.0) '(40 . 2.5) '(41 . 10.0) '(1 . \"abc\") (cons 50 (/ pi 3)) '(75 . 2) '(76 . 2) '(79 . 0) '(48 . 10.0) '(49 . 1.0) '(50 . 2.0) '(50 . 30.0) '(50 . 20.0)))",
|
||||
);
|
||||
let mtext = s.doc().unwrap().model.iter().find_map(|e| match &e.kind {
|
||||
cadcraft_doc::EntityKind::MText(t) => Some((t.rotation, t.columns.as_ref().map(|c| c.heights.clone()))),
|
||||
@@ -528,7 +528,8 @@ fn interactive_routine_waits_for_the_user() {
|
||||
#[test]
|
||||
fn getangle_is_relative_to_angbase() {
|
||||
let mut s = session();
|
||||
s.cmdline("(setvar \"ANGBASE\" 90)").unwrap();
|
||||
// AutoLISP sets angle variables in radians: ANGBASE 90 degrees.
|
||||
s.cmdline("(setvar \"ANGBASE\" (/ pi 2))").unwrap();
|
||||
// A picked point at 180° in world terms is 90° from ANGBASE.
|
||||
s.cmdline("(setq a (getangle '(0 0)))").unwrap();
|
||||
s.input(crate::Input::Point(cadcraft_geom::Vec2::new(-1.0, 0.0))).unwrap();
|
||||
@@ -543,6 +544,38 @@ fn getangle_is_relative_to_angbase() {
|
||||
assert_eq!(to_text(&s.lisp_var("T2"), true), "3.14159");
|
||||
}
|
||||
|
||||
/// `getvar` and `setvar` take angle variables in radians, as AutoCAD 2024's do (each set to 30 by
|
||||
/// SETVAR, or to `(/ pi 6)` by `setvar`, reads 0.5235987755982987; DIMJOGANG's 45 degrees reads
|
||||
/// 0.785398), while the SETVAR command keeps degrees.
|
||||
#[test]
|
||||
fn getvar_and_setvar_take_angle_variables_in_radians() {
|
||||
let mut s = session();
|
||||
let degrees = |s: &Session, name: &str| crate::sysvars::get(s, name).and_then(|v| v.as_f64()).unwrap();
|
||||
// The defaults: polar tracking every 90 degrees, a 45-degree jog.
|
||||
assert_eq!(ok(&mut s, "(list (equal (getvar \"POLARANG\") (/ pi 2) 1e-12) (equal (getvar \"DIMJOGANG\") (/ pi 4) 1e-12))"), "(T T)");
|
||||
// Set from LISP in radians, shown by SETVAR in degrees.
|
||||
for (name, radians, deg) in
|
||||
[("SNAPANG", "(/ pi 6)", 30.0), ("CHAMFERD", "0.95993108859688", 55.0), ("ANGBASE", "(/ pi 2)", 90.0), ("POLARANG", "(/ pi 4)", 45.0)]
|
||||
{
|
||||
ok(&mut s, &format!("(setvar \"{name}\" {radians})"));
|
||||
assert!((degrees(&s, name) - deg).abs() < 1e-9, "{name}: {}", degrees(&s, name));
|
||||
assert_eq!(ok(&mut s, &format!("(equal (getvar \"{name}\") {radians} 1e-12)")), "T", "{name}");
|
||||
}
|
||||
// Set by the SETVAR command in degrees, read by LISP in radians.
|
||||
s.cmdline("SETVAR HPANG 45").unwrap();
|
||||
assert!((degrees(&s, "HPANG") - 45.0).abs() < 1e-9);
|
||||
assert_eq!(ok(&mut s, "(equal (getvar \"HPANG\") 0.7853981633974482 1e-12)"), "T");
|
||||
// DIMJOGANG set to a whole number of degrees still takes a fraction of one from LISP.
|
||||
s.cmdline("SETVAR DIMJOGANG 45").unwrap();
|
||||
ok(&mut s, "(setvar \"DIMJOGANG\" 0.5)");
|
||||
assert!((degrees(&s, "DIMJOGANG") - 0.5_f64.to_degrees()).abs() < 1e-9, "{}", degrees(&s, "DIMJOGANG"));
|
||||
// It takes the value the dimension style takes: `true` is a DIMSCALE of 1.
|
||||
crate::sysvars::set(&mut s, "DIMSCALE", &serde_json::json!(true)).unwrap();
|
||||
assert_eq!(crate::sysvars::get(&s, "DIMSCALE").and_then(|v| v.as_f64()), Some(1.0));
|
||||
// Other variables are as they were.
|
||||
assert_eq!(ok(&mut s, "(getvar \"ANGDIR\")"), "0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appload_and_load() {
|
||||
let mut s = session();
|
||||
@@ -563,6 +596,111 @@ fn appload_and_load() {
|
||||
assert!(r["error"].as_str().is_some_and(|e| e.contains("bad argument type")));
|
||||
}
|
||||
|
||||
/// `command` gives an entity name, alone or as `entsel` returns it (name and point), to a prompt
|
||||
/// picking one object: the point picks that object, and a bare name stands at its DXF group 10
|
||||
/// point. Each call and its numbers are AutoCAD 2024's (accoreconsole, read back with `entget`);
|
||||
/// AutoCAD ran TRIM and EXTEND in Standard mode there.
|
||||
#[test]
|
||||
fn command_names_objects_at_prompts_picking_one() {
|
||||
use cadcraft_doc::EntityKind;
|
||||
let near = |a: [f64; 2], b: [f64; 2]| (0..2).all(|i| (a[i] - b[i]).abs() <= 1e-9 * a[i].abs().max(1.0));
|
||||
// The drawing's points, lines and polyline widths after `forms`, each evaluated on its own.
|
||||
let run = |forms: &[&str]| {
|
||||
let mut s = session();
|
||||
ok(&mut s, "(setvar \"TRIMEXTENDMODE\" 0)");
|
||||
for f in forms {
|
||||
ok(&mut s, f);
|
||||
}
|
||||
assert!(s.running.is_none(), "{forms:?}: a command is still running");
|
||||
let (mut points, mut lines, mut widths) = (Vec::new(), Vec::new(), Vec::new());
|
||||
for e in s.doc().unwrap().model.iter() {
|
||||
match &e.kind {
|
||||
EntityKind::Point(p) => points.push([p.p.x, p.p.y]),
|
||||
EntityKind::Line(l) => lines.push(([l.a.x, l.a.y], [l.b.x, l.b.y])),
|
||||
EntityKind::LwPolyline(pl) => widths.push(pl.const_width),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
(points, lines, widths)
|
||||
};
|
||||
let has_point = |points: &[[f64; 2]], p: [f64; 2]| points.iter().any(|q| near(*q, p));
|
||||
let has_line = |lines: &[([f64; 2], [f64; 2])], a: [f64; 2], b: [f64; 2]| lines.iter().any(|(p, q)| near(*p, a) && near(*q, b));
|
||||
let line = |name: &str, a: [f64; 2], b: [f64; 2]| {
|
||||
format!("(setq {name} (progn (entmake '((0 . \"LINE\") (10 {} {} 0.0) (11 {} {} 0.0))) (entlast)))", a[0], a[1], b[0], b[1])
|
||||
};
|
||||
|
||||
// A circle named alone stands at its centre, where nothing is drawn.
|
||||
let (points, ..) = run(&[
|
||||
"(setq a (progn (entmake '((0 . \"CIRCLE\") (10 0.0 0.0 0.0) (40 . 15.0))) (entlast)))",
|
||||
"(command \"_.MEASURE\" a 10.275598705220492)",
|
||||
]);
|
||||
assert!(points.len() == 9 && has_point(&points, [14.89599800946832, -1.763304653744166]), "{points:?}");
|
||||
// An arc named alone stands at its centre too, as near to either end: MEASURE runs from its
|
||||
// end (250°), so its points run from 233.4° down to 34.4°, not from 46.6° up.
|
||||
let (points, ..) = run(&[
|
||||
"(setq a (progn (entmake '((0 . \"ARC\") (10 -35000.0 81000.0 0.0) (40 . 1000.0) (50 . 0.5235987755982988) (51 . 4.363323129985824))) (entlast)))",
|
||||
"(command \"_.MEASURE\" a 289.4960530820059)",
|
||||
]);
|
||||
assert!(points.len() == 13 && has_point(&points, [-35596.04133310627, 80197.0462471419]), "{points:?}");
|
||||
assert!(has_point(&points, [-34174.59349504207, 81564.53883973838]), "{points:?}");
|
||||
// PEDIT takes a polyline by name.
|
||||
let (_, _, widths) = run(&[
|
||||
"(setq a (progn (entmake '((0 . \"LWPOLYLINE\") (100 . \"AcDbEntity\") (100 . \"AcDbPolyline\") (90 . 3) (70 . 0) (10 0.0 0.0) (10 30.0 0.0) (42 . 0.4) (10 40.0 20.0))) (entlast)))",
|
||||
"(command \"_.PEDIT\" a \"_W\" 1.25 \"\")",
|
||||
]);
|
||||
assert_eq!(widths, [1.25]);
|
||||
// `entsel` lists: the point picks the end LENGTHEN moves, BREAK's first point, the piece
|
||||
// TRIM removes and the end EXTEND moves.
|
||||
let (_, lines, _) = run(&[
|
||||
&line("a", [0.0, 0.0], [-11.745250375031729, -27.60523670660726]),
|
||||
"(command \"_.LENGTHEN\" \"_DE\" 5.0 (list a '(-10.962233683362948 -25.764887592833446)) \"\")",
|
||||
]);
|
||||
assert!(has_line(&lines, [0.0, 0.0], [-13.70279210420368, -32.2061094910418]), "{lines:?}");
|
||||
let (_, lines, _) = run(&[
|
||||
&line("a", [0.0, 0.0], [-97.11873949964188, 23.831710765295487]),
|
||||
"(command \"_.BREAK\" (list a '(-19.77309329359773 4.852067095763353)) '(-77.20177993977879 18.94434070674469))",
|
||||
]);
|
||||
assert!(lines.len() == 2 && has_line(&lines, [0.0, 0.0], [-19.77309329359773, 4.852067095763352]), "{lines:?}");
|
||||
assert!(has_line(&lines, [-77.20177993977879, 18.94434070674469], [-97.11873949964188, 23.83171076529548]), "{lines:?}");
|
||||
let (_, lines, _) = run(&[
|
||||
&line("a", [-38.39479850296135, -32.02872847799295], [38.39479850296135, 32.02872847799295]),
|
||||
&line("b", [-9.422796057479543, -30.193534825244917], [-15.589739201426854, 9.328216303207391]),
|
||||
&line("c", [35.428336962443794, 7.710053620262788], [-2.7893410422311042, 19.517216189270982]),
|
||||
"(command \"_.TRIM\" a b c \"\" (list a '(1.9066151653265737 1.5904878218740615)) \"\")",
|
||||
]);
|
||||
assert!(lines.len() == 4 && has_line(&lines, [-38.39479850296135, -32.02872847799294], [-12.50626762945319, -10.43265926101876]), "{lines:?}");
|
||||
assert!(has_line(&lines, [16.31949796010634, 13.61363490476688], [38.39479850296135, 32.02872847799294]), "{lines:?}");
|
||||
let (_, lines, _) = run(&[
|
||||
&line("a", [0.0, 0.0], [18.819750237318146, -6.7688256739971955]),
|
||||
&line("b", [17.056820153998288, -35.36317014637398], [58.2221807952743, 8.2878674503852]),
|
||||
"(command \"_.EXTEND\" a b \"\" (list a '(14.11481267798861 -5.076619255497897)) \"\")",
|
||||
]);
|
||||
assert!(has_line(&lines, [0.0, 0.0], [37.63950047463629, -13.53765134799439]), "{lines:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_named_object_holds_for_its_own_input_in_its_own_space() {
|
||||
use cadcraft_doc::Space;
|
||||
let mut s = session();
|
||||
ok(&mut s, "(setq a (progn (entmake '((0 . \"LINE\") (10 0.0 0.0 0.0) (11 10.0 0.0 0.0))) (entlast)))");
|
||||
// BREAK takes A at (5,3), off the line; a later pick there finds nothing.
|
||||
ok(&mut s, "(command \"_.BREAK\" (list a '(5.0 3.0)))");
|
||||
assert!(s.prompt_text().contains("Specify second break point"), "{}", s.prompt_text());
|
||||
let model = s.space();
|
||||
assert_eq!(s.pick_object(&model, cadcraft_geom::Vec2::new(5.0, 3.0), 0.5).unwrap(), None);
|
||||
s.cancel();
|
||||
// A paper-space copy of A named in model space picks nothing, not A under its start.
|
||||
let p = {
|
||||
let d = s.doc_mut().unwrap();
|
||||
let e = d.model.iter().next().unwrap();
|
||||
let (common, kind) = (e.common.clone(), e.kind.clone());
|
||||
d.add(&Space::Paper("Layout1".into()), common, kind).unwrap()
|
||||
};
|
||||
ok(&mut s, &format!("(command \"_.BREAK\" (handent \"{}\"))", p.hex()));
|
||||
assert!(s.prompt_text().contains("Select object"), "{}", s.prompt_text());
|
||||
s.cancel();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appload_reads_a_lsp_path_and_reports_its_commands() {
|
||||
let dir = std::env::temp_dir().join(format!("cadcraft-appload-lisp-{}", std::process::id()));
|
||||
@@ -876,3 +1014,28 @@ fn angtof_reads_the_string_in_the_given_units() {
|
||||
assert_eq!(ok(&mut s, "(angtof \"180\" 7)"), "nil");
|
||||
assert_eq!(ok(&mut s, "(angtof \"180\" \"x\")"), "nil");
|
||||
}
|
||||
|
||||
/// #1920: the value of an expression typed at a prompt is the answer, not another expression.
|
||||
/// `(setq q "!q")` returned `"!q"`, which was evaluated again as `!q`, forever, until the stack
|
||||
/// overflowed and the process aborted (no `catch_unwind` catches that).
|
||||
#[test]
|
||||
fn a_lisp_value_at_a_prompt_is_not_evaluated_again() {
|
||||
let mut s = session();
|
||||
s.cmdline("circle (setq q \"!q\")").unwrap();
|
||||
assert_eq!(ok(&mut s, "q"), "\"!q\"");
|
||||
// "!q" is no centre point, so CIRCLE still asks for one; the session carries on.
|
||||
assert!(s.running.is_some());
|
||||
s.cancel();
|
||||
// A value that is itself an expression stays text too: `(+ 1 2)` is not evaluated to 3.
|
||||
s.cmdline("circle 0,0 (setq r \"(+ 1 2)\")").unwrap();
|
||||
assert!(s.running.is_some(), "\"(+ 1 2)\" is no radius");
|
||||
s.cancel();
|
||||
// Values that answer the prompt still do.
|
||||
s.cmdline("(setq r \"4\")").unwrap();
|
||||
s.cmdline("circle 0,0 !r").unwrap();
|
||||
let r = s.doc().unwrap().model.iter().find_map(|e| match &e.kind {
|
||||
cadcraft_doc::EntityKind::Circle(c) => Some(c.radius),
|
||||
_ => None,
|
||||
});
|
||||
assert_eq!(r, Some(4.0));
|
||||
}
|
||||
|
||||
@@ -136,11 +136,9 @@ impl Formatted {
|
||||
i += 2;
|
||||
match code {
|
||||
None => {}
|
||||
Some('P') => {
|
||||
if f.paras.len() < MAX_PARAS {
|
||||
f.push_char('\n', &cur);
|
||||
f.paras.push(props.clone());
|
||||
}
|
||||
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,
|
||||
|
||||
@@ -180,10 +180,13 @@ impl Prompt {
|
||||
(k, k.to_ascii_lowercase(), shortcut)
|
||||
});
|
||||
// A whole keyword or its capital-letter shortcut wins over a prefix of another keyword
|
||||
// (`M` is Multiple, not a prefix of mEthod).
|
||||
// (`M` is Multiple, not a prefix of mEthod). A prefix reaches past the keyword's last
|
||||
// capital, as in AutoCAD: `L` is none of LAyer, LType, ltScale or LWeight (AutoCAD 2024's
|
||||
// CHPROP: "Ambiguous response, please clarify..."), `LTS` is ltScale.
|
||||
let past_capitals = |k: &str| k.char_indices().rfind(|(_, c)| c.is_ascii_uppercase() || c.is_ascii_digit()).map_or(1, |(i, _)| i + 1);
|
||||
keys.clone()
|
||||
.find(|(_, kl, sc)| tl == *kl || tl == *sc)
|
||||
.or_else(|| keys.clone().find(|(_, kl, sc)| tl.len() >= sc.len() && kl.starts_with(&tl)))
|
||||
.or_else(|| keys.clone().find(|(k, kl, _)| tl.len() >= past_capitals(k) && kl.starts_with(&tl)))
|
||||
.map(|(k, _, _)| k.clone())
|
||||
}
|
||||
}
|
||||
@@ -341,5 +344,32 @@ mod tests {
|
||||
let q = Prompt::new("", Accept::POINT).kw(&["3P", "2P", "Ttr"]);
|
||||
assert_eq!(q.match_keyword("3p").as_deref(), Some("3P"));
|
||||
assert_eq!(q.match_keyword("t").as_deref(), Some("Ttr"));
|
||||
// A prefix reaches past the last capital (AutoCAD 2024's CHPROP: `L` is ambiguous).
|
||||
let c =
|
||||
Prompt::new("Enter property to change", Accept::TEXT).kw(&["Color", "LAyer", "LType", "ltScale", "LWeight", "Thickness", "TRansparency"]);
|
||||
assert_eq!(c.match_keyword("l"), None);
|
||||
assert_eq!(c.match_keyword("la").as_deref(), Some("LAyer"));
|
||||
assert_eq!(c.match_keyword("lts").as_deref(), Some("ltScale"));
|
||||
assert_eq!(c.match_keyword("s").as_deref(), Some("ltScale"));
|
||||
assert_eq!(c.match_keyword("tr").as_deref(), Some("TRansparency"));
|
||||
// Even when no other keyword starts with it: AutoCAD 2024 answers QDIM's `DA` with "Point or
|
||||
// option keyword required." and PEDIT's vertex `E` with "Invalid option keyword.".
|
||||
let q = Prompt::new("", Accept::POINT).kw(&[
|
||||
"Continuous",
|
||||
"Staggered",
|
||||
"Baseline",
|
||||
"Ordinate",
|
||||
"Radius",
|
||||
"Diameter",
|
||||
"datumPoint",
|
||||
"Edit",
|
||||
"seTtings",
|
||||
]);
|
||||
assert_eq!(q.match_keyword("da"), None);
|
||||
assert_eq!(q.match_keyword("datump").as_deref(), Some("datumPoint"));
|
||||
assert_eq!(q.match_keyword("p").as_deref(), Some("datumPoint"));
|
||||
let v = Prompt::new("", Accept::TEXT).kw(&["Next", "Previous", "Break", "Insert", "Move", "Regen", "Straighten", "Tangent", "Width", "eXit"]);
|
||||
assert_eq!(v.match_keyword("e"), None);
|
||||
assert_eq!(v.match_keyword("ex").as_deref(), Some("eXit"));
|
||||
}
|
||||
}
|
||||
|
||||
+33
-17
@@ -181,7 +181,9 @@ pub(crate) fn candidates<'a>(
|
||||
}
|
||||
}
|
||||
|
||||
/// The topmost entity within `aperture` of `p`.
|
||||
/// The topmost entity within `aperture` of `p`: the one drawn last (on top in the draw order), not
|
||||
/// the nearest, as AutoCAD 2024 picks (two objects under the pickbox: the one drawn later is
|
||||
/// picked, and DRAWORDER changes which).
|
||||
pub fn pick(d: &Drawing, space: &Space, p: Vec2, aperture: f64) -> Option<Handle> {
|
||||
let ix = spatial::index(d, space);
|
||||
pick_with(d, space, &ix, p, aperture)
|
||||
@@ -189,7 +191,7 @@ pub fn pick(d: &Drawing, space: &Space, p: Vec2, aperture: f64) -> Option<Handle
|
||||
|
||||
pub(crate) fn pick_with(d: &Drawing, space: &Space, ix: &Option<std::sync::Arc<SpatialIndex>>, p: Vec2, aperture: f64) -> Option<Handle> {
|
||||
let probe = Bounds2::new(p, p).expand(aperture);
|
||||
let mut best: Option<(f64, Handle)> = None;
|
||||
// Candidates come topmost first: the first within the aperture is the one.
|
||||
for (e, known) in candidates(d, space, ix, &probe.expand(aperture), true, true)? {
|
||||
if !selectable(d, e) {
|
||||
continue;
|
||||
@@ -198,30 +200,24 @@ pub(crate) fn pick_with(d: &Drawing, space: &Space, ix: &Option<std::sync::Arc<S
|
||||
if !infinite && !bounds_of(d, e, known).expand(aperture).intersects(&probe) {
|
||||
continue;
|
||||
}
|
||||
let dist = entity_distance(d, e, p, aperture / 4.0);
|
||||
if dist <= aperture && best.is_none_or(|(bd, _)| dist < bd - 1e-12) {
|
||||
best = Some((dist, e.handle));
|
||||
if dist == 0.0 {
|
||||
break;
|
||||
}
|
||||
if entity_distance(d, e, p, aperture / 4.0) <= aperture {
|
||||
return Some(e.handle);
|
||||
}
|
||||
}
|
||||
best.map(|(_, h)| h)
|
||||
None
|
||||
}
|
||||
|
||||
/// Every entity within `aperture` of `p`, nearest first and topmost among equals (so the first is
|
||||
/// what [`pick`] chooses): the objects selection cycling lists.
|
||||
/// Every entity within `aperture` of `p`, topmost first (so the first is what [`pick`] chooses):
|
||||
/// the objects selection cycling lists.
|
||||
pub fn pick_all(d: &Drawing, space: &Space, p: Vec2, aperture: f64) -> Vec<Handle> {
|
||||
let ix = spatial::index(d, space);
|
||||
let probe = Bounds2::new(p, p).expand(aperture);
|
||||
let Some(cands) = candidates(d, space, &ix, &probe.expand(aperture), true, true) else { return Vec::new() };
|
||||
let mut hits: Vec<(f64, Handle)> = cands
|
||||
cands
|
||||
.filter(|(e, known)| selectable(d, e) && (is_infinite(e) || bounds_of(d, e, *known).expand(aperture).intersects(&probe)))
|
||||
.map(|(e, _)| (entity_distance(d, e, p, aperture / 4.0), e.handle))
|
||||
.filter(|(dist, _)| *dist <= aperture)
|
||||
.collect();
|
||||
hits.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
hits.into_iter().map(|(_, h)| h).collect()
|
||||
.filter(|(e, _)| entity_distance(d, e, p, aperture / 4.0) <= aperture)
|
||||
.map(|(e, _)| e.handle)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Whether PREVIEWFILTER keeps `e` from being highlighted under the cursor: bits 1 objects on
|
||||
@@ -486,6 +482,26 @@ mod tests {
|
||||
assert_eq!(fence_hits(EntityKind::XLine(line.clone()), [(0.0, 1.0), (0.0, 5.0)]), 0);
|
||||
assert_eq!(fence_hits(EntityKind::Ray(line), [(5.0, 1.0), (5.0, 5.0)]), 0);
|
||||
}
|
||||
|
||||
/// Two objects under the pickbox: AutoCAD 2024 picks the one drawn last, not the nearer one,
|
||||
/// and DRAWORDER changes which (`(ssget pt)` in accoreconsole).
|
||||
#[test]
|
||||
fn a_pick_takes_the_object_drawn_last() {
|
||||
let mut d = Drawing::new_metric();
|
||||
let circle = d
|
||||
.add(&Space::Model, Common::default(), EntityKind::Circle(cadcraft_doc::Circle { center: Vec3::new(50.0, 50.0, 0.0), radius: 10.0 }))
|
||||
.unwrap();
|
||||
let line = EntityKind::Line(cadcraft_doc::Line { a: Vec3::new(40.5, 45.0, 0.0), b: Vec3::new(40.5, 55.0, 0.0) });
|
||||
let line = d.add(&Space::Model, Common::default(), line).unwrap();
|
||||
// 0.05 from the circle and 0.45 from the line, both inside an aperture of 1.
|
||||
let p = Vec2::new(40.05, 50.0);
|
||||
assert_eq!(pick(&d, &Space::Model, p, 1.0), Some(line));
|
||||
assert_eq!(pick_all(&d, &Space::Model, p, 1.0), vec![line, circle]);
|
||||
d.model.send_to_back(line);
|
||||
assert_eq!(pick(&d, &Space::Model, p, 1.0), Some(circle));
|
||||
// Only one within reach: that one, whatever the order.
|
||||
assert_eq!(pick(&d, &Space::Model, Vec2::new(41.3, 50.0), 1.0), Some(line));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -69,6 +69,13 @@ impl Session {
|
||||
|
||||
/// One token typed while a command runs.
|
||||
pub(crate) fn typed(&mut self, t: &str) -> Result<()> {
|
||||
self.typed_token(t, true)
|
||||
}
|
||||
|
||||
/// One token typed while a command runs; `lisp` evaluates a LISP expression first. The value
|
||||
/// of that expression is the answer, so it comes back with `lisp` off: re-evaluating it would
|
||||
/// let `(setq q "!q")` evaluate itself forever and overflow the stack (#1920).
|
||||
fn typed_token(&mut self, t: &str, lisp: bool) -> Result<()> {
|
||||
let Some(prompt) = self.current_prompt() else { return Ok(()) };
|
||||
let tt = t.trim();
|
||||
if tt.is_empty() {
|
||||
@@ -84,7 +91,7 @@ impl Session {
|
||||
}
|
||||
// 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() {
|
||||
if lisp && 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,
|
||||
@@ -96,9 +103,9 @@ impl Session {
|
||||
};
|
||||
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),
|
||||
lisp::value::Value::Int(i) => self.typed_token(&i.to_string(), false),
|
||||
lisp::value::Value::Real(r) => self.typed_token(&format!("{r}"), false),
|
||||
lisp::value::Value::Str(s) => self.typed_token(&s, false),
|
||||
other => match other.as_point() {
|
||||
Some(p) => self.input(Input::Point(Vec2::new(p[0], p[1]))),
|
||||
None => {
|
||||
|
||||
@@ -4,7 +4,27 @@ impl Session {
|
||||
// ---------------- programmatic execution ----------------
|
||||
|
||||
/// Run a command with JSON parameters (no dialogs). Records undo when the drawing changes.
|
||||
///
|
||||
/// The command itself is guarded below, where its own panic can be told apart. This guard is
|
||||
/// for everything around it: the passes after a command (constraints, associativity, groups)
|
||||
/// are as able to panic as the command, and a panic there restores the drawing the same way.
|
||||
pub fn execute(&mut self, id: &str, params: &Value) -> Result<Value> {
|
||||
let before = self.state().ok().map(|s| (s.doc.clone(), s.selection.clone(), s.uid));
|
||||
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.execute_guarded(id, params))) {
|
||||
Ok(r) => r,
|
||||
Err(p) => {
|
||||
if let Some((doc, sel, uid)) = before
|
||||
&& let Some(st) = self.docs.iter_mut().find(|d| d.uid == uid)
|
||||
{
|
||||
st.doc = doc;
|
||||
st.selection = sel;
|
||||
}
|
||||
Err(EngineError::Internal(id.into(), crate::guard::message(&*p)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_guarded(&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) {
|
||||
|
||||
@@ -9,6 +9,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;
|
||||
self.pick_hint = None;
|
||||
let name = name.trim();
|
||||
let (transparent, name) = match name.strip_prefix('\'') {
|
||||
Some(n) => (true, n),
|
||||
@@ -340,7 +341,21 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// End a command: every way out of one comes through here. The passes after a command
|
||||
/// (constraints, associativity, groups) can panic like the command itself; a panic restores
|
||||
/// the drawing as it was before the command, as a panic in its `input` does.
|
||||
fn finish(&mut self, run: Running, cancelled: bool) {
|
||||
let (id, before, selection) = (run.id.clone(), run.before.clone(), run.selection_before.clone());
|
||||
if let Err(p) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.finish_guarded(run, cancelled))) {
|
||||
if let Ok(st) = self.state_mut() {
|
||||
st.doc = before;
|
||||
st.selection = selection;
|
||||
}
|
||||
self.echo(format!("Internal error in {id}: {}", crate::guard::message(&*p)));
|
||||
}
|
||||
}
|
||||
|
||||
fn finish_guarded(&mut self, run: Running, cancelled: bool) {
|
||||
// A typed selection mode or SIngle never outlives its command.
|
||||
self.select_mode = None;
|
||||
self.select_single = false;
|
||||
@@ -390,6 +405,8 @@ impl Session {
|
||||
/// started in, so associativity is not applied across a document switch; `ok` is false for a
|
||||
/// failed or non-undoable programmatic command, which skips the constraint pass.
|
||||
pub(super) fn after_command(&mut self, before: Option<&Arc<Drawing>>, uid: Option<u64>, ok: bool) {
|
||||
#[cfg(test)]
|
||||
crate::guard::tests::panic_if_armed();
|
||||
cmd::groups::after_command(self, before);
|
||||
cmd::constraints::after_command(self, before, ok);
|
||||
assoc::after_command(self, before, uid);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::{Bounds2, Handle, Input, PendingWindow, Result, Session, Vec2, cmd, select};
|
||||
use super::{Bounds2, Drawing, Entity, Handle, Input, PendingWindow, Result, Session, Space, Vec2, cmd, select};
|
||||
|
||||
impl Session {
|
||||
// ---------------- picking without a command ----------------
|
||||
@@ -125,4 +125,47 @@ impl Session {
|
||||
st.previous_selection = sel.to_vec();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- one object by name ----------------
|
||||
|
||||
/// Give the running command an object by name, as AutoLISP's `command` does with an entity
|
||||
/// name or an `entsel` list (name and point). A prompt picking one object with a point
|
||||
/// (BREAK's "Select object", MEASURE's "Select object to measure"…) takes the point, and a
|
||||
/// pick there finds that object ([`Session::pick_object`]) wherever it lies. A bare name
|
||||
/// stands at the object's DXF group 10 point (a line's start, a circle's centre, a
|
||||
/// polyline's first vertex), the point AutoCAD picks it at. The name holds for that input
|
||||
/// only: a later pick at the same point finds what lies there. Other prompts take the object
|
||||
/// as a pick.
|
||||
pub fn input_object(&mut self, h: Handle, at: Option<Vec2>) -> Result<()> {
|
||||
let one = self.current_prompt().is_some_and(|p| p.accept.point && !p.accept.select && p.picks_objects());
|
||||
let p = if one { at.or_else(|| self.doc().ok().and_then(|d| group_10(d, d.entity(h)?))) } else { None };
|
||||
let Some(p) = p.filter(|p| p.is_finite()) else { return self.input(Input::Pick(vec![h])) };
|
||||
self.pick_hint = Some((h, p));
|
||||
let r = self.input(Input::Point(p));
|
||||
self.pick_hint = None;
|
||||
r
|
||||
}
|
||||
|
||||
/// The object a prompt picking one object finds at `p` in `space`: the one named for that
|
||||
/// point ([`Session::input_object`]), which is none when it lies in another space, else the
|
||||
/// object under `p` within `aperture`.
|
||||
pub fn pick_object(&self, space: &Space, p: Vec2, aperture: f64) -> Result<Option<Handle>> {
|
||||
let d = self.doc()?;
|
||||
if let Some((h, q)) = self.pick_hint
|
||||
&& q == p
|
||||
{
|
||||
return Ok((d.space_of(h).as_ref() == Some(space)).then_some(h));
|
||||
}
|
||||
Ok(select::pick(d, space, p, aperture))
|
||||
}
|
||||
}
|
||||
|
||||
/// An object's DXF group 10 point: the first point of its entity list, or its first grip where
|
||||
/// entity lists are not built in.
|
||||
fn group_10(d: &Drawing, e: &Entity) -> Option<Vec2> {
|
||||
let Some(codec) = cmd::file::entity_codec() else { return e.kind.grips().first().copied() };
|
||||
let (_, tags) = (codec.groups)(d, e);
|
||||
let i = tags.iter().position(|t| t.code == 10)?;
|
||||
let y = tags.get(i + 1).filter(|t| t.code == 20)?;
|
||||
Some(Vec2::new(tags[i].value.as_f64(), y.value.as_f64()))
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ const SESSION_VARS: &[&str] = &[
|
||||
"FONTFALLBACK",
|
||||
"SAVETIME",
|
||||
"SAVEFILEPATH",
|
||||
"ISAVEBAK",
|
||||
];
|
||||
|
||||
pub fn get(s: &Session, name: &str) -> Option<Value> {
|
||||
@@ -154,6 +155,7 @@ pub fn get(s: &Session, name: &str) -> Option<Value> {
|
||||
// Autosave (profile settings too).
|
||||
"SAVETIME" => json!(crate::autosave::savetime()),
|
||||
"SAVEFILEPATH" => json!(crate::autosave::savefilepath()),
|
||||
"ISAVEBAK" => json!(i32::from(crate::autosave::isavebak())),
|
||||
"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)),
|
||||
@@ -366,6 +368,7 @@ pub fn set(s: &mut Session, name: &str, v: &Value) -> Result<()> {
|
||||
as_f64(v).filter(|m| *m >= 0.0 && *m <= f64::from(crate::autosave::MAX_SAVETIME)).ok_or_else(bad)?.round() as u32,
|
||||
),
|
||||
"SAVEFILEPATH" => crate::autosave::set_savefilepath(v.as_str().ok_or_else(bad)?),
|
||||
"ISAVEBAK" => crate::autosave::set_isavebak(as_bool(v).ok_or_else(bad)?),
|
||||
// A number is in degrees; text is an angle typed in AUNITS (SETVAR at the command line).
|
||||
"ANGBASE" => {
|
||||
let a = match v {
|
||||
@@ -375,6 +378,15 @@ pub fn set(s: &mut Session, name: &str, v: &Value) -> Result<()> {
|
||||
let a = a.filter(|a| a.is_finite()).ok_or_else(bad)?;
|
||||
s.doc_mut()?.header.set_f64("ANGBASE", cadcraft_geom::norm_angle(a));
|
||||
}
|
||||
// Whole numbers inside the range UNITS clamps to.
|
||||
"LUNITS" | "LUPREC" | "AUNITS" | "AUPREC" => {
|
||||
let (_, lo, hi) = crate::cmd::UNIT_RANGES.iter().find(|(k, ..)| k.eq_ignore_ascii_case(&n)).copied().ok_or_else(bad)?;
|
||||
let i = as_i(v)
|
||||
.or_else(|| as_f64(v).filter(|f| f.fract() == 0.0 && f.abs() < 1e15).map(|f| f as i64))
|
||||
.filter(|i| (lo..=hi).contains(i))
|
||||
.ok_or_else(bad)?;
|
||||
s.doc_mut()?.header.set_i64(&n, i);
|
||||
}
|
||||
"CONSTRAINTINFER" => s.doc_mut()?.parametric.settings.infer = as_bool(v).ok_or_else(bad)?,
|
||||
_ => {
|
||||
if let Some(r) = table::set(s, &n, v) {
|
||||
@@ -387,6 +399,13 @@ pub fn set(s: &mut Session, name: &str, v: &Value) -> Result<()> {
|
||||
if !st.apply_fields(&serde_json::Map::from_iter([((*field).to_string(), v.clone())])).is_empty() {
|
||||
return Err(bad());
|
||||
}
|
||||
// A real style field (DIMJOGANG, DIMSCALE...) is a real variable, even set to a
|
||||
// whole number first, so a later fraction still fits. It takes the value the style
|
||||
// took.
|
||||
if let Some(f) = serde_json::to_value(&st).ok().and_then(|j| j.get(*field).filter(|f| f.is_f64()).and_then(|f| f.as_f64())) {
|
||||
d.header.set_f64(&n, f);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
let val = match v {
|
||||
Value::Number(x) if x.is_i64() => cadcraft_doc::HVal::Int(x.as_i64().unwrap_or(0)),
|
||||
|
||||
+51
-19
@@ -344,6 +344,15 @@ fn fillet_two_lines() {
|
||||
assert!(g.iter().any(|p| p.near(Vec2::new(2.0, 0.0), 1e-9)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_zero_distance_is_rejected() {
|
||||
let mut s = Session::new();
|
||||
s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap();
|
||||
let r = s.execute("offset", &json!({"handle": "100", "distance": 0, "side": [1, 1]}));
|
||||
assert!(r.is_err(), "offset distance 0 made a coincident copy: {r:?}");
|
||||
assert_eq!(s.doc().unwrap().model.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_circle_and_polyline() {
|
||||
let mut s = Session::new();
|
||||
@@ -633,6 +642,17 @@ fn setvar_keeps_header_types() {
|
||||
assert_eq!(s.doc().unwrap().header.f64("LTSCALE", 0.0), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setvar_rejects_out_of_range_units() {
|
||||
let mut s = Session::new();
|
||||
for (name, v) in [("LUPREC", 100), ("LUNITS", 9), ("LUNITS", 0), ("AUPREC", -5), ("AUNITS", 5)] {
|
||||
assert!(s.execute("setvar", &json!({"name": name, "value": v})).is_err(), "{name} {v}");
|
||||
}
|
||||
s.execute("setvar", &json!({"name": "LUPREC", "value": 8})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().header.i64("LUPREC", 0), 8);
|
||||
assert_ne!(s.doc().unwrap().header.i64("LUNITS", 2), 9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_input_pointer_settings() {
|
||||
let mut s = Session::new();
|
||||
@@ -680,25 +700,6 @@ fn cancel_keeps_partial_line_as_one_undo() {
|
||||
assert_eq!(s.doc().unwrap().model.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_params_never_panic() {
|
||||
let mut s = Session::new();
|
||||
for c in command_specs() {
|
||||
for p in [
|
||||
json!(null),
|
||||
json!([]),
|
||||
json!("x"),
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_interactive_command_starts_and_cancels() {
|
||||
for c in command_specs().iter().filter(|c| c.interactive.is_some()) {
|
||||
@@ -1433,3 +1434,34 @@ fn hostile_variable_like_input_never_panics() {
|
||||
s.cmdline("pickadd").unwrap();
|
||||
assert!(setvar_prompt_open(&s));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chamfer_rejects_negative_distances() {
|
||||
let mut s = Session::new();
|
||||
let h1 = s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap();
|
||||
let h2 = s.execute("line", &json!({"points": [[0, 0], [0, 10]]})).unwrap();
|
||||
let before = format!("{:?}", s.doc().unwrap().model.iter().map(|e| e.kind.clone()).collect::<Vec<_>>());
|
||||
let r = s.execute("chamfer", &json!({"h1": h1["handles"][0], "h2": h2["handles"][0], "p1": [5, 0], "p2": [0, 5], "d1": -3, "d2": 2}));
|
||||
assert!(r.is_err(), "negative chamfer distance accepted: {r:?}");
|
||||
let after = format!("{:?}", s.doc().unwrap().model.iter().map(|e| e.kind.clone()).collect::<Vec<_>>());
|
||||
assert_eq!(before, after);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn array_counts_reject_invalid_values() {
|
||||
let mut s = Session::new();
|
||||
s.execute("line", &json!({"points": [[0, 0], [1, 0]]})).unwrap();
|
||||
for bad in [json!(-3), json!(0), json!(2.5)] {
|
||||
for key in ["count", "rows", "levels"] {
|
||||
let mut p = json!({"center": [0, 0], "associative": false});
|
||||
p[key] = bad.clone();
|
||||
assert!(s.execute("arraypolar", &p).is_err(), "arraypolar {key}={bad}");
|
||||
}
|
||||
for key in ["rows", "cols", "levels"] {
|
||||
let mut p = json!({"associative": false});
|
||||
p[key] = bad.clone();
|
||||
assert!(s.execute("arrayrect", &p).is_err(), "arrayrect {key}={bad}");
|
||||
}
|
||||
}
|
||||
s.execute("arraypolar", &json!({"center": [0, 0], "count": 1, "associative": false})).unwrap();
|
||||
}
|
||||
|
||||
+80
-14
@@ -68,23 +68,62 @@ pub fn format_distance(v: f64, lunits: i64, luprec: i64) -> String {
|
||||
};
|
||||
format!("{}{}{}", if neg { "-" } else { "" }, whole, fs)
|
||||
}
|
||||
_ => format!("{:.*}", prec, v),
|
||||
_ => fixed(v, prec),
|
||||
}
|
||||
}
|
||||
|
||||
/// Scientific notation with a signed exponent of at least two digits (`1.7500E+01`, `1.00E-05`).
|
||||
fn scientific(v: f64, prec: usize) -> String {
|
||||
let s = format!("{:.*E}", prec, v);
|
||||
match s.split_once('E') {
|
||||
Some((mantissa, exp)) => {
|
||||
let (sign, digits) = match exp.strip_prefix('-') {
|
||||
Some(d) => ('-', d),
|
||||
None => ('+', exp),
|
||||
};
|
||||
format!("{mantissa}E{sign}{digits:0>2}")
|
||||
}
|
||||
None => s,
|
||||
/// `v` with `prec` decimals, rounded as AutoCAD rounds a number it shows (RTOS, LUNITS): at 15
|
||||
/// significant digits, with a half rounded away from zero. AutoCAD 2024 gives 1.01 for 1.005 to two
|
||||
/// places (its binary value is just below 1.005), 3 for 2.5 and -0.13 for -0.125, where Rust's
|
||||
/// formatting rounds the binary value and a half to even (1.00, 2 and -0.12).
|
||||
pub fn fixed(v: f64, prec: usize) -> String {
|
||||
let plain = || format!("{v:.prec$}");
|
||||
if !v.is_finite() {
|
||||
return plain();
|
||||
}
|
||||
let sci = format!("{:.14e}", v.abs());
|
||||
let Some((mantissa, exp)) = sci.split_once('e') else { return plain() };
|
||||
let Ok(exp) = exp.parse::<i64>() else { return plain() };
|
||||
let sig: Vec<u8> = mantissa.bytes().filter(u8::is_ascii_digit).map(|b| b - b'0').collect();
|
||||
// Digit `k` of `sig` is worth 10^(exp - k); keep those down to 10^-prec, in units of 10^-prec.
|
||||
let digit = |k: i64| usize::try_from(k).ok().and_then(|k| sig.get(k)).copied().unwrap_or(0);
|
||||
let keep = exp + 1 + prec as i64;
|
||||
let mut units: Vec<u8> = (0..keep).map(digit).collect();
|
||||
if keep >= 0 && digit(keep) >= 5 {
|
||||
let mut carry = true;
|
||||
for d in units.iter_mut().rev() {
|
||||
if *d == 9 {
|
||||
*d = 0;
|
||||
} else {
|
||||
*d += 1;
|
||||
carry = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if carry {
|
||||
units.insert(0, 1);
|
||||
}
|
||||
}
|
||||
let n: String = units.iter().map(|d| char::from(b'0' + d)).collect();
|
||||
let n = format!("{:0>width$}", n.trim_start_matches('0'), width = prec + 1);
|
||||
let (int, frac) = n.split_at(n.len() - prec);
|
||||
let body = if prec == 0 { int.to_string() } else { format!("{int}.{frac}") };
|
||||
if v < 0.0 && n.bytes().any(|b| b != b'0') { format!("-{body}") } else { body }
|
||||
}
|
||||
|
||||
/// Scientific notation with a signed exponent of at least two digits (`1.7500E+01`, `1.00E-05`),
|
||||
/// the mantissa rounded as [`fixed`] rounds.
|
||||
fn scientific(v: f64, prec: usize) -> String {
|
||||
let s = format!("{:.14e}", v);
|
||||
let Some((m, e)) = s.split_once('e') else { return s };
|
||||
let (Ok(m), Ok(mut e)) = (m.parse::<f64>(), e.parse::<i64>()) else { return s };
|
||||
let mut mantissa = fixed(m, prec);
|
||||
// 9.99… rounded up to 10: one more power of ten.
|
||||
if mantissa.trim_start_matches('-').starts_with("10") {
|
||||
e += 1;
|
||||
mantissa = fixed(m / 10.0, prec);
|
||||
}
|
||||
format!("{mantissa}E{}{:0>2}", if e < 0 { '-' } else { '+' }, e.abs())
|
||||
}
|
||||
|
||||
fn gcd(a: i64, b: i64) -> i64 {
|
||||
@@ -435,11 +474,38 @@ mod tests {
|
||||
assert_eq!(format_distance(11.6, 3, 0), "1'-0\"");
|
||||
}
|
||||
|
||||
/// RTOS in AutoCAD 2024 (accoreconsole, DIMZIN 0): a half rounds away from zero, at 15
|
||||
/// significant digits.
|
||||
#[test]
|
||||
fn numbers_round_as_autocad_rounds_them() {
|
||||
for (v, prec, want) in [
|
||||
(1500.5, 0, "1501"),
|
||||
(2.5, 0, "3"),
|
||||
(-2.5, 0, "-3"),
|
||||
(0.125, 2, "0.13"),
|
||||
(0.5, 0, "1"),
|
||||
(1.005, 2, "1.01"),
|
||||
(3.5, 0, "4"),
|
||||
(0.0625, 3, "0.063"),
|
||||
(-0.125, 2, "-0.13"),
|
||||
(1234.5678, 3, "1234.568"),
|
||||
] {
|
||||
assert_eq!(format_distance(v, 2, prec), want, "{v} to {prec}");
|
||||
}
|
||||
assert_eq!(format_distance(2.5, 1, 0), "3E+00");
|
||||
assert_eq!(format_distance(0.125, 1, 1), "1.3E-01");
|
||||
assert_eq!(format_distance(9.96, 1, 1), "1.0E+01");
|
||||
assert_eq!(fixed(-0.004, 2), "0.00");
|
||||
assert_eq!(fixed(1e-20, 3), "0.000");
|
||||
assert_eq!(fixed(123456789012.0, 1), "123456789012.0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scientific_exponent_is_signed_and_padded() {
|
||||
assert_eq!(format_distance(17.5, 1, 4), "1.7500E+01");
|
||||
assert_eq!(format_distance(0.00001, 1, 2), "1.00E-05");
|
||||
assert_eq!(format_distance(1234.5, 1, 3), "1.234E+03");
|
||||
// 1.2345 is halfway: away from zero, as AutoCAD rounds 2.5 to 3E+00.
|
||||
assert_eq!(format_distance(1234.5, 1, 3), "1.235E+03");
|
||||
assert_eq!(format_distance(12345678.0, 1, 3), "1.235E+07");
|
||||
assert_eq!(format_distance(-17.5, 1, 4), "-1.7500E+01");
|
||||
assert_eq!(format_distance(0.0, 1, 2), "0.00E+00");
|
||||
|
||||
@@ -8,6 +8,11 @@ use cadcraft_engine::Session;
|
||||
use cadcraft_engine::cmd::resolve_alias;
|
||||
use serde_json::json;
|
||||
|
||||
#[path = "support/temp_dir.rs"]
|
||||
mod temp_dir;
|
||||
|
||||
use temp_dir::TempDir;
|
||||
|
||||
static ONE_AT_A_TIME: Mutex<()> = Mutex::new(());
|
||||
|
||||
const PGP: &str = "\
|
||||
@@ -55,10 +60,8 @@ fn an_alias_file_adds_and_overrides_aliases() {
|
||||
#[test]
|
||||
fn reinit_reads_the_alias_file_again() {
|
||||
let _one = ONE_AT_A_TIME.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let dir = std::env::temp_dir().join(format!("cadcraft-aliases-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let file = dir.join("mine.pgp").to_string_lossy().into_owned();
|
||||
std::fs::write(&file, "ZZR, *RECTANG\n").unwrap();
|
||||
let dir = TempDir::new("aliases");
|
||||
let file = dir.file("mine.pgp", "ZZR, *RECTANG\n");
|
||||
let mut s = Session::new();
|
||||
s.execute("aliasedit", &json!({ "file": file })).unwrap();
|
||||
assert_eq!(resolve_alias("zzr"), "rectang");
|
||||
|
||||
@@ -140,7 +140,8 @@ fn what_the_block_editor_refuses() {
|
||||
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");
|
||||
s.execute("bedit", &json!({ "name": "DOOR" })).unwrap();
|
||||
assert_eq!(editor(&s).as_deref(), Some("Door"), "the block already open stays 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);
|
||||
@@ -370,3 +371,129 @@ fn the_current_drawing_opens_in_the_block_editor() {
|
||||
s.undo().unwrap();
|
||||
assert_eq!(s.doc().unwrap().model.len(), 1);
|
||||
}
|
||||
|
||||
/// BEDIT in the Block Editor switches to another block, as AutoCAD's does: changes not saved yet
|
||||
/// are saved or discarded first (`save`), and a block it can't open leaves the editor as it was.
|
||||
#[test]
|
||||
fn bedit_in_the_block_editor_switches_blocks() {
|
||||
let mut s = Session::new();
|
||||
door(&mut s);
|
||||
let l = line(&mut s, [0.0, 50.0], [5.0, 50.0]);
|
||||
s.execute("block", &json!({ "name": "Frame", "base": [0, 50], "handles": [l] })).unwrap();
|
||||
let model = s.doc().unwrap().model.len();
|
||||
|
||||
s.execute("bedit", &json!({ "name": "Door" })).unwrap();
|
||||
line(&mut s, [0.0, 0.0], [0.0, 3.0]);
|
||||
// Changes not saved: JSON must say what to do with them.
|
||||
let e = s.execute("bedit", &json!({ "name": "Frame" })).unwrap_err().to_string();
|
||||
assert!(e.contains("save"), "{e}");
|
||||
assert_eq!(editor(&s).as_deref(), Some("Door"));
|
||||
assert!(s.state().unwrap().block_unsaved(), "nothing was lost");
|
||||
// A block that isn't there, or `save` that isn't a bool: refused, nothing changes.
|
||||
assert!(s.execute("bedit", &json!({ "name": "Nope", "save": true })).is_err());
|
||||
assert!(s.execute("bedit", &json!({ "name": "Frame", "save": "yes" })).is_err());
|
||||
assert_eq!(editor(&s).as_deref(), Some("Door"));
|
||||
assert!(s.state().unwrap().block_unsaved());
|
||||
|
||||
// Saved, then switched.
|
||||
let r = s.execute("bedit", &json!({ "name": "frame", "save": true })).unwrap();
|
||||
assert_eq!((r["block"].as_str(), r["saved"].as_bool()), (Some("Frame"), Some(true)));
|
||||
assert_eq!(editor(&s).as_deref(), Some("Frame"));
|
||||
assert_eq!(lines_of(&s, "Door").len(), 2, "Door keeps the new line");
|
||||
|
||||
// Discarded, then switched; with nothing to save `save` isn't needed.
|
||||
line(&mut s, [1.0, 1.0], [2.0, 2.0]);
|
||||
s.execute("bedit", &json!({ "name": "Door", "save": false })).unwrap();
|
||||
assert_eq!(lines_of(&s, "Frame").len(), 1, "Frame's change was discarded");
|
||||
s.execute("bedit", &json!({ "name": "Frame" })).unwrap();
|
||||
assert_eq!(editor(&s).as_deref(), Some("Frame"));
|
||||
// A new block, and the drawing itself.
|
||||
s.execute("bedit", &json!({ "name": "Gate", "create": true })).unwrap();
|
||||
assert_eq!(editor(&s).as_deref(), Some("Gate"));
|
||||
s.execute("bedit", &json!({ "name": "<Current Drawing>" })).unwrap();
|
||||
s.execute("bclose", &json!({ "save": false })).unwrap();
|
||||
assert!(editor(&s).is_none());
|
||||
assert_eq!(s.doc().unwrap().model.len(), model, "the drawing as it was");
|
||||
}
|
||||
|
||||
/// A block BEDIT can't switch to is refused before it asks about the changes not saved, so the
|
||||
/// question never traps the user; `check` asks the same of JSON callers.
|
||||
#[test]
|
||||
fn bedit_refuses_a_bad_target_before_asking_to_save() {
|
||||
let mut s = Session::new();
|
||||
door(&mut s);
|
||||
s.execute("bedit", &json!({ "name": "Door" })).unwrap();
|
||||
line(&mut s, [1.0, 1.0], [2.0, 2.0]);
|
||||
assert!(s.state().unwrap().block_unsaved());
|
||||
s.script(
|
||||
"BEDIT
|
||||
Nope
|
||||
",
|
||||
)
|
||||
.unwrap();
|
||||
let message = s.current_prompt().map(|p| p.message).unwrap_or_default();
|
||||
assert!(!message.contains("Save changes"), "no question for a block it can't open: {message}");
|
||||
s.input(cadcraft_engine::Input::Cancel).unwrap();
|
||||
assert_eq!(editor(&s).as_deref(), Some("Door"));
|
||||
assert!(s.state().unwrap().block_unsaved(), "nothing lost");
|
||||
// The check, without switching or saving.
|
||||
assert!(s.execute("bedit", &json!({ "name": "Nope", "check": true })).is_err());
|
||||
assert!(s.execute("bedit", &json!({ "name": "Nope", "create": true, "check": true })).is_ok(), "a new name is fine");
|
||||
assert_eq!(editor(&s).as_deref(), Some("Door"));
|
||||
assert!(s.state().unwrap().block_unsaved());
|
||||
}
|
||||
|
||||
/// Typed, BEDIT in the Block Editor asks whether to save the changes before it switches.
|
||||
#[test]
|
||||
fn bedit_typed_in_the_block_editor_asks_to_save_first() {
|
||||
let mut s = Session::new();
|
||||
door(&mut s);
|
||||
let l = line(&mut s, [0.0, 50.0], [5.0, 50.0]);
|
||||
s.execute("block", &json!({ "name": "Frame", "base": [0, 50], "handles": [l] })).unwrap();
|
||||
s.execute("bedit", &json!({ "name": "Door" })).unwrap();
|
||||
|
||||
// Nothing changed: it switches at once.
|
||||
s.script(
|
||||
"BEDIT
|
||||
Frame
|
||||
",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(s.running.is_none());
|
||||
assert_eq!(editor(&s).as_deref(), Some("Frame"));
|
||||
|
||||
line(&mut s, [1.0, 1.0], [2.0, 2.0]);
|
||||
s.script(
|
||||
"BEDIT
|
||||
Door
|
||||
",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(s.current_prompt().unwrap().message, "Save changes to block \"Frame\"?");
|
||||
// Escape keeps the editor on Frame with its change.
|
||||
s.input(cadcraft_engine::Input::Cancel).unwrap();
|
||||
assert_eq!(editor(&s).as_deref(), Some("Frame"));
|
||||
assert!(s.state().unwrap().block_unsaved());
|
||||
|
||||
s.script(
|
||||
"BEDIT
|
||||
Door
|
||||
Y
|
||||
",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(s.running.is_none());
|
||||
assert_eq!(editor(&s).as_deref(), Some("Door"));
|
||||
assert_eq!(lines_of(&s, "Frame").len(), 2, "saved before switching");
|
||||
|
||||
line(&mut s, [3.0, 3.0], [4.0, 4.0]);
|
||||
s.script(
|
||||
"BEDIT
|
||||
Frame
|
||||
N
|
||||
",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(editor(&s).as_deref(), Some("Frame"));
|
||||
assert_eq!(lines_of(&s, "Door").len(), 1, "discarded");
|
||||
}
|
||||
|
||||
@@ -8,6 +8,11 @@ use cadcraft_engine::doc::geom::Vec3;
|
||||
use cadcraft_engine::doc::{Block, Circle, Drawing, Entity, EntityKind, HVal, Handle, Insert, Layer, Line};
|
||||
use serde_json::json;
|
||||
|
||||
#[path = "support/temp_dir.rs"]
|
||||
mod temp_dir;
|
||||
|
||||
use temp_dir::TempDir;
|
||||
|
||||
/// A drawing file in a test format: its bytes are "x,y", the $INSBASE. Model space: a line from
|
||||
/// 10,10 to 20,10 on layer PARTS (colour 1), and a reference to block NUT (a circle) at 10,10.
|
||||
fn read(bytes: &[u8], _name: &str) -> Result<Drawing, String> {
|
||||
@@ -44,15 +49,6 @@ fn hooks() {
|
||||
set_io(IoHooks { read, write: |_, _| Err("no writer in this test".into()), plot: None });
|
||||
}
|
||||
|
||||
/// Write a test drawing file with $INSBASE `base`; returns its path.
|
||||
fn file(name: &str, base: &str) -> String {
|
||||
let dir = std::env::temp_dir().join(format!("cadcraft-insert-file-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let p = dir.join(name);
|
||||
std::fs::write(&p, base).unwrap();
|
||||
p.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
/// Lines in model space as (ax, ay, bx, by), rounded.
|
||||
fn lines(s: &Session) -> Vec<(f64, f64, f64, f64)> {
|
||||
let r = |v: f64| (v * 1e6).round() / 1e6;
|
||||
@@ -105,7 +101,9 @@ fn star_name_inserts_the_blocks_objects_with_one_scale_factor() {
|
||||
#[test]
|
||||
fn a_drawing_file_becomes_a_block_with_its_insbase_and_tables() {
|
||||
hooks();
|
||||
let bolt = file("bolt.dxf", "10,10");
|
||||
// Test drawing files hold their $INSBASE.
|
||||
let dir = TempDir::new("insert-file");
|
||||
let bolt = dir.file("bolt.dxf", "10,10");
|
||||
let mut s = Session::new();
|
||||
// This drawing's own layer PARTS keeps its colour.
|
||||
s.execute("layer.new", &json!({ "name": "PARTS", "color": 3 })).unwrap();
|
||||
@@ -128,7 +126,7 @@ fn a_drawing_file_becomes_a_block_with_its_insbase_and_tables() {
|
||||
assert_eq!(inserts(&s).len(), 2);
|
||||
|
||||
// NAME=path defines the block under that name without asking; *path inserts the file's objects.
|
||||
let pin = file("pin.dxf", "10,10");
|
||||
let pin = dir.file("pin.dxf", "10,10");
|
||||
s.script(&format!("-INSERT M8={bolt} 0,0 1 1 0\n-INSERT *{pin} 50,0 1 0\n")).unwrap();
|
||||
assert!(s.running.is_none(), "INSERT finished: {}", s.prompt_text());
|
||||
assert!(s.doc().unwrap().block("M8").is_some());
|
||||
@@ -138,7 +136,7 @@ fn a_drawing_file_becomes_a_block_with_its_insbase_and_tables() {
|
||||
assert!(inserts(&s).iter().any(|i| i.0 == "NUT" && i.1 == 50.0 && i.2 == 0.0));
|
||||
|
||||
// JSON: `file` with an optional block name, never a dialog.
|
||||
let r = s.execute("insert", &json!({ "file": file("washer.dxf", "0,0"), "at": [5, 5] })).unwrap();
|
||||
let r = s.execute("insert", &json!({ "file": dir.file("washer.dxf", "0,0"), "at": [5, 5] })).unwrap();
|
||||
assert!(r["handle"].is_string(), "{r}");
|
||||
assert!(inserts(&s).iter().any(|i| i.0 == "washer"));
|
||||
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
//! Small value changes must not export an imported justified attribute's old baseline.
|
||||
use cadcraft_doc::{Attrib, Drawing, EntityKind, HAlign, Handle, Text, VAlign};
|
||||
use cadcraft_engine::{Input, Session};
|
||||
use cadcraft_geom::Vec3;
|
||||
use serde_json::json;
|
||||
|
||||
const BEFORE: &str = "POWER ADAPTER";
|
||||
const AFTER: &str = "POWER ADAPTER.";
|
||||
|
||||
fn fixture() -> (Session, Handle, Handle) {
|
||||
let mut s = Session::new();
|
||||
for (tag, value, y) in [("T", BEFORE, 15), ("U", "ADPT-01", 0)] {
|
||||
s.execute("attdef", &json!({"tag":tag,"default":value,"at":[0,y],"height":3})).unwrap();
|
||||
}
|
||||
s.execute("line", &json!({"points":[[0,-5],[40,-5]]})).unwrap();
|
||||
let handles: Vec<_> = s.doc().unwrap().model.iter().map(|e| e.handle.hex()).collect();
|
||||
s.execute("block", &json!({"name":"Equipment","handles":handles,"base":[0,0],"keep":"delete"})).unwrap();
|
||||
let a = s.execute("insert", &json!({"name":"Equipment","at":[100,200],"scale":2,"rotation":30})).unwrap();
|
||||
let b = s.execute("insert", &json!({"name":"Equipment","at":[300,200],"attribs":{"T":"Other"}})).unwrap();
|
||||
let a = Handle::parse_hex(a["handle"].as_str().unwrap()).unwrap();
|
||||
let b = Handle::parse_hex(b["handle"].as_str().unwrap()).unwrap();
|
||||
// Set existing document fields directly: this regression does not require Text Options.
|
||||
s.doc_mut()
|
||||
.unwrap()
|
||||
.modify_entity(a, |e| {
|
||||
let EntityKind::Insert(i) = &mut e.kind else { panic!("insert") };
|
||||
let t = &mut i.attribs[0].text;
|
||||
t.insert.z = 7.0;
|
||||
t.align_pt = Some(t.insert);
|
||||
t.halign = HAlign::Center;
|
||||
t.valign = VAlign::Top;
|
||||
})
|
||||
.unwrap();
|
||||
let imported = cadcraft_io::read_dxf(cadcraft_io::write_dxf(s.doc().unwrap()).as_bytes()).unwrap();
|
||||
let mut s = Session::empty();
|
||||
s.open_drawing(imported, "Imported", None);
|
||||
(s, a, b)
|
||||
}
|
||||
|
||||
fn attributes(d: &Drawing, h: Handle) -> Vec<Attrib> {
|
||||
let EntityKind::Insert(i) = &d.entity(h).unwrap().kind else { panic!("insert") };
|
||||
i.attribs.clone()
|
||||
}
|
||||
|
||||
fn baseline(d: &Drawing, t: &Text) -> cadcraft_geom::Vec2 {
|
||||
let style = d.text_style(&t.style).unwrap();
|
||||
let (h, v) = cadcraft_render::text_align(t.halign, t.valign);
|
||||
cadcraft_fonts::baseline_start(
|
||||
&cadcraft_fonts::TextFont::resolve(&style.font),
|
||||
&t.value,
|
||||
&cadcraft_fonts::TextParams {
|
||||
insert: t.insert.xy(),
|
||||
align_pt: t.align_pt.map(|p| p.xy()),
|
||||
height: t.height,
|
||||
rotation: t.rotation,
|
||||
width_factor: t.width_factor,
|
||||
oblique: t.oblique,
|
||||
h,
|
||||
v,
|
||||
backwards: style.backwards,
|
||||
upside_down: style.upside_down,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_attribute_value_paths_refresh_small_justified_export_changes() {
|
||||
for path in ["json", "interactive", "global-json", "global-interactive"] {
|
||||
let (mut s, a, b) = fixture();
|
||||
let before = attributes(s.doc().unwrap(), a);
|
||||
let other = attributes(s.doc().unwrap(), b);
|
||||
let definition = s.doc().unwrap().block("Equipment").unwrap().clone();
|
||||
match path {
|
||||
"json" => {
|
||||
s.execute("attedit", &json!({"handle":a.hex(),"values":{"t":AFTER}})).unwrap();
|
||||
}
|
||||
"interactive" => {
|
||||
s.start("attedit").unwrap();
|
||||
s.input(Input::Pick(vec![a])).unwrap();
|
||||
s.input(Input::Text(AFTER.into())).unwrap();
|
||||
s.input(Input::Enter).unwrap();
|
||||
assert!(s.running.is_none());
|
||||
}
|
||||
"global-json" => {
|
||||
s.execute("-attedit", &json!({"handles":[a.hex()],"tag":"T","find":"ADAPTER","replace":"ADAPTER."})).unwrap();
|
||||
}
|
||||
"global-interactive" => {
|
||||
s.script("-attedit\nn\nn\nEquipment\nT\n\nADAPTER\nADAPTER.\n").unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
}
|
||||
_ => panic!("path"),
|
||||
}
|
||||
let edited = attributes(s.doc().unwrap(), a);
|
||||
assert_eq!(edited[0].text.value, AFTER, "{path}");
|
||||
assert_eq!(edited[0].text.align_pt, before[0].text.align_pt);
|
||||
assert_eq!(edited[0].text.insert.z, 7.0);
|
||||
assert_eq!(edited[1], before[1]);
|
||||
assert_eq!(attributes(s.doc().unwrap(), b), other);
|
||||
assert_eq!(s.doc().unwrap().block("Equipment").unwrap(), &definition);
|
||||
let expected = baseline(s.doc().unwrap(), &edited[0].text);
|
||||
assert!(expected.dist(before[0].text.insert.xy()) > 0.01, "fixture must change width");
|
||||
for name in ["edited.dxf", "edited.dwg"] {
|
||||
let reopened = cadcraft_io::read(&cadcraft_io::write(s.doc().unwrap(), name).unwrap(), name).unwrap();
|
||||
let restored = attributes(&reopened, a);
|
||||
assert!(
|
||||
restored[0].text.insert.xy().dist(expected) < 1e-8,
|
||||
"{path} {name}: stale {:?}, expected {expected:?}",
|
||||
restored[0].text.insert.xy()
|
||||
);
|
||||
assert_eq!(restored[0].text.align_pt, before[0].text.align_pt);
|
||||
assert_eq!(restored[0].text.value, AFTER);
|
||||
assert_eq!(restored[0].text.insert.z, 7.0);
|
||||
}
|
||||
s.undo().unwrap();
|
||||
assert_eq!(attributes(s.doc().unwrap(), a), before, "{path} undo");
|
||||
s.redo().unwrap();
|
||||
assert_eq!(attributes(s.doc().unwrap(), a), edited, "{path} redo");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equal_attribute_values_preserve_an_imported_font_baseline() {
|
||||
let (mut s, a, _) = fixture();
|
||||
// A small imported-font offset must survive a no-op edit (writer tolerance is deliberate).
|
||||
s.doc_mut()
|
||||
.unwrap()
|
||||
.modify_entity(a, |e| {
|
||||
let EntityKind::Insert(i) = &mut e.kind else { panic!("insert") };
|
||||
i.attribs[0].text.insert.x += 0.1;
|
||||
})
|
||||
.unwrap();
|
||||
let before = attributes(s.doc().unwrap(), a);
|
||||
s.execute("attedit", &json!({"handle":a.hex(),"values":{"T":BEFORE}})).unwrap();
|
||||
assert_eq!(attributes(s.doc().unwrap(), a), before);
|
||||
s.start("attedit").unwrap();
|
||||
s.input(Input::Pick(vec![a])).unwrap();
|
||||
s.input(Input::Enter).unwrap();
|
||||
s.input(Input::Enter).unwrap();
|
||||
assert_eq!(attributes(s.doc().unwrap(), a), before);
|
||||
s.execute("-attedit", &json!({"handles":[a.hex()],"tag":"T","find":"ADAPTER","replace":"ADAPTER"})).unwrap();
|
||||
assert_eq!(attributes(s.doc().unwrap(), a), before, "global JSON no-op");
|
||||
s.script("-attedit\nn\nn\nEquipment\nT\n\nADAPTER\nADAPTER\n").unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
assert_eq!(attributes(s.doc().unwrap(), a), before, "global interactive no-op");
|
||||
}
|
||||
|
||||
/// Recomputing a justified baseline must not replace its independent insertion elevation.
|
||||
#[test]
|
||||
fn value_edits_preserve_distinct_insertion_and_alignment_elevations_on_export() {
|
||||
let (mut s, a, _) = fixture();
|
||||
s.doc_mut()
|
||||
.unwrap()
|
||||
.modify_entity(a, |e| {
|
||||
let EntityKind::Insert(i) = &mut e.kind else { panic!("insert") };
|
||||
i.attribs[0].text.insert.z = 9.0;
|
||||
})
|
||||
.unwrap();
|
||||
let anchor = attributes(s.doc().unwrap(), a)[0].text.align_pt;
|
||||
s.execute("attedit", &json!({"handle":a.hex(),"values":{"T":AFTER}})).unwrap();
|
||||
for name in ["elevations.dxf", "elevations.dwg"] {
|
||||
let reopened = cadcraft_io::read(&cadcraft_io::write(s.doc().unwrap(), name).unwrap(), name).unwrap();
|
||||
let restored = attributes(&reopened, a);
|
||||
assert_eq!(restored[0].text.insert.z, 9.0, "{name}: insertion elevation");
|
||||
assert_eq!(restored[0].text.align_pt.map(|p| p.xy()), anchor.map(|p| p.xy()), "{name}: alignment XY");
|
||||
if name.ends_with(".dxf") {
|
||||
assert_eq!(restored[0].text.align_pt, anchor, "DXF retains independent alignment elevation");
|
||||
} else {
|
||||
// The DWG bridge uses one text elevation for both points.
|
||||
assert_eq!(restored[0].text.align_pt.unwrap().z, 9.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_edits_preserve_left_baseline_and_two_point_placements() {
|
||||
for horizontal in [HAlign::Left, HAlign::Aligned, HAlign::Fit] {
|
||||
let (mut s, a, _) = fixture();
|
||||
s.doc_mut()
|
||||
.unwrap()
|
||||
.modify_entity(a, |e| {
|
||||
let EntityKind::Insert(i) = &mut e.kind else { panic!("insert") };
|
||||
let t = &mut i.attribs[0].text;
|
||||
t.halign = horizontal;
|
||||
t.valign = VAlign::Baseline;
|
||||
t.insert = Vec3::new(10.0, 20.0, 7.0);
|
||||
t.align_pt = (horizontal != HAlign::Left).then_some(Vec3::new(40.0, 30.0, 9.0));
|
||||
})
|
||||
.unwrap();
|
||||
let before = attributes(s.doc().unwrap(), a);
|
||||
s.execute("attedit", &json!({"handle":a.hex(),"values":{"T":AFTER}})).unwrap();
|
||||
let mut expected = before;
|
||||
expected[0].text.value = AFTER.into();
|
||||
assert_eq!(attributes(s.doc().unwrap(), a), expected);
|
||||
if horizontal != HAlign::Left {
|
||||
let reopened = cadcraft_io::read_dxf(cadcraft_io::write_dxf(s.doc().unwrap()).as_bytes()).unwrap();
|
||||
let restored = attributes(&reopened, a);
|
||||
assert_eq!(restored[0].text.insert, expected[0].text.insert);
|
||||
assert_eq!(restored[0].text.align_pt, expected[0].text.align_pt);
|
||||
assert_eq!(restored[0].text.halign, horizontal);
|
||||
assert_eq!(restored[0].text.value, AFTER);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
//! CHPROP as AutoCAD 2024 runs it from AutoLISP's `command` (accoreconsole, every object read back
|
||||
//! with `entget`).
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::doc::color::{Color, Rgb};
|
||||
use cadcraft_engine::doc::{Handle, Lineweight, Transparency};
|
||||
use serde_json::json;
|
||||
|
||||
/// A session with line A (0,0)-(50,0) on layer 0 and line B (0,20)-(50,30) on layer L1, as `a` and
|
||||
/// `b` in LISP, after `code`; the properties of A then B.
|
||||
fn run(code: &str) -> (Session, [cadcraft_engine::doc::Common; 2]) {
|
||||
let mut s = Session::new();
|
||||
s.doc_mut().unwrap().ensure_layer("L1");
|
||||
s.execute("line", &json!({ "points": [[0, 0], [50, 0]] })).unwrap();
|
||||
s.cmdline("(setq a (entlast))").unwrap();
|
||||
s.execute("line", &json!({ "points": [[0, 20], [50, 30]] })).unwrap();
|
||||
s.cmdline("(setq b (entlast))").unwrap();
|
||||
s.cmdline("(command \"_.CHPROP\" b \"\" \"_LA\" \"L1\" \"\")").unwrap();
|
||||
s.cmdline(&format!("(progn {code})")).unwrap();
|
||||
if s.running.is_some() {
|
||||
s.cancel();
|
||||
}
|
||||
let common = {
|
||||
let d = s.doc().unwrap();
|
||||
let mut it = d.model.iter().map(|e| e.common.clone());
|
||||
[it.next().unwrap(), it.next().unwrap()]
|
||||
};
|
||||
(s, common)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chprop_sets_each_property_as_autocad() {
|
||||
let (_, [a, _]) = run("(command \"_.CHPROP\" a \"\" \"_C\" 1 \"\")");
|
||||
assert_eq!(a.color, Color::Index(1));
|
||||
let (_, [a, _]) = run("(command \"_.CHPROP\" a \"\" \"_C\" \"blue\" \"\")");
|
||||
assert_eq!(a.color, Color::Index(5));
|
||||
let (_, [_, b]) = run("(command \"_.CHPROP\" b \"\" \"_C\" \"_BYBLOCK\" \"\")");
|
||||
assert_eq!(b.color, Color::ByBlock);
|
||||
let (_, [a, _]) = run("(command \"_.CHPROP\" a \"\" \"_C\" 3 \"\") (command \"_.CHPROP\" a \"\" \"_C\" \"_BYLAYER\" \"\")");
|
||||
assert_eq!(a.color, Color::ByLayer);
|
||||
let (_, [a, _]) = run("(command \"_.CHPROP\" a \"\" \"_C\" \"_T\" \"255,128,0\" \"\")");
|
||||
assert_eq!(a.color, Color::True(Rgb(255, 128, 0)));
|
||||
let (_, [a, _]) = run("(command \"_.CHPROP\" a \"\" \"_LA\" \"L1\" \"\")");
|
||||
assert_eq!(a.layer, "L1");
|
||||
let (_, [a, _]) = run("(command \"_.CHPROP\" a \"\" \"_LT\" \"DASHED\" \"\")");
|
||||
assert_eq!(a.linetype, "DASHED");
|
||||
// Not loaded yet: loaded and given, as AutoCAD loads it from acadiso.lin.
|
||||
let (s, [a, _]) = run("(command \"_.CHPROP\" a \"\" \"_LT\" \"PHANTOM2\" \"\")");
|
||||
assert_eq!(a.linetype, "PHANTOM2");
|
||||
assert!(s.doc().unwrap().linetype("PHANTOM2").is_some());
|
||||
let (_, [a, _]) = run("(command \"_.CHPROP\" a \"\" \"_S\" 2.5 \"\")");
|
||||
assert_eq!(a.ltscale, 2.5);
|
||||
let (s, [a, _]) = run("(command \"_.CHPROP\" a \"\" \"_LW\" 0.33 \"\")");
|
||||
assert_eq!(a.lineweight, Lineweight::Mm100(35));
|
||||
assert!(s.log.iter().any(|l| l == "Lineweight rounded to nearest valid value of 0.35 mm"), "{:?}", s.log);
|
||||
let (_, [a, _]) = run("(command \"_.CHPROP\" a \"\" \"_T\" 5.0 \"\")");
|
||||
assert_eq!(a.thickness, 5.0);
|
||||
let (_, [a, _]) = run("(command \"_.CHPROP\" a \"\" \"_TR\" 50 \"\")");
|
||||
assert_eq!(a.transparency, Transparency::Percent(50));
|
||||
let (_, [a, _]) = run("(command \"_.CHPROP\" a \"\" \"_TR\" \"_BYBLOCK\" \"\")");
|
||||
assert_eq!(a.transparency, Transparency::ByBlock);
|
||||
// Several objects and several properties in one go.
|
||||
let (_, [a, b]) = run("(command \"_.CHPROP\" a b \"\" \"_C\" 3 \"_LA\" \"L1\" \"_LW\" 0.5 \"\")");
|
||||
for c in [a, b] {
|
||||
assert_eq!((c.color, c.layer.as_str(), c.lineweight), (Color::Index(3), "L1", Lineweight::Mm100(50)));
|
||||
}
|
||||
let (_, [a, _]) = run(
|
||||
"(command \"_.CHPROP\" a \"\" \"_C\" 3 \"\") (command \"_.CHPROP\" a \"\" \"_C\" \"_BYLAYER\" \"_LT\" \"_BYBLOCK\" \"_LW\" \"_BYLAYER\" \"\")",
|
||||
);
|
||||
assert_eq!((a.color, a.linetype.as_str(), a.lineweight), (Color::ByLayer, "ByBlock", Lineweight::ByLayer));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chprop_refuses_what_autocad_refuses() {
|
||||
let (s, [a, _]) = run("(command \"_.CHPROP\" a \"\" \"_LA\" \"KHONGCO\" \"\")");
|
||||
assert_eq!(a.layer, "0");
|
||||
assert!(s.log.iter().any(|l| l == "Cannot find layer \"KHONGCO\"."), "{:?}", s.log);
|
||||
for n in [0, 256, 300] {
|
||||
let (s, [a, _]) = run(&format!("(command \"_.CHPROP\" a \"\" \"_C\" {n} \"\")"));
|
||||
assert_eq!(a.color, Color::ByLayer, "{n}");
|
||||
assert!(s.log.iter().any(|l| l == "Invalid color number."), "{n}: {:?}", s.log);
|
||||
}
|
||||
let (s, [a, _]) = run("(command \"_.CHPROP\" a \"\" \"_C\" \"_T\" \"300,0,0\" \"\")");
|
||||
assert_eq!(a.color, Color::ByLayer);
|
||||
assert!(s.log.iter().any(|l| l == "Requires three integers from 0 to 255, separated by commas."), "{:?}", s.log);
|
||||
let (s, [a, _]) = run("(command \"_.CHPROP\" a \"\" \"_TR\" 95 \"\")");
|
||||
assert_eq!(a.transparency, Transparency::ByLayer);
|
||||
assert!(s.log.iter().any(|l| l == "Requires ByLayer, ByBlock or an integer from 0 to 90."), "{:?}", s.log);
|
||||
// `L` is LAyer, LType, ltScale or LWeight: none of them.
|
||||
let (s, [a, _]) = run("(command \"_.CHPROP\" a \"\" \"_L\")");
|
||||
assert_eq!(a.layer, "0");
|
||||
assert!(s.log.iter().any(|l| l == "Ambiguous response, please clarify..."), "{:?}", s.log);
|
||||
}
|
||||
|
||||
/// Line a on layer 0 and lines b and c on the locked layer L1 (`a`, `b` and `c` in LISP); their
|
||||
/// handles.
|
||||
fn locked_drawing() -> (Session, Vec<Handle>) {
|
||||
let mut s = Session::new();
|
||||
s.doc_mut().unwrap().ensure_layer("L1");
|
||||
for (v, y) in [("a", 0), ("b", 20), ("c", 40)] {
|
||||
s.execute("line", &json!({ "points": [[0, y], [50, y]] })).unwrap();
|
||||
s.cmdline(&format!("(setq {v} (entlast))")).unwrap();
|
||||
}
|
||||
s.cmdline("(command \"_.CHPROP\" b c \"\" \"_LA\" \"L1\" \"\")").unwrap();
|
||||
s.execute("layer.set", &json!({ "name": "L1", "locked": true })).unwrap();
|
||||
s.log.clear();
|
||||
let hs = s.doc().unwrap().model.iter().map(|e| e.handle).collect();
|
||||
(s, hs)
|
||||
}
|
||||
|
||||
fn colors(s: &Session) -> Vec<Color> {
|
||||
s.doc().unwrap().model.iter().map(|e| e.common.color).collect()
|
||||
}
|
||||
|
||||
/// AutoCAD 2024 leaves objects on locked layers out at "Select objects" and says how many after
|
||||
/// what was found; with none left, Enter ends the command.
|
||||
#[test]
|
||||
fn chprop_leaves_objects_on_locked_layers_alone() {
|
||||
// The colours of a, b and c after `code`, run with `pickfirst` selected.
|
||||
let after = |code: &str, pickfirst: &[usize]| {
|
||||
let (mut s, hs) = locked_drawing();
|
||||
s.set_selection(pickfirst.iter().map(|i| hs[*i]).collect());
|
||||
s.cmdline(code).unwrap();
|
||||
(colors(&s), s.log.clone(), s.running.is_some())
|
||||
};
|
||||
let (c, log, _) = after("(command \"_.CHPROP\" (ssadd b (ssadd a)) \"\" \"_C\" 3 \"\")", &[]);
|
||||
assert_eq!(c, [Color::Index(3), Color::ByLayer, Color::ByLayer]);
|
||||
assert!(log.iter().any(|l| l == "1 was on a locked layer."), "{log:?}");
|
||||
let (c, log, _) = after("(command \"_.CHPROP\" (ssadd c (ssadd b (ssadd a))) \"\" \"_C\" 4 \"\")", &[]);
|
||||
assert_eq!(c, [Color::Index(4), Color::ByLayer, Color::ByLayer]);
|
||||
assert!(log.iter().any(|l| l == "2 were on a locked layer."), "{log:?}");
|
||||
// Nothing left: Enter ends the command.
|
||||
let (_, log, running) = after("(command \"_.CHPROP\" b \"\")", &[]);
|
||||
assert!(log.iter().any(|l| l == "1 was on a locked layer."), "{log:?}");
|
||||
assert!(!running);
|
||||
// The pickfirst selection loses them too; with none of it left, CHPROP asks for objects.
|
||||
let (c, log, _) = after("(command \"_.CHPROP\" \"_C\" 5 \"\")", &[0, 1]);
|
||||
assert_eq!(c, [Color::Index(5), Color::ByLayer, Color::ByLayer]);
|
||||
assert!(log.iter().any(|l| l == "1 was on a locked layer."), "{log:?}");
|
||||
let (c, _, _) = after("(command \"_.CHPROP\" a \"\" \"_C\" 6 \"\")", &[1]);
|
||||
assert_eq!(c, [Color::Index(6), Color::ByLayer, Color::ByLayer]);
|
||||
// The JSON form leaves them out as well, and says how many.
|
||||
let (mut s, hs) = locked_drawing();
|
||||
let r = s.execute("chprop", &json!({ "handles": [hs[0].hex(), hs[1].hex()], "color": 3 })).unwrap();
|
||||
assert_eq!((r["changed"].as_u64(), r["locked"].as_u64()), (Some(1), Some(1)), "{r}");
|
||||
let r = s.execute("chprop", &json!({ "handles": [hs[2].hex()], "color": 4 })).unwrap();
|
||||
assert_eq!((r["changed"].as_u64(), r["locked"].as_u64()), (Some(0), Some(1)), "{r}");
|
||||
assert_eq!(colors(&s), [Color::Index(3), Color::ByLayer, Color::ByLayer]);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
//! JSON COPY refuses a `count` that is not an integer from 1 to 10000 instead of clamping it.
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn entities(s: &Session) -> usize {
|
||||
s.doc().map(|d| d.model.iter().count()).unwrap_or(0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_copy_count_is_refused() {
|
||||
let mut s = Session::new();
|
||||
let r = s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap();
|
||||
let h = r["handles"][0].clone();
|
||||
let before = entities(&s);
|
||||
let bad: [Value; 7] = [json!(0), json!(-3), json!(2.5), json!("abc"), json!(null), json!(10001), json!(50000)];
|
||||
for c in bad {
|
||||
let r = s.execute("copy", &json!({"handles": [h], "delta": [0, 5], "count": c}));
|
||||
assert!(r.is_err(), "count {c} should be refused");
|
||||
assert_eq!(entities(&s), before, "count {c} changed the drawing");
|
||||
}
|
||||
s.execute("copy", &json!({"handles": [h], "delta": [0, 5], "count": 3})).unwrap();
|
||||
assert_eq!(entities(&s), before + 3);
|
||||
}
|
||||
@@ -99,6 +99,7 @@ fn jogged_radius() {
|
||||
let (dm, g) = dim_of(&s, &h);
|
||||
let j = dm.marks.jogged.clone().unwrap();
|
||||
assert_eq!(g.value, "R50.0000", "the true radius");
|
||||
assert!((dm.measurement - 50.0).abs() < 1e-9, "the stored measurement is the radius, got {} (#2421)", dm.measurement);
|
||||
assert!(j.center.xy().dist(Vec2::new(20.0, -10.0)) < 1e-9);
|
||||
// The line runs from the arrowhead on the circle to the override, with a 45° jog (DIMJOGANG).
|
||||
let tip = Vec2::new(1.0, 1.0).normalized() * 50.0;
|
||||
@@ -257,3 +258,29 @@ fn marks_follow_move_and_rotate_and_hostile_values_do_not_panic() {
|
||||
.unwrap();
|
||||
let _ = dim_of(&s, &h);
|
||||
}
|
||||
|
||||
/// A dimension made in the session reports its measurement at once, not 0.0 until it is saved
|
||||
/// and reopened (#2421).
|
||||
#[test]
|
||||
fn a_new_dimension_reports_its_measurement() {
|
||||
let mut s = Session::new();
|
||||
let measurement = |s: &Session, h: &str| dim_of(s, h).0.measurement;
|
||||
let horizontal = handle(&s.execute("dimlinear", &json!({ "p1": [0, 0], "p2": [100, 0], "at": [50, 20] })).unwrap());
|
||||
assert!((measurement(&s, &horizontal) - 100.0).abs() < 1e-9, "{}", measurement(&s, &horizontal));
|
||||
let vertical = handle(&s.execute("dimlinear", &json!({ "p1": [0, 0], "p2": [0, 37.5], "at": [-10, 20] })).unwrap());
|
||||
assert!((measurement(&s, &vertical) - 37.5).abs() < 1e-9, "{}", measurement(&s, &vertical));
|
||||
}
|
||||
|
||||
/// A new dimension's text position is known at once, as it is after save and reopen (#2421).
|
||||
#[test]
|
||||
fn a_new_dimension_reports_its_text_position() {
|
||||
let mut s = Session::new();
|
||||
let horizontal = handle(&s.execute("dimlinear", &json!({ "p1": [0, 0], "p2": [100, 0], "at": [50, 20] })).unwrap());
|
||||
let vertical = handle(&s.execute("dimlinear", &json!({ "p1": [0, 0], "p2": [0, 37.5], "at": [-20, 18] })).unwrap());
|
||||
for h in [&horizontal, &vertical] {
|
||||
let (dm, g) = dim_of(&s, h);
|
||||
assert!(dm.text_mid.xy().dist(g.text_pos) < 1e-9, "stored {:?}, drawn at {:?}", dm.text_mid, g.text_pos);
|
||||
assert!(dm.text_mid.xy().dist(Vec2::ZERO) > 1.0, "still at the origin: {:?}", dm.text_mid);
|
||||
}
|
||||
assert!((dim_of(&s, &horizontal).0.text_mid.x - 50.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Command-line INSERT: X/Y scale prompts and the Basepoint/Scale/Rotate options (#245).
|
||||
//! Command-line INSERT: X/Y/Z scale prompts and the Basepoint/Scale/X/Y/Z/Rotate options (#245).
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::doc::EntityKind;
|
||||
@@ -53,3 +53,79 @@ fn script_insert_reads_x_and_y_scale_then_rotation_and_honours_its_options() {
|
||||
s.execute("insert", &json!({ "name": "B1", "at": [0, 80], "scale": [2, 3], "basePoint": [10, 0] })).unwrap();
|
||||
assert_eq!(inserts(&s).last(), Some(&(-20.0, 80.0, 2.0, 3.0, 0.0)));
|
||||
}
|
||||
|
||||
/// (x, y, x scale, y scale, z scale, rotation in degrees) of the last block reference.
|
||||
fn last_insert(s: &Session) -> (f64, f64, f64, f64, f64, f64) {
|
||||
let r = |v: f64| (v * 1e9).round() / 1e9;
|
||||
s.doc()
|
||||
.unwrap()
|
||||
.model
|
||||
.iter()
|
||||
.filter_map(|e| match &e.kind {
|
||||
EntityKind::Insert(i) => Some((r(i.insert.x), r(i.insert.y), r(i.scale.x), r(i.scale.y), r(i.scale.z), r(i.rotation.to_degrees()))),
|
||||
_ => None,
|
||||
})
|
||||
.last()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// The options of AutoCAD 2024's -INSERT, each run as AutoLISP's `command` gives it (accoreconsole,
|
||||
/// the block reference read back with `entget`): X/Y/Z presets set that axis alone and skip the
|
||||
/// scale prompts, XYZ asks Z too, a Z factor not asked is the X factor's size, and a Basepoint is
|
||||
/// picked on the block dropped at the cursor (the origin in a script).
|
||||
#[test]
|
||||
fn insert_options_match_autocad() {
|
||||
// A line (0,0)-(10,0) and a circle at (5,5) r 3, around `base`, as block `name`.
|
||||
let block = |s: &mut Session, name: &str, base: [f64; 2], unit: f64| {
|
||||
let at = |x: f64, y: f64| [base[0] + x * unit, base[1] + y * unit];
|
||||
s.execute("line", &json!({ "points": [at(0.0, 0.0), at(10.0, 0.0)] })).unwrap();
|
||||
s.execute("circle", &json!({ "center": at(5.0, 5.0), "radius": 3.0 * unit })).unwrap();
|
||||
let hs: Vec<String> = s.doc().unwrap().model.handles().iter().map(|h| h.hex()).collect();
|
||||
s.execute("block", &json!({ "name": name, "base": base, "keep": "delete", "handles": hs })).unwrap();
|
||||
};
|
||||
let cases: [(&str, [f64; 2], f64, &str, (f64, f64, f64, f64, f64, f64)); 17] = [
|
||||
// X -1, Y as X: Z +1.
|
||||
("B3", [612345.678, 2398765.432], 1.0, "\"B3\" '(612365.678 2398775.432) -1 \"\" 0", (612365.678, 2398775.432, -1.0, -1.0, 1.0, 0.0)),
|
||||
("B4", [0.0, 0.0], 1.0, "\"B4\" \"_S\" 2.5 '(20.0 10.0) 45", (20.0, 10.0, 2.5, 2.5, 2.5, 45.0)),
|
||||
("B5", [0.0, 0.0], 1.0, "\"B5\" \"_R\" 60 '(20.0 10.0) 2 \"\"", (20.0, 10.0, 2.0, 2.0, 2.0, 60.0)),
|
||||
// X alone: the next number is the rotation.
|
||||
("B6", [0.5, 0.25], 0.001, "\"B6\" \"_X\" 2 '(0.52 0.26) 3", (0.52, 0.26, 2.0, 1.0, 1.0, 3.0)),
|
||||
(
|
||||
"B7",
|
||||
[612345.678, 2398765.432],
|
||||
1.0,
|
||||
"\"B7\" \"_B\" '(612350.678 2398770.432) '(612365.678 2398775.432) 1 \"\" 0",
|
||||
(15.0, 5.0, 1.0, 1.0, 1.0, 0.0),
|
||||
),
|
||||
("B8", [0.0, 0.0], 1.0, "\"B8\" '(20.0 10.0) \"_XYZ\" 2 3 4 0", (20.0, 10.0, 2.0, 3.0, 4.0, 0.0)),
|
||||
("B9", [0.0, 0.0], 1.0, "\"B9\" '(20.0 10.0) \"_C\" '(26.0 14.0) 0", (20.0, 10.0, 6.0, 4.0, 6.0, 0.0)),
|
||||
// Z is stored as a size unless typed at XYZ's Z prompt: Scale -2, XYZ -2 with Enter at Y
|
||||
// and Z, and a Z preset of -3 all give +; XYZ's -4 stays.
|
||||
("C1", [0.0, 0.0], 1.0, "\"C1\" \"_S\" -2 '(20.0 10.0) 0", (20.0, 10.0, -2.0, -2.0, 2.0, 0.0)),
|
||||
("C2", [0.0, 0.0], 1.0, "\"C2\" '(20.0 10.0) \"_XYZ\" -2 \"\" \"\" 0", (20.0, 10.0, -2.0, -2.0, 2.0, 0.0)),
|
||||
("C5", [0.0, 0.0], 1.0, "\"C5\" \"_Z\" -3 '(20.0 10.0) 0", (20.0, 10.0, 1.0, 1.0, 3.0, 0.0)),
|
||||
("C6", [0.0, 0.0], 1.0, "\"C6\" '(20.0 10.0) \"_XYZ\" 2 3 -4 0", (20.0, 10.0, 2.0, 3.0, -4.0, 0.0)),
|
||||
("C7", [0.0, 0.0], 1.0, "\"C7\" \"_X\" -2 '(20.0 10.0) 0", (20.0, 10.0, -2.0, 1.0, 1.0, 0.0)),
|
||||
// Scale and the axis presets in turn: the later one wins for its axes.
|
||||
("C8", [0.0, 0.0], 1.0, "\"C8\" \"_S\" -3 \"_X\" 2 '(20.0 10.0) 0", (20.0, 10.0, 2.0, -3.0, 3.0, 0.0)),
|
||||
("C9", [0.0, 0.0], 1.0, "\"C9\" \"_X\" 2 \"_S\" -3 '(20.0 10.0) 0", (20.0, 10.0, -3.0, -3.0, 3.0, 0.0)),
|
||||
("CA", [0.0, 0.0], 1.0, "\"CA\" \"_S\" 2 \"_Z\" -5 '(20.0 10.0) 0", (20.0, 10.0, 2.0, 2.0, 5.0, 0.0)),
|
||||
// XYZ then a corner (by Corner or straight away) still asks Z, which defaults to X's size.
|
||||
("C3", [0.0, 0.0], 1.0, "\"C3\" '(20.0 10.0) \"_XYZ\" \"_C\" '(26.0 14.0) \"\" 0", (20.0, 10.0, 6.0, 4.0, 6.0, 0.0)),
|
||||
("C4", [0.0, 0.0], 1.0, "\"C4\" '(20.0 10.0) \"_XYZ\" '(14.0 14.0) \"\" 0", (20.0, 10.0, -6.0, 4.0, 6.0, 0.0)),
|
||||
];
|
||||
for (name, base, unit, args, want) in cases {
|
||||
let mut s = Session::new();
|
||||
block(&mut s, name, base, unit);
|
||||
s.cmdline(&format!("(command \"_.-INSERT\" {args})")).unwrap();
|
||||
assert!(s.running.is_none(), "{name}: {}", s.prompt_text());
|
||||
assert_eq!(last_insert(&s), want, "{name}");
|
||||
}
|
||||
// Basepoint is picked on the block dropped at the cursor: with the cursor at (100, 50)
|
||||
// instead of the origin, B7's call inserts 100 and 50 further on.
|
||||
let mut s = Session::new();
|
||||
block(&mut s, "B7", [612345.678, 2398765.432], 1.0);
|
||||
s.cursor = cadcraft_engine::geom::Vec2::new(100.0, 50.0);
|
||||
s.cmdline("(command \"_.-INSERT\" \"B7\" \"_B\" '(612350.678 2398770.432) '(612365.678 2398775.432) 1 \"\" 0)").unwrap();
|
||||
assert_eq!(last_insert(&s), (115.0, 55.0, 1.0, 1.0, 1.0, 0.0));
|
||||
}
|
||||
|
||||
@@ -159,3 +159,16 @@ fn layer_delete_clears_viewport_overrides_for_the_layer() {
|
||||
s.execute("layer.new", &json!({ "name": "C" })).unwrap();
|
||||
assert!(viewport_lists(&s).iter().all(|(f, _)| !f.contains(&"C".to_string())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renaming_a_layer_repoints_viewport_lists() {
|
||||
let mut s = drawing();
|
||||
assert_eq!(viewport_lists(&s), vec![(vec!["A".to_string(), "C".to_string()], vec!["B".to_string()])]);
|
||||
s.execute("rename", &json!({ "table": "layer", "from": "A", "to": "Murs" })).unwrap();
|
||||
assert_eq!(viewport_lists(&s), vec![(vec!["Murs".to_string(), "C".to_string()], vec!["B".to_string()])]);
|
||||
// layer.set's newName form (what the Properties palette sends) goes through the same path.
|
||||
s.execute("layer.set", &json!({ "name": "B", "newName": "Cloisons" })).unwrap();
|
||||
assert_eq!(viewport_lists(&s), vec![(vec!["Murs".to_string(), "C".to_string()], vec!["Cloisons".to_string()])]);
|
||||
s.undo().unwrap();
|
||||
assert_eq!(viewport_lists(&s), vec![(vec!["Murs".to_string(), "C".to_string()], vec!["B".to_string()])], "undo brings the old name back");
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ mod attedit_prompts;
|
||||
mod attrib_modes;
|
||||
mod attrib_props;
|
||||
mod attribute_tools;
|
||||
mod attribute_value_baseline;
|
||||
mod block_description;
|
||||
mod block_keep;
|
||||
mod block_redefine;
|
||||
@@ -18,9 +19,11 @@ mod break_closed_polyline;
|
||||
mod break_join_locked_layers;
|
||||
mod break_keeps_z;
|
||||
mod burst;
|
||||
mod chprop_command;
|
||||
mod compare_export;
|
||||
mod constraint_geometry;
|
||||
mod constraint_infer;
|
||||
mod copy_count;
|
||||
mod count;
|
||||
mod dash_block;
|
||||
mod data_extraction;
|
||||
@@ -85,6 +88,7 @@ mod offset_move;
|
||||
mod options_profile;
|
||||
mod overkill;
|
||||
mod parallel_display_list;
|
||||
mod pedit_multiple;
|
||||
mod pedit_splinedit;
|
||||
mod pick_text;
|
||||
mod pickdrag;
|
||||
@@ -123,6 +127,7 @@ mod snap_nearest;
|
||||
mod snap_overrides;
|
||||
mod snap_tracking;
|
||||
mod snaps_more;
|
||||
mod solid_command;
|
||||
mod space_is_enter;
|
||||
mod spline_degree;
|
||||
mod spline_options;
|
||||
@@ -142,6 +147,7 @@ mod undo_layout_tab;
|
||||
mod viewport_clip;
|
||||
mod vplayer_prompts;
|
||||
mod vports;
|
||||
mod wipeout_grips;
|
||||
mod xdata_follows_entity;
|
||||
mod zoom_keywords;
|
||||
mod zoom_viewport;
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
//! #2182: PEDIT Multiple at the command line, as AutoCAD: select several objects, turn lines and
|
||||
//! arcs into polylines, then each option applies to all of them; Join joins them with each other.
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::doc::EntityKind;
|
||||
use serde_json::json;
|
||||
|
||||
fn polylines(s: &Session) -> Vec<(usize, bool, f64)> {
|
||||
s.doc()
|
||||
.unwrap()
|
||||
.model
|
||||
.iter()
|
||||
.filter_map(|e| if let EntityKind::LwPolyline(p) = &e.kind { Some((p.vertices.len(), p.closed, p.const_width)) } else { None })
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn count(s: &Session) -> usize {
|
||||
s.doc().unwrap().model.len()
|
||||
}
|
||||
|
||||
fn lines(s: &mut Session, pts: &[[f64; 4]]) {
|
||||
for p in pts {
|
||||
s.execute("line", &json!({ "points": [[p[0], p[1]], [p[2], p[3]]] })).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_turns_four_lines_into_one_closed_polyline() {
|
||||
let mut s = Session::new();
|
||||
lines(&mut s, &[[0.0, 0.0, 10.0, 0.0], [10.0, 0.0, 10.0, 5.0], [10.0, 5.0, 0.0, 5.0], [0.0, 5.0, 0.0, 0.0]]);
|
||||
for t in ["PEDIT", "M", "ALL", "", "Y", "J", "", ""] {
|
||||
s.cmdline(t).unwrap();
|
||||
}
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
assert_eq!(polylines(&s), vec![(4, true, 0.0)]);
|
||||
assert_eq!(count(&s), 1);
|
||||
assert!(s.log.iter().any(|l| l.contains("3 segments added to polyline")), "{:?}", s.log);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_bridges_gaps_within_the_fuzz_distance_only() {
|
||||
// Two chains with a 0.5 gap between them.
|
||||
let gap = |s: &mut Session| lines(s, &[[0.0, 0.0, 10.0, 0.0], [10.5, 0.0, 20.0, 0.0]]);
|
||||
let mut s = Session::new();
|
||||
gap(&mut s);
|
||||
for t in ["PE", "M", "ALL", "", "Y", "J", "0", ""] {
|
||||
s.cmdline(t).unwrap();
|
||||
}
|
||||
assert_eq!(polylines(&s).len(), 2, "no fuzz: still two");
|
||||
let mut s = Session::new();
|
||||
gap(&mut s);
|
||||
for t in ["PE", "M", "ALL", "", "Y", "J", "1", ""] {
|
||||
s.cmdline(t).unwrap();
|
||||
}
|
||||
assert_eq!(polylines(&s).len(), 1, "a fuzz of 1 joins them");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn options_apply_to_all_and_undo_takes_one_back() {
|
||||
let mut s = Session::new();
|
||||
s.cmdline("pline 0,0 10,0 10,10").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
s.cmdline("pline 20,0 30,0 30,10").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
for t in ["PEDIT", "M", "ALL", "", "W", "0.5", "C"] {
|
||||
s.cmdline(t).unwrap();
|
||||
}
|
||||
assert_eq!(polylines(&s), vec![(3, true, 0.5), (3, true, 0.5)], "width and close on both");
|
||||
s.cmdline("U").unwrap();
|
||||
assert_eq!(polylines(&s), vec![(3, false, 0.5), (3, false, 0.5)], "Undo opens them again");
|
||||
s.cmdline("").unwrap();
|
||||
assert!(s.running.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lines_left_out_when_not_converted() {
|
||||
let mut s = Session::new();
|
||||
s.cmdline("pline 0,0 10,0 10,10").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
lines(&mut s, &[[20.0, 0.0, 30.0, 0.0]]);
|
||||
for t in ["PEDIT", "M", "ALL", "", "N", "C", ""] {
|
||||
s.cmdline(t).unwrap();
|
||||
}
|
||||
assert_eq!(polylines(&s), vec![(3, true, 0.0)], "only the polyline is edited");
|
||||
assert!(s.doc().unwrap().model.iter().any(|e| matches!(e.kind, EntityKind::Line(_))), "the line stays a line");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn objects_selected_first_go_straight_to_multiple() {
|
||||
let mut s = Session::new();
|
||||
s.cmdline("pline 0,0 10,0 10,10").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
s.cmdline("pline 20,0 30,0 30,10").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
let all: Vec<_> = s.doc().unwrap().model.iter().map(|e| e.handle).collect();
|
||||
s.set_selection(all);
|
||||
for t in ["PEDIT", "W", "0.5", ""] {
|
||||
s.cmdline(t).unwrap();
|
||||
}
|
||||
assert_eq!(polylines(&s), vec![(3, false, 0.5), (3, false, 0.5)], "no second selection asked for");
|
||||
assert!(s.running.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decurve_after_fit_gives_back_the_vertices() {
|
||||
let mut s = Session::new();
|
||||
s.cmdline("pline 0,0 10,0 10,10 20,10").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
s.cmdline("pline 0,20 10,20 10,30").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
for t in ["PEDIT", "M", "ALL", "", "S"] {
|
||||
s.cmdline(t).unwrap();
|
||||
}
|
||||
assert!(polylines(&s).iter().all(|p| p.0 > 4), "Spline adds vertices: {:?}", polylines(&s));
|
||||
s.cmdline("D").unwrap();
|
||||
assert_eq!(polylines(&s), vec![(4, false, 0.0), (3, false, 0.0)], "Decurve gives back each one's vertices");
|
||||
s.cmdline("").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decurve_still_gives_back_the_vertices_after_a_join_that_joins_nothing_or_is_undone() {
|
||||
let mut s = Session::new();
|
||||
s.cmdline("pline 0,0 10,0 10,10 20,10").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
s.cmdline("pline 0,20 10,20 10,30").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
// Join with nothing in reach joins nothing: Decurve still gives back the vertices.
|
||||
for t in ["PEDIT", "M", "ALL", "", "S", "J", "0", "D"] {
|
||||
s.cmdline(t).unwrap();
|
||||
}
|
||||
assert_eq!(polylines(&s), vec![(4, false, 0.0), (3, false, 0.0)], "after a Join that joined nothing");
|
||||
// Spline, a Join that joins them, Undo: Decurve gives back the vertices again.
|
||||
for t in ["S", "J", "100", "U", "D"] {
|
||||
s.cmdline(t).unwrap();
|
||||
}
|
||||
assert_eq!(polylines(&s), vec![(4, false, 0.0), (3, false, 0.0)], "after an undone Join");
|
||||
s.cmdline("").unwrap();
|
||||
}
|
||||
|
||||
fn widths(s: &Session) -> Vec<f64> {
|
||||
s.doc()
|
||||
.unwrap()
|
||||
.model
|
||||
.iter()
|
||||
.filter_map(|e| if let EntityKind::LwPolyline(p) = &e.kind { Some(p) } else { None })
|
||||
.flat_map(|p| p.vertices.iter().flat_map(|v| [v.start_width, v.end_width]))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_asks_again_for_an_answer_that_is_not_yes_or_no() {
|
||||
let mut s = Session::new();
|
||||
lines(&mut s, &[[0.0, 0.0, 10.0, 0.0]]);
|
||||
for t in ["PEDIT", "M", "ALL", ""] {
|
||||
s.cmdline(t).unwrap();
|
||||
}
|
||||
s.cmdline("foo").unwrap();
|
||||
assert!(s.prompt_text().contains("Convert"), "{}", s.prompt_text());
|
||||
assert_eq!(polylines(&s), vec![], "nothing converted on a wrong answer");
|
||||
s.cmdline("Y").unwrap();
|
||||
assert_eq!(polylines(&s).len(), 1);
|
||||
s.cmdline("").unwrap();
|
||||
|
||||
// Single-polyline PEDIT asks again too.
|
||||
lines(&mut s, &[[0.0, 5.0, 10.0, 5.0]]);
|
||||
let l = s.doc().unwrap().model.last().unwrap().handle;
|
||||
s.set_selection(vec![l]);
|
||||
s.cmdline("PEDIT").unwrap();
|
||||
s.cmdline("foo").unwrap();
|
||||
assert!(s.prompt_text().contains("turn it into one"), "{}", s.prompt_text());
|
||||
s.cmdline("N").unwrap();
|
||||
assert!(s.running.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decurve_after_width_keeps_the_new_width() {
|
||||
let mut s = Session::new();
|
||||
for t in ["PLINE", "0,0", "W", "0", "2", "10,0", "10,10", "20,10", ""] {
|
||||
s.cmdline(t).unwrap();
|
||||
}
|
||||
assert!(widths(&s).iter().any(|w| *w > 0.0), "{:?}", widths(&s));
|
||||
for t in ["PEDIT", "M", "ALL", "", "S", "W", "0", "D", ""] {
|
||||
s.cmdline(t).unwrap();
|
||||
}
|
||||
assert!(widths(&s).iter().all(|w| *w == 0.0), "Multiple: {:?}", widths(&s));
|
||||
|
||||
let mut s = Session::new();
|
||||
for t in ["PLINE", "0,0", "W", "0", "2", "10,0", "10,10", "20,10", ""] {
|
||||
s.cmdline(t).unwrap();
|
||||
}
|
||||
let h = s.doc().unwrap().model.last().unwrap().handle;
|
||||
s.set_selection(vec![h]);
|
||||
for t in ["PEDIT", "S", "W", "0", "D", ""] {
|
||||
s.cmdline(t).unwrap();
|
||||
}
|
||||
assert!(widths(&s).iter().all(|w| *w == 0.0), "single: {:?}", widths(&s));
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
//! SOLID (2D solids) as AutoCAD 2024 draws them from AutoLISP's `command` (accoreconsole, every
|
||||
//! solid read back with `entget`).
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::doc::EntityKind;
|
||||
use cadcraft_engine::geom::Vec2;
|
||||
use serde_json::json;
|
||||
|
||||
/// The corners of every solid, in drawing order.
|
||||
fn solids(s: &Session) -> Vec<[Vec2; 4]> {
|
||||
s.doc()
|
||||
.unwrap()
|
||||
.model
|
||||
.iter()
|
||||
.filter_map(|e| match &e.kind {
|
||||
EntityKind::Solid(so) => Some(so.corners.map(|c| Vec2::new(c.x, c.y))),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solid_chains_its_corners_as_autocad() {
|
||||
let p = |x: f64, y: f64| (x, y);
|
||||
// (points, Enters after them, each solid as indexes into the points)
|
||||
let cases: [(Vec<(f64, f64)>, usize, Vec<[usize; 4]>); 7] = [
|
||||
// Enter at the fourth point: a triangle repeating its third corner.
|
||||
(
|
||||
vec![p(19.451648928934738, -21.25585202371201), p(34.61693931374715, -3.839951210926415), p(14.55170247613551, -36.00688291519149)],
|
||||
1,
|
||||
vec![[0, 1, 2, 2]],
|
||||
),
|
||||
// Two solids, the second on the first one's last two corners.
|
||||
(
|
||||
vec![
|
||||
p(612336.9748522255, 2398742.6897319513),
|
||||
p(612351.3507889743, 2398794.3456611084),
|
||||
p(612371.7043989347, 2398771.9517326546),
|
||||
p(612379.1755178814, 2398787.9912080113),
|
||||
p(612308.5250187548, 2398780.014694856),
|
||||
p(612356.1118879627, 2398726.474738389),
|
||||
],
|
||||
1,
|
||||
vec![[0, 1, 2, 3], [2, 3, 4, 5]],
|
||||
),
|
||||
// A solid, then a triangle.
|
||||
(
|
||||
vec![
|
||||
p(15.962585354071173, 3.3526103822549587),
|
||||
p(-0.8095657933516947, 13.123790321338404),
|
||||
p(-19.412229808271626, -27.60123059493223),
|
||||
p(-33.66123919971652, 12.18177290955613),
|
||||
p(14.002713873827737, -8.925735622823005),
|
||||
],
|
||||
1,
|
||||
vec![[0, 1, 2, 3], [2, 3, 4, 4]],
|
||||
),
|
||||
// Three solids, the last a triangle.
|
||||
(
|
||||
vec![
|
||||
p(29.3005193409103, -24.874809106460187),
|
||||
p(-21.460879071052776, 35.356609837704795),
|
||||
p(-31.347336242570112, -21.756568658673665),
|
||||
p(-39.08169761088666, 23.588062542950155),
|
||||
p(23.0763904721503, 10.219805555956789),
|
||||
p(-22.701751810515756, 22.76615204356692),
|
||||
p(4.338066830902321, -37.50391578060281),
|
||||
],
|
||||
1,
|
||||
vec![[0, 1, 2, 3], [2, 3, 4, 5], [4, 5, 6, 6]],
|
||||
),
|
||||
// Enter twice: the triangle, then Enter at the third point ends the command.
|
||||
(
|
||||
vec![p(612325.6793323631, 2398757.396299568), p(612370.1009101832, 2398796.2224137546), p(612378.8199480829, 2398804.1981022176)],
|
||||
2,
|
||||
vec![[0, 1, 2, 2]],
|
||||
),
|
||||
// Corners stay in the order given (a "Z" order makes a rectangle).
|
||||
(vec![p(0.0, 0.0), p(30.0, 0.0), p(0.0, 20.0), p(30.0, 20.0)], 1, vec![[0, 1, 2, 3]]),
|
||||
// Enter after two points: nothing.
|
||||
(vec![p(-34769.59558954922, 81960.0236700466), p(-35927.22173307072, 79264.64372697374)], 1, vec![]),
|
||||
];
|
||||
let same = |a: Vec2, b: (f64, f64)| (a.x - b.0).abs() <= 1e-9 * b.0.abs().max(1.0) && (a.y - b.1).abs() <= 1e-9 * b.1.abs().max(1.0);
|
||||
for (pts, enters, want) in cases {
|
||||
let mut s = Session::new();
|
||||
let args: Vec<String> = pts.iter().map(|(x, y)| format!("'({x:?} {y:?})")).chain(std::iter::repeat_n("\"\"".to_string(), enters)).collect();
|
||||
s.cmdline(&format!("(command \"_.SOLID\" {})", args.join(" "))).unwrap();
|
||||
// One Enter leaves the command at "Specify third point", as AutoCAD's does (its log then
|
||||
// shows the measuring routine ending it).
|
||||
if s.running.is_some() {
|
||||
assert!(s.prompt_text().contains("Specify third point"), "{pts:?}: {}", s.prompt_text());
|
||||
s.cancel();
|
||||
}
|
||||
let got = solids(&s);
|
||||
assert_eq!(got.len(), want.len(), "{pts:?}: {got:?}");
|
||||
for (g, w) in got.iter().zip(&want) {
|
||||
assert!(g.iter().zip(w).all(|(c, &i)| same(*c, pts[i])), "{pts:?}: {got:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solid_json_chains_the_same_way() {
|
||||
let mut s = Session::new();
|
||||
s.execute("solid", &json!({ "points": [[0, 0], [10, 0], [0, 5], [10, 5], [0, 10]] })).unwrap();
|
||||
let got = solids(&s);
|
||||
let v = |x: f64, y: f64| Vec2::new(x, y);
|
||||
assert_eq!(got, vec![[v(0.0, 0.0), v(10.0, 0.0), v(0.0, 5.0), v(10.0, 5.0)], [v(0.0, 5.0), v(10.0, 5.0), v(0.0, 10.0), v(0.0, 10.0)]]);
|
||||
assert!(s.execute("solid", &json!({ "points": [[0, 0], [1, 0]] })).is_err());
|
||||
// The alias.
|
||||
s.cmdline("so 20,0 30,0 20,5").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
assert_eq!(solids(&s).len(), 3);
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
//! WIPEOUT's optional repeated closing point is not a second editable corner (#2256).
|
||||
//! Authored contours keep their grips and edit results through DXF/DWG saves, both via the
|
||||
//! command API and the interactive grip machine, without changing the file's explicit closure.
|
||||
|
||||
use cadcraft_doc::{Drawing, EntityKind, Handle, LwPolyline, Space};
|
||||
use cadcraft_engine::{
|
||||
Input, Session,
|
||||
grips::{GripMode, stretch_grip},
|
||||
};
|
||||
use cadcraft_geom::{PolyVertex, Vec2};
|
||||
use cadcraft_io::{SaveOptions, Version};
|
||||
use serde_json::json;
|
||||
|
||||
fn fixture(explicit: bool, reversed: bool) -> (Drawing, Handle, Vec<Vec2>) {
|
||||
let mut points = vec![Vec2::new(1.0, 2.0), Vec2::new(7.0, 3.0), Vec2::new(4.0, 9.0)];
|
||||
if reversed {
|
||||
points.reverse();
|
||||
}
|
||||
let mut input = points.clone();
|
||||
if explicit {
|
||||
input.push(points[0]);
|
||||
}
|
||||
let mut session = Session::new();
|
||||
let input: Vec<_> = input.iter().map(|p| [p.x, p.y]).collect();
|
||||
let reply = session.execute("wipeout", &json!({"points": input})).unwrap();
|
||||
let handle = Handle::parse_hex(reply["handle"].as_str().unwrap()).unwrap();
|
||||
(session.doc().unwrap().clone(), handle, points)
|
||||
}
|
||||
|
||||
fn points_equal(actual: &[Vec2], expected: &[Vec2]) {
|
||||
assert_eq!(actual.len(), expected.len(), "{actual:?} != {expected:?}");
|
||||
for (a, b) in actual.iter().zip(expected) {
|
||||
assert!(a.near(*b, 1e-12), "{actual:?} != {expected:?}");
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_boundary(drawing: &Drawing, handle: Handle, points: &[Vec2], explicit: bool) {
|
||||
let EntityKind::Wipeout(w) = &drawing.entity(handle).unwrap().kind else { panic!("WIPEOUT lost") };
|
||||
let mut expected = points.to_vec();
|
||||
if explicit {
|
||||
expected.push(points[0]);
|
||||
}
|
||||
points_equal(&w.boundary, &expected);
|
||||
}
|
||||
|
||||
fn move_grip(session: &mut Session, handle: Handle, index: usize, to: Vec2, interactive: bool) {
|
||||
session.set_selection(vec![handle]);
|
||||
if interactive {
|
||||
session.start_grip_edit(&[(handle, index)]).unwrap();
|
||||
session.input(Input::Point(to)).unwrap();
|
||||
assert!(!session.prompting());
|
||||
} else {
|
||||
session.grip_edit(handle, index, to, GripMode::Stretch).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn edit(drawing: Drawing, handle: Handle, points: &[Vec2], explicit: bool, index: usize, interactive: bool) -> Drawing {
|
||||
let mut session = Session::new();
|
||||
session.open_drawing(drawing, "wipeout", None);
|
||||
points_equal(&session.grips_of(handle).unwrap(), points);
|
||||
let to = points[index] + Vec2::new(2.0, 1.0);
|
||||
let mut expected = points.to_vec();
|
||||
expected[index] = to;
|
||||
move_grip(&mut session, handle, index, to, interactive);
|
||||
assert_boundary(session.doc().unwrap(), handle, &expected, explicit);
|
||||
points_equal(&session.grips_of(handle).unwrap(), &expected);
|
||||
session.execute("undo", &json!({})).unwrap();
|
||||
assert_boundary(session.doc().unwrap(), handle, points, explicit);
|
||||
session.execute("redo", &json!({})).unwrap();
|
||||
assert_boundary(session.doc().unwrap(), handle, &expected, explicit);
|
||||
session.doc().unwrap().clone()
|
||||
}
|
||||
|
||||
fn check(format: &str, interactive: bool) {
|
||||
for explicit in [false, true] {
|
||||
for reversed in [false, true] {
|
||||
let (drawing, handle, points) = fixture(explicit, reversed);
|
||||
for index in 0..points.len() {
|
||||
edit(drawing.clone(), handle, &points, explicit, index, interactive);
|
||||
}
|
||||
for version in Version::ALL {
|
||||
for binary in [false, true].into_iter().filter(|binary| !*binary || format == "wipeout.dxf") {
|
||||
let options = SaveOptions { version: Some(version), binary };
|
||||
let mut current = drawing.clone();
|
||||
for _pass in 0..2 {
|
||||
let bytes = cadcraft_io::write_with(¤t, format, &options).unwrap();
|
||||
current = cadcraft_io::read(&bytes, format).unwrap();
|
||||
assert!(current.read_notes.is_empty() && current.skipped_records.is_empty());
|
||||
assert_boundary(¤t, handle, &points, true);
|
||||
for index in 0..points.len() {
|
||||
let edited = edit(current.clone(), handle, &points, true, index, interactive);
|
||||
let mut expected = points.clone();
|
||||
expected[index] += Vec2::new(2.0, 1.0);
|
||||
let bytes = cadcraft_io::write_with(&edited, format, &options).unwrap();
|
||||
let back = cadcraft_io::read(&bytes, format).unwrap();
|
||||
assert_boundary(&back, handle, &expected, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dxf_api_grips_survive_roundtrip() {
|
||||
check("wipeout.dxf", false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dxf_interactive_grips_survive_roundtrip() {
|
||||
check("wipeout.dxf", true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dwg_api_grips_survive_roundtrip() {
|
||||
check("wipeout.dwg", false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dwg_interactive_grips_survive_roundtrip() {
|
||||
check("wipeout.dwg", true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn polyline_closedness_is_not_inferred_from_coincident_endpoints() {
|
||||
for closed in [false, true] {
|
||||
for repeated in [false, true] {
|
||||
let mut points = vec![Vec2::ZERO, Vec2::new(7.0, 1.0), Vec2::new(3.0, 7.0)];
|
||||
if repeated {
|
||||
points.push(points[0]);
|
||||
}
|
||||
let polyline = LwPolyline {
|
||||
vertices: points.iter().copied().map(PolyVertex::new).collect(),
|
||||
closed,
|
||||
const_width: 0.0,
|
||||
elevation: 0.0,
|
||||
plinegen: false,
|
||||
};
|
||||
let kind = EntityKind::LwPolyline(polyline);
|
||||
let segments = if repeated || closed { 3 } else { 2 };
|
||||
assert_eq!(kind.prims().len(), segments);
|
||||
assert_eq!(kind.grips().len(), points.len() + segments);
|
||||
let to = Vec2::new(2.0, 3.0);
|
||||
let EntityKind::LwPolyline(edited) = stretch_grip(&kind, 0, to).unwrap() else { panic!("polyline") };
|
||||
assert_eq!(edited.closed, closed);
|
||||
let mut expected = points.clone();
|
||||
expected[0] = to;
|
||||
assert_eq!(edited.vertices.iter().map(|v| v.p).collect::<Vec<_>>(), expected);
|
||||
|
||||
// WIPEOUT conversion requires the closed flag, even with coincident endpoints.
|
||||
let mut session = Session::new();
|
||||
let handle = session.doc_mut().unwrap().add(&Space::Model, Default::default(), kind).unwrap();
|
||||
let result = session.execute("wipeout", &json!({"handle": handle.hex()}));
|
||||
assert_eq!(result.is_ok(), closed);
|
||||
if let Ok(result) = result {
|
||||
let wipeout = Handle::parse_hex(result["handle"].as_str().unwrap()).unwrap();
|
||||
assert_boundary(session.doc().unwrap(), wipeout, &points[..3], repeated);
|
||||
points_equal(&session.grips_of(wipeout).unwrap(), &points[..3]);
|
||||
} else {
|
||||
assert_eq!(session.doc().unwrap().model.len(), 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coincident_endpoints_remain_separately_editable_after_save() {
|
||||
let original = [Vec2::ZERO, Vec2::new(8.0, 0.0), Vec2::new(8.0, 6.0), Vec2::new(0.0, 6.0)];
|
||||
for explicit in [false, true] {
|
||||
for interactive in [false, true] {
|
||||
for index in [0, 3] {
|
||||
let other = 3 - index;
|
||||
let mut input: Vec<_> = original.iter().map(|p| [p.x, p.y]).collect();
|
||||
if explicit {
|
||||
input.push(input[0]);
|
||||
}
|
||||
let mut session = Session::new();
|
||||
let reply = session.execute("wipeout", &json!({"points": input})).unwrap();
|
||||
let handle = Handle::parse_hex(reply["handle"].as_str().unwrap()).unwrap();
|
||||
let mut coincident = original;
|
||||
coincident[index] = original[other];
|
||||
move_grip(&mut session, handle, index, coincident[index], interactive);
|
||||
points_equal(&session.grips_of(handle).unwrap(), &coincident);
|
||||
assert_boundary(session.doc().unwrap(), handle, &coincident, true);
|
||||
session.undo().unwrap();
|
||||
assert_boundary(session.doc().unwrap(), handle, &original, explicit);
|
||||
session.execute("redo", &json!({})).unwrap();
|
||||
points_equal(&session.grips_of(handle).unwrap(), &coincident);
|
||||
let collapsed = session.doc().unwrap().clone();
|
||||
for version in Version::ALL {
|
||||
for (format, binary) in [("wipeout.dxf", false), ("wipeout.dxf", true), ("wipeout.dwg", false)] {
|
||||
let options = SaveOptions { version: Some(version), binary };
|
||||
let bytes = cadcraft_io::write_with(&collapsed, format, &options).unwrap();
|
||||
let back = cadcraft_io::read(&bytes, format).unwrap();
|
||||
session.open_drawing(back, "coincident corners", None);
|
||||
points_equal(&session.grips_of(handle).unwrap(), &coincident);
|
||||
assert_boundary(session.doc().unwrap(), handle, &coincident, true);
|
||||
let mut separated = coincident;
|
||||
separated[other] = Vec2::new(-2.0, 4.0);
|
||||
move_grip(&mut session, handle, other, separated[other], interactive);
|
||||
points_equal(&session.grips_of(handle).unwrap(), &separated);
|
||||
assert_boundary(session.doc().unwrap(), handle, &separated, true);
|
||||
let bytes = cadcraft_io::write_with(session.doc().unwrap(), format, &options).unwrap();
|
||||
let back = cadcraft_io::read(&bytes, format).unwrap();
|
||||
assert_boundary(&back, handle, &separated, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_hot_grips_keep_the_closing_point_attached() {
|
||||
let (drawing, handle, mut points) = fixture(true, false);
|
||||
let mut session = Session::new();
|
||||
session.open_drawing(drawing, "wipeout", None);
|
||||
session.set_selection(vec![handle]);
|
||||
session.start_grip_edit(&[(handle, 0), (handle, 1)]).unwrap();
|
||||
let delta = Vec2::new(2.0, 1.0);
|
||||
session.input(Input::Point(points[0] + delta)).unwrap();
|
||||
points[0] += delta;
|
||||
points[1] += delta;
|
||||
assert_boundary(session.doc().unwrap(), handle, &points, true);
|
||||
points_equal(&session.grips_of(handle).unwrap(), &points);
|
||||
}
|
||||
@@ -6,16 +6,12 @@ use cadcraft_engine::Session;
|
||||
use cadcraft_engine::doc::{EntityKind, Hatch};
|
||||
use serde_json::json;
|
||||
|
||||
const LIN: &str = "*MYDASH,My dash __ __\nA,.5,-.25\n*MYTEXT,My text --MY--\nA,.5,-.2,[\"MY\",Standard,S=.1,X=-.1,Y=-.05],-.2\n";
|
||||
#[path = "support/temp_dir.rs"]
|
||||
mod temp_dir;
|
||||
|
||||
/// A user file in the test's temp folder; returns its path.
|
||||
fn file(name: &str, text: &str) -> String {
|
||||
let dir = std::env::temp_dir().join(format!("cadcraft-lin-pat-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let p = dir.join(name);
|
||||
std::fs::write(&p, text).unwrap();
|
||||
p.to_string_lossy().into_owned()
|
||||
}
|
||||
use temp_dir::TempDir;
|
||||
|
||||
const LIN: &str = "*MYDASH,My dash __ __\nA,.5,-.25\n*MYTEXT,My text --MY--\nA,.5,-.2,[\"MY\",Standard,S=.1,X=-.1,Y=-.05],-.2\n";
|
||||
|
||||
fn linetype_pattern(s: &Session, name: &str) -> Option<Vec<f64>> {
|
||||
s.doc().unwrap().linetype(name).map(|l| l.pattern.iter().map(|e| e.length).collect())
|
||||
@@ -46,7 +42,8 @@ fn linetype_json_loads_named_linetypes_from_file_text() {
|
||||
|
||||
#[test]
|
||||
fn linetype_json_reads_a_lin_file_by_path() {
|
||||
let path = file("mine.lin", LIN);
|
||||
let dir = TempDir::new("lin-pat");
|
||||
let path = dir.file("mine.lin", LIN);
|
||||
let mut s = Session::new();
|
||||
s.execute("linetype", &json!({ "load": "MYTEXT", "file": path })).unwrap();
|
||||
assert!(linetype_pattern(&s, "MYTEXT").is_some());
|
||||
@@ -58,7 +55,8 @@ fn linetype_json_reads_a_lin_file_by_path() {
|
||||
|
||||
#[test]
|
||||
fn dash_linetype_loads_from_a_file_and_the_library_at_the_command_line() {
|
||||
let path = file("cmdline.lin", LIN);
|
||||
let dir = TempDir::new("lin-pat");
|
||||
let path = dir.file("cmdline.lin", LIN);
|
||||
let mut s = Session::new();
|
||||
s.cmdline("-linetype").unwrap();
|
||||
for line in ["l", "MYDASH,MYTEXT", path.as_str(), "s", "MYTEXT"] {
|
||||
@@ -72,7 +70,7 @@ fn dash_linetype_loads_from_a_file_and_the_library_at_the_command_line() {
|
||||
assert!(s.log.iter().any(|l| l.contains("Linetype MYDASH loaded")), "{:?}", s.log);
|
||||
|
||||
// Already loaded: asked whether to reload, in the file's order.
|
||||
let changed = file("changed.lin", &LIN.replace(".5,-.25", "2,-2"));
|
||||
let changed = dir.file("changed.lin", LIN.replace(".5,-.25", "2,-2"));
|
||||
s.cmdline("-linetype").unwrap();
|
||||
for line in ["l", "*", changed.as_str()] {
|
||||
s.cmdline(line).unwrap();
|
||||
@@ -132,12 +130,16 @@ fn hatch_uses_a_pattern_from_a_pat_file_and_keeps_its_definition() {
|
||||
|
||||
#[test]
|
||||
fn hatch_pattern_prompt_takes_a_pat_file_name() {
|
||||
let path = file("MYSTAIRS.pat", "*MYOTHER\n0,0,0,0,1\n*MYSTAIRS,Stairs\n0,0,0,.5,.5,.5,-.5\n90,.5,0,.5,.5,.5,-.5\n");
|
||||
let dir = TempDir::new("lin-pat");
|
||||
let path = dir.file("MYSTAIRS.pat", "*MYOTHER\n0,0,0,0,1\n*MYSTAIRS,Stairs\n0,0,0,.5,.5,.5,-.5\n90,.5,0,.5,.5,.5,-.5\n");
|
||||
let mut s = Session::new();
|
||||
s.execute("rectang", &json!({ "p1": [0, 0], "p2": [10, 10] })).unwrap();
|
||||
s.cmdline("hatch").unwrap();
|
||||
s.cmdline("p").unwrap();
|
||||
s.cmdline(&path).unwrap();
|
||||
// Its scale and angle follow; Enter keeps them.
|
||||
s.cmdline("").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
// The pattern named like the file is the one used.
|
||||
s.cmdline("5,5").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
@@ -149,9 +151,21 @@ fn hatch_pattern_prompt_takes_a_pat_file_name() {
|
||||
s.cmdline("hatch").unwrap();
|
||||
s.cmdline("p").unwrap();
|
||||
s.cmdline("myother").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
s.cmdline("5,5").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
assert_eq!(hatches(&s).iter().filter(|h| h.pattern == "MYOTHER").count(), 1);
|
||||
|
||||
// A file with no pattern in it, or none at all, asks for the pattern again.
|
||||
let empty = dir.file("EMPTY.pat", ";; no patterns\n");
|
||||
for file in [empty, dir.path_str("MISSING.pat")] {
|
||||
s.cmdline("hatch").unwrap();
|
||||
s.cmdline("p").unwrap();
|
||||
s.cmdline(&file).unwrap();
|
||||
assert!(s.prompt_text().contains("Enter a pattern name"), "{file}: {}", s.prompt_text());
|
||||
s.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
//! Fuzz every registered command with adversarial params: a command must return `Err`, never panic
|
||||
//! or hang. Each call gets a fresh, populated session on its own thread with a deadline, so one bad
|
||||
//! command can't change what the next one sees or wedge the run.
|
||||
//!
|
||||
//! `Session::execute` contains a command's panic and returns an ordinary `Err`, so looking at the
|
||||
//! result can't find one, and neither can `catch_unwind` here. A panic hook counts them instead,
|
||||
//! on whichever thread they happen. `contained_panics_count_and_graceful_errors_do_not` proves it.
|
||||
|
||||
use cadcraft_engine::{Session, command_specs};
|
||||
use serde_json::{Value, json};
|
||||
use std::panic::{AssertUnwindSafe, catch_unwind};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Mutex, Once, PoisonError, mpsc};
|
||||
use std::time::Duration;
|
||||
|
||||
/// A drawing with something to act on, so commands reach their real work: with nothing in it most
|
||||
/// of them stop at "nothing to do", and with no drawing at all they don't run.
|
||||
fn fresh() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("line", &json!({"points": [[0, 0], [10, 0], [10, 10]]})).unwrap();
|
||||
s.execute("circle", &json!({"center": [5, 5], "radius": 2})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
/// Wall-clock budget for one command on the small drawing. Real work takes milliseconds, so this
|
||||
/// only catches hangs and work that grows with a number in the params.
|
||||
const DEADLINE: Duration = Duration::from_secs(4);
|
||||
|
||||
/// Budget for the self-test, which checks how panics are counted: the hook prints a backtrace
|
||||
/// under `RUST_BACKTRACE=1`, and the first symbol lookup can be slow on a loaded runner.
|
||||
const SELF_TEST_DEADLINE: Duration = Duration::from_secs(120);
|
||||
|
||||
/// Panics anywhere in the process, counted by a hook.
|
||||
static PANICS: AtomicUsize = AtomicUsize::new(0);
|
||||
/// One run at a time, so a panic is charged to the run that caused it.
|
||||
static RUN: Mutex<()> = Mutex::new(());
|
||||
|
||||
/// Run `f` on a worker thread. `None` if it took longer than `deadline`, else whether it finished
|
||||
/// without panicking, contained or not (returning `Ok` or a graceful `Err` both count).
|
||||
fn run_watched(deadline: Duration, f: impl FnOnce() + Send + 'static) -> Option<bool> {
|
||||
static HOOK: Once = Once::new();
|
||||
HOOK.call_once(|| {
|
||||
let default = std::panic::take_hook();
|
||||
std::panic::set_hook(Box::new(move |info| {
|
||||
PANICS.fetch_add(1, Ordering::SeqCst);
|
||||
default(info);
|
||||
}));
|
||||
});
|
||||
let _one = RUN.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
let before = PANICS.load(Ordering::SeqCst);
|
||||
let (tx, rx) = mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let r = catch_unwind(AssertUnwindSafe(f));
|
||||
let _ = tx.send(r.is_ok());
|
||||
});
|
||||
rx.recv_timeout(deadline).ok().map(|ok| ok && PANICS.load(Ordering::SeqCst) == before)
|
||||
}
|
||||
|
||||
fn run_once(id: &str, p: &Value) -> Option<bool> {
|
||||
let (id, p) = (id.to_string(), p.clone());
|
||||
run_watched(DEADLINE, move || {
|
||||
let mut s = fresh();
|
||||
let _ = s.execute(&id, &p);
|
||||
})
|
||||
}
|
||||
|
||||
/// `Err` with a reason on a panic or a hang. A real hang reproduces and one-time setup (the first
|
||||
/// font scan) doesn't, so a timeout counts only if a rerun on a fresh session times out too.
|
||||
fn run_guarded(id: &str, p: &Value) -> Result<(), String> {
|
||||
match run_once(id, p).or_else(|| run_once(id, p)) {
|
||||
Some(true) => Ok(()),
|
||||
Some(false) => Err("panicked".into()),
|
||||
None => Err(format!("hung (>{}s, twice)", DEADLINE.as_secs())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Shapes and sizes a hostile script, drawing or agent could send. No param names a real path:
|
||||
/// the hunt must not write files.
|
||||
fn adversarial() -> Vec<Value> {
|
||||
let long = "x".repeat(100_000);
|
||||
vec![
|
||||
json!(null),
|
||||
json!({}),
|
||||
json!([]),
|
||||
json!("x"),
|
||||
json!({"points": [[1e308, -1e308], ["a"]], "handles": ["zz", 99999]}),
|
||||
json!({"points": [], "handles": [], "center": [], "from": [], "to": [], "name": "", "layer": "", "style": "", "text": ""}),
|
||||
json!({"center": [1e308, 1e308], "radius": 1e308, "angle": 1e308, "scale": 1e308, "height": 1e308, "width": 1e308, "distance": 1e308}),
|
||||
json!({"center": [0, 0], "radius": -1, "angle": -1e308, "scale": 0, "height": 0, "width": 0, "distance": -1, "factor": 0}),
|
||||
json!({"count": 1e9, "rows": 1e9, "columns": 1e9, "levels": 1e9, "items": 1e9, "sides": 1e9, "segments": 1e9, "index": 1e9}),
|
||||
json!({"count": -1, "rows": -1, "columns": -1, "levels": -1, "items": -1, "sides": -1, "segments": -1, "index": -1}),
|
||||
json!({"name": long, "layer": long, "text": long, "style": long, "mode": long}),
|
||||
json!({"name": "\u{0}\u{202e}\u{1f4a5}", "text": r"%%%\P{\H0;", "gradient": {"name": 5, "color1": [1], "angle": "x", "centered": "y"}}),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_command_panics_or_hangs_on_adversarial_params() {
|
||||
let params = adversarial();
|
||||
let mut bad: Vec<String> = Vec::new();
|
||||
for spec in command_specs() {
|
||||
for p in ¶ms {
|
||||
if let Err(why) = run_guarded(spec.id, p) {
|
||||
let shown: String = p.to_string().chars().take(120).collect();
|
||||
bad.push(format!("{} [{why}] <- {shown}", spec.id));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(bad.is_empty(), "{} commands panicked or hung:\n{}", bad.len(), bad.join("\n"));
|
||||
}
|
||||
|
||||
/// A panic the code under test contains still fails the run; a graceful `Err` does not. Without
|
||||
/// this the hunt could quietly stop detecting anything.
|
||||
#[test]
|
||||
fn contained_panics_count_and_graceful_errors_do_not() {
|
||||
let contained = run_watched(SELF_TEST_DEADLINE, || {
|
||||
let _ = catch_unwind(|| panic!("contained, as Session::execute does"));
|
||||
});
|
||||
assert_eq!(contained, Some(false));
|
||||
let graceful = run_watched(SELF_TEST_DEADLINE, || {
|
||||
let mut s = fresh();
|
||||
assert!(s.execute("no-such-command", &json!({})).is_err());
|
||||
});
|
||||
assert_eq!(graceful, Some(true));
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
//! Saving over a drawing keeps the previous file as `<name>.bak` (ISAVEBAK, #540), and the
|
||||
//! save itself replaces the file atomically: no temporary files are left behind.
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::cmd::file::{IoHooks, backup_path, looks_complete, looks_like_drawing, save_keeps_current, set_io};
|
||||
use serde_json::json;
|
||||
|
||||
fn hooks() {
|
||||
set_io(IoHooks {
|
||||
read: |b, n| cadcraft_io::read(b, n).map_err(|e| e.to_string()),
|
||||
write: |d, n| cadcraft_io::write(d, n).map_err(|e| e.to_string()),
|
||||
plot: None,
|
||||
});
|
||||
// Saves at a chosen version (a DWG 2000), as the app's.
|
||||
cadcraft_engine::cmd::file::set_write_as(|d, n, o| {
|
||||
cadcraft_io::write_with(d, n, &cadcraft_io::SaveOptions { version: o.version, binary: o.binary }).map_err(|e| e.to_string())
|
||||
});
|
||||
}
|
||||
|
||||
fn temp_dir(tag: &str) -> std::path::PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("cadcraft-bak-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn circles(path: &std::path::Path) -> usize {
|
||||
let d = cadcraft_io::read(&std::fs::read(path).unwrap(), &*path.to_string_lossy()).unwrap();
|
||||
d.model.iter().filter(|e| matches!(e.kind, cadcraft_engine::doc::EntityKind::Circle(_))).count()
|
||||
}
|
||||
|
||||
/// Only the drawing and its backup are in the folder: atomic saves leave no staging files.
|
||||
fn files(dir: &std::path::Path) -> Vec<String> {
|
||||
let mut v: Vec<String> = std::fs::read_dir(dir).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().to_string()).collect();
|
||||
v.sort();
|
||||
v
|
||||
}
|
||||
|
||||
/// ISAVEBAK is process-wide: the tests that save hold this lock, so one turning it off can't
|
||||
/// race another's save. Dropping the guard turns it back on, also when a test fails.
|
||||
static SAVES: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
struct Saving(#[allow(dead_code)] std::sync::MutexGuard<'static, ()>);
|
||||
|
||||
impl Drop for Saving {
|
||||
fn drop(&mut self) {
|
||||
cadcraft_engine::autosave::set_isavebak(true);
|
||||
}
|
||||
}
|
||||
|
||||
fn saving() -> Saving {
|
||||
let g = SAVES.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
cadcraft_engine::autosave::set_isavebak(true);
|
||||
Saving(g)
|
||||
}
|
||||
|
||||
fn add_circle(s: &mut Session, x: f64) {
|
||||
s.execute("circle", &json!({ "center": [x, 0], "radius": 1 })).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_save_keeps_the_previous_file_as_bak() {
|
||||
let _saving = saving();
|
||||
hooks();
|
||||
let dir = temp_dir("rotate");
|
||||
let path = dir.join("Plan.dxf");
|
||||
let p = path.to_string_lossy().to_string();
|
||||
let mut s = Session::new();
|
||||
add_circle(&mut s, 0.0);
|
||||
s.execute("saveas", &json!({ "path": p })).unwrap();
|
||||
assert_eq!(files(&dir), ["Plan.dxf"], "a first save has nothing to back up");
|
||||
|
||||
add_circle(&mut s, 5.0);
|
||||
s.execute("qsave", &json!({})).unwrap();
|
||||
assert_eq!(files(&dir), ["Plan.bak", "Plan.dxf"]);
|
||||
assert_eq!((circles(&path), circles(&backup_path(&path))), (2, 1), "the .bak is the previous save");
|
||||
|
||||
add_circle(&mut s, 10.0);
|
||||
s.execute("qsave", &json!({})).unwrap();
|
||||
assert_eq!((circles(&path), circles(&backup_path(&path))), (3, 2));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_damaged_file_never_replaces_a_good_backup_and_isavebak_0_keeps_none() {
|
||||
let _saving = saving();
|
||||
hooks();
|
||||
let dir = temp_dir("damaged");
|
||||
let path = dir.join("Plan.dxf");
|
||||
let p = path.to_string_lossy().to_string();
|
||||
let mut s = Session::new();
|
||||
add_circle(&mut s, 0.0);
|
||||
s.execute("saveas", &json!({ "path": p })).unwrap();
|
||||
add_circle(&mut s, 5.0);
|
||||
s.execute("qsave", &json!({})).unwrap();
|
||||
assert_eq!(circles(&backup_path(&path)), 1);
|
||||
|
||||
// Another program truncates the drawing; the next save keeps the good backup.
|
||||
std::fs::write(&path, b"").unwrap();
|
||||
add_circle(&mut s, 10.0);
|
||||
s.execute("qsave", &json!({})).unwrap();
|
||||
assert_eq!((circles(&path), circles(&backup_path(&path))), (3, 1));
|
||||
|
||||
// Cut off with a drawing's start kept (half a DXF, a DWG's version tag alone): the backup
|
||||
// stays too.
|
||||
let whole = std::fs::read(&path).unwrap();
|
||||
std::fs::write(&path, &whole[..whole.len() / 2]).unwrap();
|
||||
add_circle(&mut s, 11.0);
|
||||
s.execute("qsave", &json!({})).unwrap();
|
||||
assert_eq!(circles(&backup_path(&path)), 1, "half a DXF");
|
||||
std::fs::write(&path, b"AC1032").unwrap();
|
||||
add_circle(&mut s, 12.0);
|
||||
s.execute("qsave", &json!({})).unwrap();
|
||||
assert_eq!(circles(&backup_path(&path)), 1, "a DWG's tag alone");
|
||||
|
||||
// ISAVEBAK 0: the backup is left as it was.
|
||||
s.execute("setvar", &json!({ "name": "ISAVEBAK", "value": 0 })).unwrap();
|
||||
add_circle(&mut s, 15.0);
|
||||
s.execute("qsave", &json!({})).unwrap();
|
||||
assert_eq!((circles(&path), circles(&backup_path(&path))), (6, 1));
|
||||
assert_eq!(files(&dir), ["Plan.bak", "Plan.dxf"]);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drawings_are_recognised_by_their_start() {
|
||||
assert!(looks_like_drawing(b"AC1032\0\0\0\0\0"));
|
||||
assert!(looks_like_drawing(b" 0\r\nSECTION\r\n 2\r\nHEADER\r\n"));
|
||||
assert!(looks_like_drawing(b"999\ndxfrw\n0\nSECTION\n"));
|
||||
assert!(looks_like_drawing(b"AutoCAD Binary DXF\r\n\x1a\0"));
|
||||
for junk in [&b""[..], b"\0\0\0\0", b"0\nEOF\n", b"<svg>", b"hello"] {
|
||||
assert!(!looks_like_drawing(junk), "{junk:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A private drawing (0600) keeps a private backup: a new .bak, and a .bak already there that
|
||||
/// others could read, both take the drawing's mode.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn the_backup_is_no_more_readable_than_the_drawing() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _saving = saving();
|
||||
hooks();
|
||||
let mode = |p: &std::path::Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777;
|
||||
let dir = temp_dir("mode");
|
||||
let path = dir.join("Plan.dxf");
|
||||
let p = path.to_string_lossy().to_string();
|
||||
let mut s = Session::new();
|
||||
add_circle(&mut s, 0.0);
|
||||
s.execute("saveas", &json!({ "path": p })).unwrap();
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
|
||||
add_circle(&mut s, 5.0);
|
||||
s.execute("qsave", &json!({})).unwrap();
|
||||
assert_eq!(mode(&backup_path(&path)), 0o600, "a new .bak");
|
||||
|
||||
std::fs::set_permissions(backup_path(&path), std::fs::Permissions::from_mode(0o644)).unwrap();
|
||||
add_circle(&mut s, 10.0);
|
||||
s.execute("qsave", &json!({})).unwrap();
|
||||
assert_eq!(mode(&backup_path(&path)), 0o600, "a .bak already there");
|
||||
assert_eq!(circles(&backup_path(&path)), 2);
|
||||
|
||||
// A .bak kept more private than the drawing is never widened.
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
|
||||
std::fs::set_permissions(backup_path(&path), std::fs::Permissions::from_mode(0o600)).unwrap();
|
||||
add_circle(&mut s, 15.0);
|
||||
s.execute("qsave", &json!({})).unwrap();
|
||||
assert_eq!(mode(&backup_path(&path)), 0o600, "a more private .bak");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A symlink planted at the .bak is replaced by the backup, never written through: the file it
|
||||
/// pointed at is left as it was.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_symlink_at_the_backup_is_not_followed() {
|
||||
let _saving = saving();
|
||||
hooks();
|
||||
let dir = temp_dir("link");
|
||||
let path = dir.join("Plan.dxf");
|
||||
let p = path.to_string_lossy().to_string();
|
||||
let other = dir.join("other.txt");
|
||||
std::fs::write(&other, b"someone else's file").unwrap();
|
||||
let mut s = Session::new();
|
||||
add_circle(&mut s, 0.0);
|
||||
s.execute("saveas", &json!({ "path": p })).unwrap();
|
||||
std::os::unix::fs::symlink(&other, backup_path(&path)).unwrap();
|
||||
add_circle(&mut s, 5.0);
|
||||
s.execute("qsave", &json!({})).unwrap();
|
||||
assert_eq!(std::fs::read(&other).unwrap(), b"someone else's file");
|
||||
assert!(std::fs::symlink_metadata(backup_path(&path)).unwrap().is_file(), "the .bak is a file now");
|
||||
assert_eq!(circles(&backup_path(&path)), 1);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A drawing looks whole from its end: an ASCII DXF ends with 0/EOF, a binary one with EOF and
|
||||
/// its NUL, and a DWG is not a few bytes long.
|
||||
#[test]
|
||||
fn whole_drawings_are_told_from_cut_off_ones() {
|
||||
let ascii = b" 0\r\nSECTION\r\n 2\r\nHEADER\r\n 0\r\nENDSEC\r\n 0\r\nEOF\r\n";
|
||||
assert!(looks_complete(ascii, ascii, ascii.len() as u64));
|
||||
let cut = &ascii[..30];
|
||||
assert!(!looks_complete(cut, cut, cut.len() as u64));
|
||||
let mut binary = b"AutoCAD Binary DXF\r\n\x1a\0".to_vec();
|
||||
binary.extend_from_slice(b"\0\0SECTION\0\0\0EOF\0");
|
||||
assert!(looks_complete(&binary, &binary, binary.len() as u64));
|
||||
assert!(!looks_complete(&binary, &binary[..binary.len() - 5], binary.len() as u64 - 5));
|
||||
assert!(!looks_complete(b"AC1032", b"AC1032", 6));
|
||||
assert!(!looks_complete(b"AC1032", b"", 30_000), "a DWG tag without its header is not whole");
|
||||
}
|
||||
|
||||
/// A DWG reaches the end its own header gives: cut off anywhere (not only right after its start),
|
||||
/// in a 2018 and a 2000 file, it doesn't replace the good backup; whole, it does.
|
||||
#[test]
|
||||
fn a_dwg_cut_off_anywhere_keeps_the_good_backup() {
|
||||
let _saving = saving();
|
||||
hooks();
|
||||
for version in [2018, 2000] {
|
||||
let dir = temp_dir(&format!("dwg{version}"));
|
||||
let path = dir.join("Plan.dwg");
|
||||
let p = path.to_string_lossy().to_string();
|
||||
let mut s = Session::new();
|
||||
add_circle(&mut s, 0.0);
|
||||
s.execute("saveas", &json!({ "path": p, "version": version })).unwrap();
|
||||
add_circle(&mut s, 5.0);
|
||||
s.execute("qsave", &json!({})).unwrap();
|
||||
assert_eq!(circles(&backup_path(&path)), 1, "{version}: the first save is backed up");
|
||||
let whole = std::fs::read(&path).unwrap();
|
||||
let head = &whole[..whole.len().min(4096)];
|
||||
assert!(looks_complete(head, &whole[whole.len().saturating_sub(256)..], whole.len() as u64), "{version}: whole");
|
||||
for keep in [whole.len() / 2, whole.len() * 9 / 10, whole.len() - 1] {
|
||||
assert!(!looks_complete(head, &[], keep as u64), "{version}: cut to {keep} of {}", whole.len());
|
||||
}
|
||||
// Another program leaves it cut off at 90%: the next save keeps the good backup.
|
||||
std::fs::write(&path, &whole[..whole.len() * 9 / 10]).unwrap();
|
||||
add_circle(&mut s, 10.0);
|
||||
s.execute("qsave", &json!({})).unwrap();
|
||||
assert_eq!(circles(&backup_path(&path)), 1, "{version}: the cut-off file didn't replace it");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
/// The checks can be wrong about a real file. With no backup yet, a file that doesn't look whole
|
||||
/// is still kept, because the save is about to replace the only copy of it.
|
||||
#[test]
|
||||
fn a_file_that_looks_damaged_is_kept_when_there_is_no_backup_yet() {
|
||||
let _saving = saving();
|
||||
hooks();
|
||||
let dir = temp_dir("first");
|
||||
let path = dir.join("Plan.dxf");
|
||||
let p = path.to_string_lossy().to_string();
|
||||
let mut s = Session::new();
|
||||
add_circle(&mut s, 0.0);
|
||||
s.execute("saveas", &json!({ "path": p })).unwrap();
|
||||
let odd = b"AC1032 but not as this header expects it".to_vec();
|
||||
std::fs::write(&path, &odd).unwrap();
|
||||
add_circle(&mut s, 5.0);
|
||||
s.execute("qsave", &json!({})).unwrap();
|
||||
assert_eq!(std::fs::read(backup_path(&path)).unwrap(), odd);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// What the warning promises and what the save does agree. A 2007 DWG, whose completeness can't
|
||||
/// be told, replaces an older backup; a file known to be cut off doesn't.
|
||||
#[test]
|
||||
fn the_backup_a_save_promises_is_the_one_it_writes() {
|
||||
let _saving = saving();
|
||||
hooks();
|
||||
let dir = temp_dir("promise");
|
||||
let path = dir.join("Plan.dxf");
|
||||
let p = path.to_string_lossy().to_string();
|
||||
let mut s = Session::new();
|
||||
add_circle(&mut s, 0.0);
|
||||
s.execute("saveas", &json!({ "path": p })).unwrap();
|
||||
add_circle(&mut s, 5.0);
|
||||
s.execute("qsave", &json!({})).unwrap();
|
||||
let old = std::fs::read(backup_path(&path)).unwrap();
|
||||
|
||||
let mut dwg2007 = b"AC1021".to_vec();
|
||||
dwg2007.resize(4096, 7);
|
||||
std::fs::write(&path, &dwg2007).unwrap();
|
||||
assert!(save_keeps_current(&path));
|
||||
add_circle(&mut s, 10.0);
|
||||
s.execute("qsave", &json!({})).unwrap();
|
||||
assert_eq!(std::fs::read(backup_path(&path)).unwrap(), dwg2007, "the promise was kept");
|
||||
|
||||
std::fs::write(backup_path(&path), &old).unwrap();
|
||||
std::fs::write(&path, b"AC1032").unwrap();
|
||||
assert!(!save_keeps_current(&path));
|
||||
add_circle(&mut s, 15.0);
|
||||
s.execute("qsave", &json!({})).unwrap();
|
||||
assert_eq!(std::fs::read(backup_path(&path)).unwrap(), old, "cut off: nothing was promised");
|
||||
|
||||
cadcraft_engine::autosave::set_isavebak(false);
|
||||
assert!(!save_keeps_current(&path), "backups off");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
//! A test's own folder in the system temp directory, removed when the test ends, even when an
|
||||
//! assertion fails. Shared by the integration tests (`#[path]`) and the crate's unit tests.
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
/// `cadcraft-<tag>-<pid>-<n>`: the process id keeps parallel test binaries apart and the counter
|
||||
/// keeps the tests of one binary apart, so no two tests ever share (or delete) one folder.
|
||||
pub struct TempDir(PathBuf);
|
||||
|
||||
impl TempDir {
|
||||
pub fn new(tag: &str) -> Self {
|
||||
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||||
let n = NEXT.fetch_add(1, Ordering::Relaxed);
|
||||
let dir = std::env::temp_dir().join(format!("cadcraft-{tag}-{}-{n}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("create the test's temp folder");
|
||||
Self(dir)
|
||||
}
|
||||
|
||||
/// Write `contents` to `name` in the folder; returns the file's path as text.
|
||||
pub fn file(&self, name: &str, contents: impl AsRef<[u8]>) -> String {
|
||||
let p = self.0.join(name);
|
||||
std::fs::write(&p, contents).expect("write a test file");
|
||||
p.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
/// The path of `name` in the folder as text, without creating it.
|
||||
pub fn path_str(&self, name: &str) -> String {
|
||||
self.0.join(name).to_string_lossy().into_owned()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::Deref for TempDir {
|
||||
type Target = Path;
|
||||
fn deref(&self) -> &Path {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TempDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,11 @@ use cadcraft_engine::cmd::file::{IoHooks, set_io};
|
||||
use cadcraft_engine::doc::Drawing;
|
||||
use serde_json::json;
|
||||
|
||||
#[path = "support/temp_dir.rs"]
|
||||
mod temp_dir;
|
||||
|
||||
use temp_dir::TempDir;
|
||||
|
||||
static FILES: Mutex<Vec<(String, Drawing)>> = Mutex::new(Vec::new());
|
||||
|
||||
fn write(d: &Drawing, name: &str) -> Result<Vec<u8>, String> {
|
||||
@@ -27,17 +32,11 @@ fn hooks() {
|
||||
set_io(IoHooks { read, write, plot: None });
|
||||
}
|
||||
|
||||
fn path(name: &str) -> String {
|
||||
let dir = std::env::temp_dir().join(format!("cadcraft-templates-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir.join(name).to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
/// A drawing with an office layer and a title block circle, saved as a template.
|
||||
fn office_template(s: &mut Session, name: &str) -> String {
|
||||
fn office_template(s: &mut Session, dir: &TempDir, name: &str) -> String {
|
||||
s.execute("layer.new", &json!({ "name": "A-WALL", "color": 3 })).unwrap();
|
||||
s.execute("circle", &json!({ "center": [5, 5], "radius": 2 })).unwrap();
|
||||
let p = path(name);
|
||||
let p = dir.path_str(name);
|
||||
s.execute("saveas", &json!({ "path": p })).unwrap();
|
||||
p
|
||||
}
|
||||
@@ -46,7 +45,8 @@ fn office_template(s: &mut Session, name: &str) -> String {
|
||||
fn saveas_dwt_then_new_from_the_template() {
|
||||
hooks();
|
||||
let mut s = Session::new();
|
||||
let dwt = office_template(&mut s, "office.dwt");
|
||||
let dir = TempDir::new("templates");
|
||||
let dwt = office_template(&mut s, &dir, "office.dwt");
|
||||
// A template is saved like a drawing: the format sees the .dwt name, the drawing is saved.
|
||||
assert!(FILES.lock().unwrap().iter().any(|(n, _)| n.ends_with("office.dwt")));
|
||||
let st = s.state().unwrap();
|
||||
@@ -68,7 +68,8 @@ fn saveas_dwt_then_new_from_the_template() {
|
||||
fn qnewtemplate_is_the_default_for_new_and_qnew() {
|
||||
hooks();
|
||||
let mut s = Session::new();
|
||||
let dwt = office_template(&mut s, "default.dwt");
|
||||
let dir = TempDir::new("templates");
|
||||
let dwt = office_template(&mut s, &dir, "default.dwt");
|
||||
let docs = s.docs.len();
|
||||
|
||||
s.execute("setvar", &json!({ "name": "QNEWTEMPLATE", "value": dwt })).unwrap();
|
||||
@@ -99,8 +100,8 @@ fn a_template_that_cannot_be_read_makes_no_drawing() {
|
||||
let docs = s.docs.len();
|
||||
let err = s.execute("new", &json!({ "template": "/no/such/folder/office.dwt" })).unwrap_err().to_string();
|
||||
assert!(err.contains("office.dwt"), "{err}");
|
||||
let junk = path("junk.dwt");
|
||||
std::fs::write(&junk, b"not a drawing at all").unwrap();
|
||||
let dir = TempDir::new("templates");
|
||||
let junk = dir.file("junk.dwt", b"not a drawing at all");
|
||||
assert!(s.execute("new", &json!({ "template": junk })).is_err());
|
||||
assert!(s.execute("new", &json!({ "template": 42 })).is_err());
|
||||
assert_eq!(s.docs.len(), docs);
|
||||
@@ -121,6 +122,7 @@ fn layout_names(d: &Drawing) -> Vec<String> {
|
||||
fn layout_saveas_writes_the_layout_and_the_named_objects_it_uses() {
|
||||
hooks();
|
||||
let mut s = Session::new();
|
||||
let dir = TempDir::new("templates");
|
||||
for name in ["TITLE", "PARTS-LAYER", "UNUSED", "WALLS", "NOTES"] {
|
||||
s.execute("layer.new", &json!({ "name": name })).unwrap();
|
||||
}
|
||||
@@ -138,7 +140,7 @@ fn layout_saveas_writes_the_layout_and_the_named_objects_it_uses() {
|
||||
s.execute("layer.current", &json!({ "name": "0" })).unwrap();
|
||||
let sheet_objects = s.doc().unwrap().layout("Sheet").unwrap().entities.iter().count();
|
||||
|
||||
let p = path("sheet.dwt");
|
||||
let p = dir.path_str("sheet.dwt");
|
||||
let r = s.execute("layout.saveas", &json!({ "path": p })).unwrap();
|
||||
assert_eq!(r["name"], json!("Sheet"), "the current layout by default");
|
||||
assert_eq!(r["path"], json!(p));
|
||||
@@ -175,13 +177,14 @@ fn layout_saveas_writes_the_layout_and_the_named_objects_it_uses() {
|
||||
fn typed_layout_saveas_asks_for_the_layout_and_the_template_file() {
|
||||
hooks();
|
||||
let mut s = Session::new();
|
||||
let dir = TempDir::new("templates");
|
||||
s.cmdline("LAYOUT").unwrap();
|
||||
s.cmdline("sa").unwrap();
|
||||
assert_eq!(s.current_prompt().unwrap().display(), "Enter layout to save to template <Layout1>:");
|
||||
s.cmdline("Layout2").unwrap();
|
||||
assert_eq!(s.current_prompt().unwrap().display(), "Enter template file name <Layout2.dwt>:");
|
||||
// No extension: the template is a .dwt.
|
||||
let p = path("typed");
|
||||
let p = dir.path_str("typed");
|
||||
s.cmdline(&p).unwrap();
|
||||
assert!(s.current_prompt().is_none());
|
||||
let saved = format!("{p}.dwt");
|
||||
@@ -189,11 +192,11 @@ fn typed_layout_saveas_asks_for_the_layout_and_the_template_file() {
|
||||
assert_eq!(layout_names(&written("typed.dwt")), ["Layout2"]);
|
||||
|
||||
// The JSON form of LAYOUT, other file types, a missing path and an unknown layout.
|
||||
let dxf = path("sheet1.dxf");
|
||||
let dxf = dir.path_str("sheet1.dxf");
|
||||
let r = s.execute("layout", &json!({ "option": "saveas", "name": "layout1", "path": dxf })).unwrap();
|
||||
assert_eq!(r["name"], json!("Layout1"));
|
||||
assert_eq!(layout_names(&written("sheet1.dxf")), ["Layout1"]);
|
||||
assert!(s.execute("layout.saveas", &json!({ "path": path("sheet.png") })).is_err());
|
||||
assert!(s.execute("layout.saveas", &json!({ "path": dir.path_str("sheet.png") })).is_err());
|
||||
assert!(s.execute("layout.saveas", &json!({})).is_err());
|
||||
assert!(s.execute("layout.saveas", &json!({ "name": "Nope", "path": path("nope.dwt") })).is_err());
|
||||
assert!(s.execute("layout.saveas", &json!({ "name": "Nope", "path": dir.path_str("nope.dwt") })).is_err());
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! loading with `xref|name` symbols, saving only the reference, reopening, Reload/Unload/Detach/
|
||||
//! Path, Bind and XBIND, overlays, missing files, circular and deeply nested references.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
@@ -12,6 +12,11 @@ use cadcraft_engine::doc::geom::Vec3;
|
||||
use cadcraft_engine::doc::{Block, Circle, Drawing, Entity, EntityKind, Handle, Insert, Layer, Line, Linetype, MAX_BLOCK_DEPTH, XrefStatus};
|
||||
use serde_json::json;
|
||||
|
||||
#[path = "support/temp_dir.rs"]
|
||||
mod temp_dir;
|
||||
|
||||
use temp_dir::TempDir;
|
||||
|
||||
fn hooks() {
|
||||
set_io(IoHooks {
|
||||
read: |b, name| cadcraft_io::read(b, name).map_err(|e| e.to_string()),
|
||||
@@ -20,12 +25,9 @@ fn hooks() {
|
||||
});
|
||||
}
|
||||
|
||||
/// A fresh folder for one test.
|
||||
fn folder(tag: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("cadcraft-xref-{}-{tag}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
/// A fresh folder for one test, removed when the test ends.
|
||||
fn folder(tag: &str) -> TempDir {
|
||||
TempDir::new(&format!("xref-{tag}"))
|
||||
}
|
||||
|
||||
fn save(d: &Drawing, path: &Path) {
|
||||
|
||||
+130
-30
@@ -502,6 +502,42 @@ pub fn baseline_start(font: &TextFont, s: &str, p: &TextParams) -> Vec2 {
|
||||
origin + Vec2::new(local.x * mirror.x, local.y * mirror.y).rotate(rot)
|
||||
}
|
||||
|
||||
/// How far below the baseline a bottom-justified text's alignment point lies: a third of the
|
||||
/// height for the stroke font (as AutoCAD places it with SHX fonts), the face's descent for an
|
||||
/// outline font (#2229).
|
||||
fn bottom_depth(font: &TextFont, height: f64) -> f64 {
|
||||
match font {
|
||||
TextFont::Outline(face) => ttf::descent_ratio(face).map_or(height / 3.0, |r| r * height),
|
||||
TextFont::Stroke => height / 3.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// The vertical middle of shaped text's ink (its strokes and outlines), above the baseline;
|
||||
/// `None` when it draws nothing. A Middle (M) text is centred there: AutoCAD centres it on the
|
||||
/// string's own extents, descenders included, not at half the height (#2229).
|
||||
fn ink_middle(sh: &Shaped) -> Option<f64> {
|
||||
let ys = sh.strokes.iter().flatten().chain(sh.glyphs.iter().flatten().flatten()).map(|q| q.y).filter(|y| y.is_finite());
|
||||
let (lo, hi) = ys.fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), y| (lo.min(y), hi.max(y)));
|
||||
(lo <= hi).then_some((lo + hi) / 2.0)
|
||||
}
|
||||
|
||||
/// The offset of a line's baseline start from its alignment point, for justification `h`/`v`.
|
||||
fn justify_offset(font: &TextFont, sh: &Shaped, height: f64, h: Align, v: VAlign) -> Vec2 {
|
||||
let dx = match h {
|
||||
Align::Left | Align::Aligned | Align::Fit => 0.0,
|
||||
Align::Center | Align::Middle => -sh.width / 2.0,
|
||||
Align::Right => -sh.width,
|
||||
};
|
||||
let dy = match (h, v) {
|
||||
(Align::Middle, _) => -ink_middle(sh).unwrap_or(height / 2.0),
|
||||
(_, VAlign::Baseline) => 0.0,
|
||||
(_, VAlign::Bottom) => bottom_depth(font, height),
|
||||
(_, VAlign::Middle) => -height / 2.0,
|
||||
(_, VAlign::Top) => -height,
|
||||
};
|
||||
Vec2::new(dx, dy)
|
||||
}
|
||||
|
||||
/// How [`place`] puts a line in the world: the point it is placed by, the offset of its baseline
|
||||
/// start from that point (along the text), its rotation and its mirroring (backwards, upside
|
||||
/// down). Aligned and fit text are placed by their insertion point.
|
||||
@@ -512,25 +548,13 @@ fn frame(font: &TextFont, s: &str, p: &TextParams) -> (Vec2, Vec2, f64, Vec2) {
|
||||
let rot = p.align_pt.map_or(rot, |p2| p.insert.angle_to(p2));
|
||||
return (p.insert, Vec2::ZERO, rot, Vec2::new(1.0, 1.0));
|
||||
}
|
||||
let width = shape_line(font, s, height, p.width_factor, p.oblique).width;
|
||||
let sh = shape_line(font, s, height, p.width_factor, p.oblique);
|
||||
let origin = match (p.h, p.v) {
|
||||
(Align::Left, VAlign::Baseline) => p.insert,
|
||||
_ => p.align_pt.unwrap_or(p.insert),
|
||||
};
|
||||
let dx = match p.h {
|
||||
Align::Left | Align::Aligned | Align::Fit => 0.0,
|
||||
Align::Center | Align::Middle => -width / 2.0,
|
||||
Align::Right => -width,
|
||||
};
|
||||
let dy = match (p.h, p.v) {
|
||||
(Align::Middle, _) => -height / 2.0,
|
||||
(_, VAlign::Baseline) => 0.0,
|
||||
(_, VAlign::Bottom) => height / 3.0,
|
||||
(_, VAlign::Middle) => -height / 2.0,
|
||||
(_, VAlign::Top) => -height,
|
||||
};
|
||||
let mirror = Vec2::new(if p.backwards { -1.0 } else { 1.0 }, if p.upside_down { -1.0 } else { 1.0 });
|
||||
(origin, Vec2::new(dx, dy), rot, mirror)
|
||||
(origin, justify_offset(font, &sh, height, p.h, p.v), rot, mirror)
|
||||
}
|
||||
|
||||
/// Place a single-line TEXT in world coordinates with any font. Returns the shaped text and its
|
||||
@@ -563,28 +587,15 @@ pub fn place(font: &TextFont, s: &str, p: &TextParams) -> (Shaped, Bounds2) {
|
||||
(Align::Left, VAlign::Baseline) | (Align::Aligned | Align::Fit, _) => insert,
|
||||
_ => p.align_pt.unwrap_or(insert),
|
||||
};
|
||||
let dx = match p.h {
|
||||
Align::Left | Align::Aligned | Align::Fit => 0.0,
|
||||
Align::Center | Align::Middle => -width / 2.0,
|
||||
Align::Right => -width,
|
||||
};
|
||||
let dy = match (p.h, p.v) {
|
||||
(Align::Middle, _) => -height / 2.0,
|
||||
(_, VAlign::Baseline) => 0.0,
|
||||
(_, VAlign::Bottom) => height / 3.0,
|
||||
(_, VAlign::Middle) => -height / 2.0,
|
||||
(_, VAlign::Top) => -height,
|
||||
};
|
||||
let local = Vec2::new(dx, dy);
|
||||
let local = justify_offset(font, &sh, height, p.h, p.v);
|
||||
let (mx, my) = (if p.backwards { -1.0 } else { 1.0 }, if p.upside_down { -1.0 } else { 1.0 });
|
||||
let xf = |q: Vec2| {
|
||||
let l = q + local;
|
||||
origin + Vec2::new(l.x * mx, l.y * my).rotate(rot)
|
||||
};
|
||||
sh.map(xf);
|
||||
let bb = Bounds2::from_points(
|
||||
[Vec2::new(0.0, -height / 3.0), Vec2::new(width, -height / 3.0), Vec2::new(width, height), Vec2::new(0.0, height)].map(xf),
|
||||
);
|
||||
let depth = bottom_depth(font, height);
|
||||
let bb = Bounds2::from_points([Vec2::new(0.0, -depth), Vec2::new(width, -depth), Vec2::new(width, height), Vec2::new(0.0, height)].map(xf));
|
||||
(sh, bb)
|
||||
}
|
||||
|
||||
@@ -797,3 +808,92 @@ mod ttf_tests {
|
||||
assert!(crate::is_wide('\u{3042}') && crate::is_wide('\u{AC00}') && crate::is_wide('\u{FF21}') && !crate::is_wide('A'));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod justify_tests {
|
||||
use super::*;
|
||||
|
||||
const H: f64 = 2.5;
|
||||
|
||||
/// `s` placed by the alignment point (0, 0) with justification `h`/`v` in `font`.
|
||||
fn placed(font: &TextFont, s: &str, h: Align, v: VAlign) -> (Shaped, Vec2) {
|
||||
let p = TextParams { align_pt: Some(Vec2::ZERO), h, v, ..TextParams::new(Vec2::ZERO, H) };
|
||||
(place(font, s, &p).0, baseline_start(font, s, &p))
|
||||
}
|
||||
|
||||
/// The lowest and highest point of placed text's ink.
|
||||
fn ink_y(sh: &Shaped) -> (f64, f64) {
|
||||
let ys = sh.strokes.iter().flatten().chain(sh.glyphs.iter().flatten().flatten()).map(|q| q.y);
|
||||
ys.fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), y| (lo.min(y), hi.max(y)))
|
||||
}
|
||||
|
||||
/// A Middle (M) text is centred on its own ink, descenders included, as AutoCAD centres it,
|
||||
/// not at half the height: lowercase and descenders move the baseline (#2229).
|
||||
#[test]
|
||||
fn a_middle_text_is_centred_on_its_ink() {
|
||||
let font = TextFont::Stroke;
|
||||
let mut baselines = Vec::new();
|
||||
for s in ["ABC 123", "gjpqy", "Tg 1234 ABC", "ace"] {
|
||||
let (sh, start) = placed(&font, s, Align::Middle, VAlign::Baseline);
|
||||
let (lo, hi) = ink_y(&sh);
|
||||
assert!(((lo + hi) / 2.0).abs() < 1e-9, "{s}: ink {lo}..{hi} is centred on the alignment point");
|
||||
// The insertion point a DWG keeps (group 10) is the baseline start, below the ink's
|
||||
// middle by as much as the unplaced text's ink middle is above its baseline.
|
||||
let (lo0, hi0) = ink_y(&shape_line(&font, s, H, 1.0, 0.0));
|
||||
assert!((start.y + (lo0 + hi0) / 2.0).abs() < 1e-9, "{s}: baseline start {start:?}");
|
||||
baselines.push((s, start.y));
|
||||
}
|
||||
// Capitals and digits span the height, so they stay at half of it.
|
||||
assert!((baselines[0].1 + H / 2.0).abs() < 1e-9, "{baselines:?}");
|
||||
// Descenders, or lowercase alone, bring the ink's middle down, so the baseline sits higher.
|
||||
for (s, y) in &baselines[1..] {
|
||||
if *s != "Tg 1234 ABC" {
|
||||
assert!(*y > -H / 2.0 + 0.1, "{s}: {baselines:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Text that draws nothing has no ink to centre: it keeps half the height.
|
||||
#[test]
|
||||
fn a_middle_text_without_ink_keeps_half_the_height() {
|
||||
let (_, start) = placed(&TextFont::Stroke, " ", Align::Middle, VAlign::Baseline);
|
||||
assert!((start.y + H / 2.0).abs() < 1e-9, "{start:?}");
|
||||
}
|
||||
|
||||
/// With the stroke font (as with AutoCAD's SHX fonts), a bottom-justified text's baseline is
|
||||
/// a third of the height above its alignment point, and Middle Center still uses half.
|
||||
#[test]
|
||||
fn stroke_font_bottom_and_middle_center_are_unchanged() {
|
||||
let font = TextFont::Stroke;
|
||||
for h in [Align::Left, Align::Center, Align::Right] {
|
||||
let (_, start) = placed(&font, "gjpqy", h, VAlign::Bottom);
|
||||
assert!((start.y - H / 3.0).abs() < 1e-9, "{h:?}: {start:?}");
|
||||
}
|
||||
let (_, start) = placed(&font, "gjpqy", Align::Center, VAlign::Middle);
|
||||
assert!((start.y + H / 2.0).abs() < 1e-9, "MC: {start:?}");
|
||||
}
|
||||
|
||||
/// With a TrueType font, a bottom-justified text's alignment point is at the face's descent,
|
||||
/// where its descenders end, as AutoCAD places it (#2229: Arial's is 0.296 of the height).
|
||||
/// Uses the first of these common fonts that is installed (Windows, macOS and Linux each have
|
||||
/// one); skips without any.
|
||||
#[test]
|
||||
fn a_bottom_justified_truetype_text_sits_on_its_descent() {
|
||||
let found = ["Arial", "Helvetica", "DejaVuSans", "Verdana", "LiberationSans-Regular"].into_iter().find_map(|n| ttf::find(n).map(|f| (n, f)));
|
||||
let Some((name, face)) = found else { return };
|
||||
let ratio = ttf::descent_ratio(&face).unwrap();
|
||||
let font = TextFont::Outline(face);
|
||||
let (sh, start) = placed(&font, "gjpqy", Align::Left, VAlign::Bottom);
|
||||
assert!((start.y - ratio * H).abs() < 1e-9, "{name}: {start:?}");
|
||||
if name == "Arial" {
|
||||
assert!((ratio - 0.296).abs() < 0.01, "Arial's descent over cap height: {ratio}");
|
||||
// The descenders reach the alignment point's line: h/3 left them 0.1 above it.
|
||||
let (lo, _) = ink_y(&sh);
|
||||
assert!(lo.abs() < 0.02 * H, "descender bottom {lo} from the alignment point");
|
||||
}
|
||||
// And Middle centres the ink, as with the stroke font.
|
||||
let (sh, _) = placed(&font, "Tg 1234 ABC", Align::Middle, VAlign::Baseline);
|
||||
let (lo, hi) = ink_y(&sh);
|
||||
assert!(((lo + hi) / 2.0).abs() < 1e-9, "{name}: {lo}..{hi}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -709,6 +709,16 @@ impl OutlinePen for Pen {
|
||||
}
|
||||
}
|
||||
|
||||
/// How far below the baseline a face reaches, as a fraction of the text height (which is the
|
||||
/// face's cap height): its descent over its cap height. A bottom-justified text's alignment
|
||||
/// point is there, as AutoCAD places it (#2229). `None` when the face has no usable descent.
|
||||
pub fn descent_ratio(face: &Face) -> Option<f64> {
|
||||
let f = face.font()?;
|
||||
let descent = -f64::from(f.metrics(Size::new(UPEM), LocationRef::default()).descent);
|
||||
let r = descent / cap_height(&f);
|
||||
(r.is_finite() && r > 0.0 && r < 2.0).then_some(r)
|
||||
}
|
||||
|
||||
/// Cap height of a font in font units at 1000 units per em: the OS/2 value, else the height
|
||||
/// of its "H", else an estimate from the ascent.
|
||||
fn cap_height(f: &FontRef) -> f64 {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user