mirror of
https://github.com/storytold/cadcraft.git
synced 2026-11-10 04:19:59 -06:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b4ec8043e | ||
|
|
7400119120 | ||
|
|
258c89fbb0 | ||
|
|
d7ef7da872 | ||
|
|
5caaf73e3b | ||
|
|
b6331c8603 | ||
|
|
931c0c711a | ||
|
|
fa060b0998 | ||
|
|
87f8ce22f6 | ||
|
|
ac886483d1 | ||
|
|
a626b71284 | ||
|
|
81e93f2caf | ||
|
|
f18cdcbcac | ||
|
|
fc9d056576 | ||
|
|
0575a76291 | ||
|
|
e28ed2018a | ||
|
|
add1fd3528 | ||
|
|
c254c51474 | ||
|
|
b0b2aba23e | ||
|
|
7cee28580d | ||
|
|
a83af92eb2 | ||
|
|
4552b35688 | ||
|
|
e60d546ce4 | ||
|
|
42be8540a9 | ||
|
|
a1335895fc | ||
|
|
b6c7a73fa6 | ||
|
|
b35f00847a | ||
|
|
c6e49b177c | ||
|
|
11ce7eb52c | ||
|
|
9b32882065 | ||
|
|
ee715f4636 | ||
|
|
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 |
@@ -62,6 +62,7 @@ jobs:
|
||||
permissions:
|
||||
# The reusable release workflow tags and uploads the draft release.
|
||||
contents: write
|
||||
packages: write
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
|
||||
with:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
CADCraft is a clean-room, open-source, pure-Rust computer-aided design and drafting application targeting Autodesk AutoCAD parity — and superiority (speed, openness, agent control). It runs natively on macOS, Windows, Linux and FreeBSD, and on the web via WASM. Siblings with the same conventions: `../photocraft` (Photoshop-class), `../vectorcraft` (Illustrator), `../filmcraft` (Premiere), `../lightcraft` (Lightroom), `../pdfcraft` (Acrobat), `../effectcraft` (After Effects), `../designcraft` (InDesign).
|
||||
|
||||
Standards and learnings shared across the crafting apps live in `../../craftrules` (or `storytold/craftrules`). Read its `AGENTS.md` at the start of a session, follow its standards, and contribute reusable learnings back there. Never code: repos don't share code.
|
||||
|
||||
## Start every session here
|
||||
1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md` and the relevant `plan/architecture.md` section. Behaviour reference: `plan/autocad/*` (`01-observed-ui.md` holds measured observations of the running reference app; `04-menu-tree.txt` the menu tree by name).
|
||||
2. Library decisions: `plan/adr/0001-geometry-libraries.md` (what we may depend on, what is rejected for licence reasons).
|
||||
@@ -12,13 +10,13 @@ Standards and learnings shared across the crafting apps live in `../../craftrule
|
||||
`plan/` is gitignored (local only).
|
||||
|
||||
## Never crash
|
||||
People trust CADCraft with their drawings; a crash loses their work. **This outranks feature work.** Standard: [`craftrules/standards/never-crash.md`](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md).
|
||||
People trust CADCraft with their drawings; a crash loses their work. **This outranks feature work.**
|
||||
- **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`).
|
||||
- **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,22 +45,24 @@ 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. 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)
|
||||
|
||||
- About ▸ Contributors/Models are compiled into the binary from `contributors/contributors.json`
|
||||
(commit stats; generated, never hand-edit) and `contributors/people.toml` (names people chose for
|
||||
(commit stats; never hand-edit) and `contributors/people.toml` (names people chose for
|
||||
themselves). See `docs/contributors.md`.
|
||||
- **Agents working for a contributor:** when you prepare a PR, check whether your human's GitHub
|
||||
username has a `[people.<username>]` entry in `contributors/people.toml`. If not, ask them once
|
||||
whether they want to be credited by more than their username: a real name, a display name, and/or
|
||||
their public GitHub profile name (`sync_github_name = true`). If yes, add **only their own** entry
|
||||
(copy the template at the top of the file, or run
|
||||
`python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "…" --sync-github-name`)
|
||||
(copy the template at the top of the file)
|
||||
and include it in their PR, committed as them. If no, change nothing: they are credited as
|
||||
`@username` anyway.
|
||||
- Never add, edit, guess or copy anyone else's entry or name (not from git config, commit authors or
|
||||
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.
|
||||
|
||||
+10
-8
@@ -48,14 +48,14 @@ Generated-in-code assets are original and have no file to list:
|
||||
| `docs/images/ui-apartment.png` | CADCraft contributors | screenshot of CADCraft itself (examples/apartment.dxf) | MIT OR Apache-2.0 | Original; no Autodesk UI |
|
||||
| `docs/images/ui-layout.png` | CADCraft contributors | screenshot of CADCraft itself (a layout of examples/apartment.dxf) | MIT OR Apache-2.0 | Original; no Autodesk UI |
|
||||
| `docs/images/ui-bracket.png` | CADCraft contributors | screenshot of CADCraft itself (sample drawing generated in code) | MIT OR Apache-2.0 | Original; no Autodesk UI |
|
||||
| `docs/brand/artcraft-logo-white.png` | ArtCraft Team | storytold/craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
|
||||
| `docs/brand/artcraft-logo-white.svg` | ArtCraft Team | storytold/craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
|
||||
| `docs/brand/artcraft-logo.png` | ArtCraft Team | storytold/craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
|
||||
| `docs/brand/artcraft-logo.svg` | ArtCraft Team | storytold/craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
|
||||
| `docs/brand/artcraft-mark-black.png` | ArtCraft Team | storytold/craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
|
||||
| `docs/brand/artcraft-mark-black.svg` | ArtCraft Team | storytold/craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
|
||||
| `docs/brand/artcraft-mark.png` | ArtCraft Team | storytold/craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
|
||||
| `docs/brand/artcraft-mark.svg` | ArtCraft Team | storytold/craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
|
||||
| `docs/brand/artcraft-logo-white.png` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
|
||||
| `docs/brand/artcraft-logo-white.svg` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
|
||||
| `docs/brand/artcraft-logo.png` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
|
||||
| `docs/brand/artcraft-logo.svg` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
|
||||
| `docs/brand/artcraft-mark-black.png` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
|
||||
| `docs/brand/artcraft-mark-black.svg` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
|
||||
| `docs/brand/artcraft-mark.png` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
|
||||
| `docs/brand/artcraft-mark.svg` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
|
||||
|
||||
## Interface translations
|
||||
|
||||
@@ -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 |
|
||||
@@ -76,3 +77,4 @@ Generated-in-code assets are original and have no file to list:
|
||||
| `crates/ui-egui/src/i18n/hu.tsv` | @Skaywalker | Original Hungarian translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
|
||||
| `crates/ui-egui/src/i18n/tr.tsv` | @ogzhnklvz (AI-assisted) | Original Turkish translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied; written from the English strings, no other catalog used as a source; the running app reviewed by a native speaker |
|
||||
| `crates/ui-egui/src/i18n/el.tsv` | @Camthalion77 with ChatGPT (AI-assisted) | Original Greek translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied; independent native proofreading pending |
|
||||
| `crates/ui-egui/src/i18n/vi.tsv` | @2ez4gcx with Claude Code (AI-assisted) | Original Vietnamese translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied; terminology chosen with a native speaker, full proofreading pending |
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
CADCraft is a clean-room, open-source, pure-Rust computer-aided design and drafting application targeting Autodesk AutoCAD parity — and superiority (speed, openness, agent control). It runs natively on macOS, Windows, Linux and FreeBSD, and on the web via WASM. Siblings with the same conventions: `../photocraft` (Photoshop-class), `../vectorcraft` (Illustrator), `../filmcraft` (Premiere), `../lightcraft` (Lightroom), `../pdfcraft` (Acrobat), `../effectcraft` (After Effects), `../designcraft` (InDesign).
|
||||
|
||||
Standards and learnings shared across the crafting apps live in `../../craftrules` (or `storytold/craftrules`). Read its `AGENTS.md` at the start of a session, follow its standards, and contribute reusable learnings back there. Never code: repos don't share code.
|
||||
|
||||
## Start every session here
|
||||
1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md` and the relevant `plan/architecture.md` section. Behaviour reference: `plan/autocad/*` (`01-observed-ui.md` holds measured observations of the running reference app; `04-menu-tree.txt` the menu tree by name).
|
||||
2. Library decisions: `plan/adr/0001-geometry-libraries.md` (what we may depend on, what is rejected for licence reasons).
|
||||
@@ -12,13 +10,13 @@ Standards and learnings shared across the crafting apps live in `../../craftrule
|
||||
`plan/` is gitignored (local only).
|
||||
|
||||
## Never crash
|
||||
People trust CADCraft with their drawings; a crash loses their work. **This outranks feature work.** Standard: [`craftrules/standards/never-crash.md`](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md).
|
||||
People trust CADCraft with their drawings; a crash loses their work. **This outranks feature work.**
|
||||
- **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`).
|
||||
- **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 +44,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. 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
+20
-19
@@ -614,7 +614,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"cadcraft-engine",
|
||||
"cadcraft-io",
|
||||
@@ -643,7 +643,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-cli"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"cadcraft-engine",
|
||||
"cadcraft-io",
|
||||
@@ -659,14 +659,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-color"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-constraints"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"cadcraft-doc",
|
||||
"cadcraft-geom",
|
||||
@@ -676,7 +676,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-doc"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-geom",
|
||||
@@ -687,14 +687,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-dwg"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"acadrust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-dxf"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"log",
|
||||
@@ -703,7 +703,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-engine"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-constraints",
|
||||
@@ -724,7 +724,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-fonts"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"cadcraft-geom",
|
||||
"harfrust 0.14.0",
|
||||
@@ -734,14 +734,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-geom"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-io"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"acadrust",
|
||||
"cadcraft-color",
|
||||
@@ -762,7 +762,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-mcp"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"cadcraft-engine",
|
||||
"cadcraft-io",
|
||||
@@ -775,7 +775,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-render"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-doc",
|
||||
@@ -789,7 +789,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-ui-egui"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-doc",
|
||||
@@ -815,7 +815,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-web"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
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=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dunce",
|
||||
"egui",
|
||||
"flate2",
|
||||
"getrandom 0.3.4",
|
||||
"image",
|
||||
"log",
|
||||
"plist",
|
||||
"semver",
|
||||
@@ -5893,7 +5894,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
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.16.0"
|
||||
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 = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] }
|
||||
rfd = "0.17"
|
||||
arboard = { version = "3.6", default-features = false }
|
||||
|
||||
|
||||
@@ -377,7 +377,15 @@ stand on its own.
|
||||
| <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/cadcraft/main/assets/app-icon/cadcraft-64.png" alt="" width="32" height="32"> | **CADCraft** | **Computer-aided design and drafting · you are here** | [GitHub](https://github.com/storytold/cadcraft) | [Website](https://getartcraft.com/apps/cadcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/figcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.figcraft.png" alt="" width="32" height="32"> | **FigCraft** | Interface design and prototyping | [GitHub](https://github.com/storytold/figcraft) | [Website](https://getartcraft.com/apps/figcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/wordcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.wordcraft.png" alt="" width="32" height="32"> | **WordCraft** | Word processing and documents | [GitHub](https://github.com/storytold/wordcraft) | [Website](https://getartcraft.com/apps/wordcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/soundcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.soundcraft.png" alt="" width="32" height="32"> | **SoundCraft** | Audio recording and editing | [GitHub](https://github.com/storytold/soundcraft) | [Website](https://getartcraft.com/apps/soundcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/deckcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.deckcraft.png" alt="" width="32" height="32"> | **DeckCraft** | Presentations and slide decks | [GitHub](https://github.com/storytold/deckcraft) | [Website](https://getartcraft.com/apps/deckcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/cadcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.cadcraft.png" alt="" width="32" height="32"> | **CADCraft** | Computer-aided design | [GitHub](https://github.com/storytold/cadcraft) | [Website](https://getartcraft.com/apps/cadcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/gridcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.gridcraft.png" alt="" width="32" height="32"> | **GridCraft** | Spreadsheets and data | [GitHub](https://github.com/storytold/gridcraft) | [Website](https://getartcraft.com/apps/gridcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/texturecraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.texturecraft.png" alt="" width="32" height="32"> | **TextureCraft** | 3D texture painting | [GitHub](https://github.com/storytold/texturecraft) | [Website](https://getartcraft.com/apps/texturecraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/craft-anything/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.craft-anything.png" alt="" width="32" height="32"> | **Craft Anything** | See how any software works, then build your own | [GitHub](https://github.com/storytold/craft-anything) | [Website](https://getartcraft.com/apps/craft-anything) |
|
||||
| <img src="https://raw.githubusercontent.com/tangency-labs/solidcraft/main/assets/icons/app/linux/exports/hicolor/64x64/apps/io.tangencylabs.solidcraft.png" alt="" width="32" height="32"> | **SolidCraft** | Parametric 3D CAD by Tangency Labs (community app) | [GitHub](https://github.com/tangency-labs/solidcraft) | |
|
||||
|
||||
And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video studio for artists who want real control.
|
||||
|
||||
|
||||
+3
-3
@@ -125,8 +125,8 @@ Ranked; detail and the full beta checklist in [docs/roadmap.md](docs/roadmap.md)
|
||||
| 2026-10-12 | HATCHEDIT refuses an explicit bad `scale` or `angle` before it edits anything, so a rejected request leaves the drawing, undo and redo as they were (#724) |
|
||||
| 2026-10-10 | Merged main: Ukrainian interface (#36, first string catalog), Dynamic Input pointer boxes with Tab and relative entry (#60), press-drag selection windows (#63), WIPEOUT and hatch gradients kept through DXF/DWG save (#147, #162), SETVAR DIM* as style overrides (#129), UNITS dialog (#38), close-window save prompt (#47), DIMANGULAR on circles (#20), cargo fmt fixed on main. UI/UX 40% → 43% (dynamic input 15% → 45%), localization 3% → 5%; ready for real work ≈ 37% → 38% (weighted 37.2% → 37.7%), mainstream 27% → 28%, essentials 45% → 46%. Beta is now ~37 points away; hours unchanged within rounding. |
|
||||
| 2026-10-10 | Added mainstream practitioner (≈ 27%) and essentials user (≈ 45%) numbers; ready for real work unchanged at ≈ 37%. User evidence: 2 praise comments, 0 "switched from AutoCAD" reports, 27 of 37 open issues on the core path. |
|
||||
| 2026-10-10 | Stage re-normalized from alpha to **pre-alpha** under the craftrules core-workflow gate: drafting with tracking, blocks, plotting and lossless DWG save fail; dimensioning passes. Alpha is ≈ 125–190 h away. |
|
||||
| 2026-10-10 | Full re-measure against AutoCAD 2027 for Mac in the craftrules progress-docs format: menu breadth 245/491 (50%), ready for real work ≈ 37%, stage alpha. Remaining estimates rose (≈ 570 h → 1,300–2,100 h) on new evidence: behaviour audits found tracking/extension/parallel snaps and dynamic input are not real, saves drop unmodelled content, DWG saves as R2000, and the old estimate had no localization, ecosystem, stability, platform or AI rows. New docs: target-app-parity, gaps, roadmap, architecture, localization, file-format, hardware, UI and geometry parity; `docs/parity.md` became `docs/parity-checklist.md`. |
|
||||
| 2026-10-10 | Stage re-normalized from alpha to **pre-alpha** under the core-workflow gate: drafting with tracking, blocks, plotting and lossless DWG save fail; dimensioning passes. Alpha is ≈ 125–190 h away. |
|
||||
| 2026-10-10 | Full re-measure against AutoCAD 2027 for Mac: menu breadth 245/491 (50%), ready for real work ≈ 37%, stage alpha. Remaining estimates rose (≈ 570 h → 1,300–2,100 h) on new evidence: behaviour audits found tracking/extension/parallel snaps and dynamic input are not real, saves drop unmodelled content, DWG saves as R2000, and the old estimate had no localization, ecosystem, stability, platform or AI rows. New docs: target-app-parity, gaps, roadmap, architecture, localization, file-format, hardware, UI and geometry parity; `docs/parity.md` became `docs/parity-checklist.md`. |
|
||||
| 2026-10-10 | About 60 community PRs merged in a day: DXF fidelity (transparency, LWPOLYLINE elevation, frozen VP layers, dimension text rotation, arc-length dims, non-ASCII escapes), CJK font fallback, Save/Don't Save on close, Light/System themes, F2 history, trackpad pan, Linux XWayland drag-and-drop, correctness fixes across draw/modify/constraints. Release v0.4.0. |
|
||||
| 2026-10-08 | Releases v0.1.0–v0.3.0: Flatpak, AppImage updates, signed macOS universal, Windows x64/x86/arm64, Linux, FreeBSD and web builds. |
|
||||
| 2026-10-07 | Old assessment: menu breadth 233/491 (47%), ≈ 29% weighted parity, 2D drafting ≈ 55%, ≈ 570 h remaining; milestones as reported then: M1 ~85%, M2 ~65%, M3 ~75%, M4 ~50%, M5 ~35%, M6 ~55%, M7 ~55%, M8 ~20%, M9 ~60%, M10 ~70%, M11 0%, M12 ~25%, M13 ~10%. |
|
||||
@@ -144,7 +144,7 @@ Ranked; detail and the full beta checklist in [docs/roadmap.md](docs/roadmap.md)
|
||||
| 2026-10-10 | minor | Readiness-by-audience table with hours to ~95% for each number |
|
||||
| 2026-10-10 | minor | Mainstream practitioner and essentials user headline numbers |
|
||||
| 2026-10-10 | minor | Stage alpha → pre-alpha under the core-workflow gate; banner, why, headline row |
|
||||
| 2026-10-10 | major | Restructured to the progress-docs standard (stage, two numbers, dimensions, features, languages, upcoming, log); full re-measure; milestone detail moved to docs/roadmap.md, parity assessment to docs/target-app-parity.md |
|
||||
| 2026-10-10 | major | Progress docs restructured (stage, two numbers, dimensions, features, languages, upcoming, log); full re-measure; milestone detail moved to docs/roadmap.md, parity assessment to docs/target-app-parity.md |
|
||||
| 2026-10-07 | major | Alpha checklist, milestone table, ≈ 29% weighted parity estimate |
|
||||
|
||||
## Shared UI workspace integration
|
||||
|
||||
@@ -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() {
|
||||
|
||||
+36
-18
@@ -194,6 +194,31 @@ fn filters_for_save(name: &str) -> [(&'static str, &'static str); 6] {
|
||||
filters
|
||||
}
|
||||
|
||||
/// The main window: its size, title bar, placement and rendering needs.
|
||||
fn window_options() -> eframe::NativeOptions {
|
||||
eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_title("CADCraft")
|
||||
.with_inner_size([1600.0, 1000.0])
|
||||
.with_min_inner_size([900.0, 560.0])
|
||||
.with_drag_and_drop(true)
|
||||
.with_fullsize_content_view(true)
|
||||
.with_titlebar_shown(false)
|
||||
.with_title_shown(false)
|
||||
.with_app_id(APP_ID),
|
||||
persist_window: false,
|
||||
// The window opens centred on its screen, not cascaded from the top-left corner (#1129).
|
||||
// Wayland compositors place windows themselves.
|
||||
centered: true,
|
||||
// CADCraft installs its own capped font chain (`theme::install_fonts`); never run
|
||||
// system-font discovery as a hidden fallback.
|
||||
system_font_fallback: false,
|
||||
// A stencil buffer clips the contents of clipped layout viewports on the GPU.
|
||||
stencil_buffer: STENCIL_BITS,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
const APP_ID: &str = "ai.storyteller.cadcraft";
|
||||
|
||||
/// Stencil bits of the window's render pass (see `NativeOptions::stencil_buffer`).
|
||||
@@ -218,7 +243,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);
|
||||
@@ -256,22 +281,7 @@ fn main() -> eframe::Result {
|
||||
// The crosshair is drawn by the app: present without a queue on Linux/BSD, where X11
|
||||
// swapchains hold two frames back under vsync (`CADCRAFT_VSYNC=1` restores vsync).
|
||||
wgpu_options: eframe::egui_wgpu::WgpuConfiguration::default().with_surface_config(surface),
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_title("CADCraft")
|
||||
.with_inner_size([1600.0, 1000.0])
|
||||
.with_min_inner_size([900.0, 560.0])
|
||||
.with_drag_and_drop(true)
|
||||
.with_fullsize_content_view(true)
|
||||
.with_titlebar_shown(false)
|
||||
.with_title_shown(false)
|
||||
.with_app_id(APP_ID),
|
||||
persist_window: false,
|
||||
// CADCraft installs its own capped font chain (`theme::install_fonts`); never run
|
||||
// system-font discovery as a hidden fallback.
|
||||
system_font_fallback: false,
|
||||
// A stencil buffer clips the contents of clipped layout viewports on the GPU.
|
||||
stencil_buffer: STENCIL_BITS,
|
||||
..Default::default()
|
||||
..window_options()
|
||||
};
|
||||
if let Some(icon) = app_icon() {
|
||||
options.viewport = options.viewport.with_icon(icon);
|
||||
@@ -383,7 +393,7 @@ fn main() -> eframe::Result {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{filters_for_save, follow_desktop};
|
||||
use super::{filters_for_save, follow_desktop, window_options};
|
||||
|
||||
#[test]
|
||||
fn save_dialog_offers_dwg_with_the_drawing_format_first() {
|
||||
@@ -401,6 +411,14 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// #1129: the window opens centred, not cascaded from the top-left corner.
|
||||
#[test]
|
||||
fn the_window_opens_centred() {
|
||||
let o = window_options();
|
||||
assert!(o.centered, "centred on launch");
|
||||
assert!(!o.persist_window, "no saved position to restore instead");
|
||||
}
|
||||
|
||||
/// View ▸ Theme ▸ System on Linux: winit reports no theme, so the desktop's answer stands in;
|
||||
/// without one (or where winit answers and the desktop doesn't) the input is left alone.
|
||||
#[test]
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
# Contributor names for the About window (craftrules standards/contributors.md).
|
||||
# Contributor names for the About window.
|
||||
#
|
||||
# Everyone who contributes is credited by GitHub username automatically. Anything more is up to
|
||||
# you: add YOUR OWN entry below, in your own PR (commit it yourself). Never add someone else; a
|
||||
# maintainer may add a person only when that person asked, recording where in consent_source.
|
||||
#
|
||||
# Easiest: python3 ../../craftrules/scripts/contributors.py --add-me . \
|
||||
# [--real-name "Ada Lovelace"] [--display-name "Ada"] [--sync-github-name]
|
||||
#
|
||||
# Or copy this block, replacing your-github-username (every field is optional):
|
||||
# Copy this block, replacing your-github-username (every field is optional):
|
||||
#
|
||||
# [people.your-github-username]
|
||||
# real_name = "Your Name" # shown when the viewer picks "Real name"
|
||||
@@ -91,3 +88,14 @@ 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
|
||||
|
||||
[people.GULERTOLGA]
|
||||
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();
|
||||
@@ -467,6 +465,91 @@ impl EntityKind {
|
||||
}
|
||||
}
|
||||
|
||||
/// Scale every height of the object by `f` about the height `base_z`: the Z of its points,
|
||||
/// its elevation. With [`EntityKind::transform`] scaling about the base point's XY this is a
|
||||
/// uniform 3D scale about the base point, as AutoCAD 2024's SCALE makes it. Sizes `transform`
|
||||
/// already scales (text and helix heights, an insert's Z scale) are left alone; splines,
|
||||
/// regions and wipeouts lie in the XY plane.
|
||||
pub fn scale_heights(&mut self, base_z: f64, f: f64) {
|
||||
let scale = |h: &mut f64| *h = base_z + (*h - base_z) * f;
|
||||
let z = |p: &mut Vec3| scale(&mut p.z);
|
||||
let text_z = |t: &mut Text| {
|
||||
z(&mut t.insert);
|
||||
if let Some(a) = &mut t.align_pt {
|
||||
z(a);
|
||||
}
|
||||
};
|
||||
let attribs_z = |attribs: &mut Vec<Attrib>| {
|
||||
for a in attribs {
|
||||
text_z(&mut a.text);
|
||||
if let Some(ml) = &mut a.multiline {
|
||||
z(&mut ml.mtext.insert);
|
||||
}
|
||||
}
|
||||
};
|
||||
match self {
|
||||
EntityKind::Line(l) => {
|
||||
z(&mut l.a);
|
||||
z(&mut l.b);
|
||||
}
|
||||
EntityKind::Point(p) => z(&mut p.p),
|
||||
EntityKind::Circle(c) => z(&mut c.center),
|
||||
EntityKind::Arc(a) => z(&mut a.center),
|
||||
EntityKind::Ellipse(e) => z(&mut e.center),
|
||||
EntityKind::LwPolyline(p) => scale(&mut p.elevation),
|
||||
EntityKind::Polyline3d(p) => p.points.iter_mut().for_each(z),
|
||||
EntityKind::Helix(h) => z(&mut h.center),
|
||||
EntityKind::Ray(r) | EntityKind::XLine(r) => z(&mut r.base),
|
||||
EntityKind::Text(t) => text_z(t),
|
||||
EntityKind::AttDef(a) => {
|
||||
text_z(&mut a.text);
|
||||
if let Some(ml) = &mut a.multiline {
|
||||
z(&mut ml.mtext.insert);
|
||||
}
|
||||
}
|
||||
EntityKind::MText(t) => z(&mut t.insert),
|
||||
EntityKind::Insert(i) => {
|
||||
z(&mut i.insert);
|
||||
attribs_z(&mut i.attribs);
|
||||
}
|
||||
EntityKind::Dimension(d) => {
|
||||
for p in [&mut d.defpt, &mut d.text_mid, &mut d.p13, &mut d.p14, &mut d.p15, &mut d.p16] {
|
||||
z(p);
|
||||
}
|
||||
if let Some(j) = &mut d.marks.jog_line {
|
||||
z(j);
|
||||
}
|
||||
if let Some(j) = &mut d.marks.jogged {
|
||||
z(&mut j.center);
|
||||
z(&mut j.jog);
|
||||
}
|
||||
d.marks.breaks.iter_mut().for_each(|b| z(&mut b.at));
|
||||
d.block = None;
|
||||
}
|
||||
EntityKind::MLine(ml) => ml.vertices.iter_mut().for_each(|v| z(&mut v.p)),
|
||||
EntityKind::Leader(l) => l.vertices.iter_mut().for_each(z),
|
||||
EntityKind::MLeader(ml) => {
|
||||
ml.leaders.iter_mut().flatten().for_each(z);
|
||||
z(&mut ml.landing);
|
||||
if let Some(t) = &mut ml.text {
|
||||
z(&mut t.insert);
|
||||
}
|
||||
if let Some(b) = &mut ml.block {
|
||||
z(&mut b.insert);
|
||||
attribs_z(&mut b.attribs);
|
||||
}
|
||||
}
|
||||
EntityKind::Hatch(h) => scale(&mut h.elevation),
|
||||
EntityKind::Solid(so) | EntityKind::Trace(so) => so.corners.iter_mut().for_each(z),
|
||||
EntityKind::Face3d(fc) => fc.corners.iter_mut().for_each(z),
|
||||
EntityKind::Viewport(v) => z(&mut v.center),
|
||||
EntityKind::Image(i) => z(&mut i.insert),
|
||||
EntityKind::Table(t) => z(&mut t.insert),
|
||||
EntityKind::Tolerance(t) => z(&mut t.insert),
|
||||
EntityKind::Spline(_) | EntityKind::Region(_) | EntityKind::Wipeout(_) | EntityKind::Unknown(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Grip points (editable defining points), in display order.
|
||||
pub fn grips(&self) -> Vec<Vec2> {
|
||||
// A broken table has a grip for each part.
|
||||
@@ -535,7 +618,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
|
||||
@@ -392,6 +416,13 @@ mod native {
|
||||
writes
|
||||
}
|
||||
|
||||
/// Whether a background write hasn't finished yet. Its file can exist before it finishes
|
||||
/// (the folder is synced after the rename), and until [`Autosaver::reap`] takes it in, the
|
||||
/// drawing is left out of planning.
|
||||
pub fn writing(&self) -> bool {
|
||||
self.running.iter().any(|(_, h)| !h.is_finished())
|
||||
}
|
||||
|
||||
/// Take in the writes that have finished ([`Autosaver::tick`] does this first). A drawing
|
||||
/// whose write failed is no longer counted as autosaved: the next plan restarts its clock,
|
||||
/// so it is tried again SAVETIME later even if it doesn't change.
|
||||
@@ -478,3 +509,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`.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//! Array commands: ARRAY, ARRAYRECT and ARRAYPOLAR (ARRAYPATH lives in `modify2`).
|
||||
//! Array commands: ARRAY, ARRAYRECT, ARRAYPOLAR and the classic -ARRAY (ARRAYPATH lives in
|
||||
//! `modify2`).
|
||||
//!
|
||||
//! Arrays are associative by default (ARRAYASSOCIATIVITY): the source objects move into an
|
||||
//! anonymous source block and the array becomes one block reference to an anonymous array block
|
||||
@@ -21,6 +22,7 @@ use super::{CommandSpec, bad, bool_or, f64_or, find_command, point_param, point_
|
||||
|
||||
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
mod classic;
|
||||
mod edit;
|
||||
|
||||
/// Most preview entities drawn while the array is edited.
|
||||
@@ -48,6 +50,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"{handles?, center, count, angle? (degrees, default 360), rotate?: bool, rows? (1), rowSpacing?, rowElevation?, levels? (1), levelSpacing?, base? ([x, y], default the first object's centre), associative?: bool (default ARRAYASSOCIATIVITY, on)} → {handle (the array, when associative), created (copies)}",
|
||||
)
|
||||
.interactive(|_| Ok(Box::new(PolarM::default()))),
|
||||
CommandSpec::new("-array", "Array", classic::run_classic)
|
||||
.params("{handles?, type?: \"rectangular\" (default) | \"polar\", rows? (1), cols? (1), rowSpacing?, colSpacing?, center, count, angle? (degrees, default 360), rotate?: bool (default true), base? ([x, y], default the DXF group 10 point of the last object)} → {created}: separate copies, as the classic -ARRAY makes them")
|
||||
.interactive(|_| Ok(Box::new(classic::ClassicM::default()))),
|
||||
]
|
||||
.into_iter()
|
||||
.chain(edit::specs())
|
||||
@@ -328,16 +333,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 +368,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);
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
//! -ARRAY: the classic command-line array, as AutoCAD 2024 runs it from AutoLISP's `command`
|
||||
//! (accoreconsole, every copy read back with `entget`). It makes separate copies, never an
|
||||
//! associative array:
|
||||
//! - "Enter the type of array [Rectangular/Polar] <R>": the default is the type used last;
|
||||
//! - Rectangular asks "Enter the number of rows (---) <1>" and "Enter the number of columns (|||)
|
||||
//! <1>", then "Enter the distance between rows or specify unit cell (---)" only for more than one
|
||||
//! row (two corners give both distances) and "Specify the distance between columns (|||)" only for
|
||||
//! more than one column. The copy in row r and column c moves by (c·columns, r·rows): 2 rows and
|
||||
//! 2 columns at −12.57 and −18.01 put the copies below and to the left;
|
||||
//! - Polar asks "Specify center point of array or [Base]", "Enter the number of items in the
|
||||
//! array", "Specify the angle to fill (+=ccw, -=cw) <360>" and "Rotate arrayed objects?
|
||||
//! [Yes/No] <Y>". Items are 360/n apart around a full circle, else fill/(n − 1) apart (6 items in
|
||||
//! 270°: 54°; −90° turns clockwise). Not rotated, each copy moves as the DXF group 10 point of the
|
||||
//! object selected last turns about the centre (a polyline's first vertex, an arc's centre), or
|
||||
//! as the Base point does.
|
||||
|
||||
use cadcraft_doc::Handle;
|
||||
use cadcraft_geom::{Mat3, Vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::super::machines::{SelOutcome, SelectPhase, number};
|
||||
use super::super::{bad, bool_or, f64_or, point_param, point_req, str_param, targets};
|
||||
use super::{KEYWORDS, angle, check_size, copy_all, count, count_or, max_count, other, yes_no};
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
enum Stage {
|
||||
#[default]
|
||||
Select,
|
||||
Kind,
|
||||
Rows,
|
||||
Columns,
|
||||
RowDistance,
|
||||
/// The unit cell's other corner, after the first one.
|
||||
Corner(Vec2),
|
||||
ColumnDistance,
|
||||
Center,
|
||||
Base,
|
||||
Items,
|
||||
Fill,
|
||||
Rotate,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct ClassicM {
|
||||
sel: SelectPhase,
|
||||
objs: Vec<Handle>,
|
||||
stage: Stage,
|
||||
rows: u64,
|
||||
columns: u64,
|
||||
row_distance: f64,
|
||||
center: Vec2,
|
||||
base: Option<Vec2>,
|
||||
items: u64,
|
||||
fill: f64,
|
||||
}
|
||||
|
||||
/// The moves of a rectangular array's copies (the objects themselves stay in row 0, column 0).
|
||||
fn rect_mats(rows: u64, columns: u64, row_distance: f64, column_distance: f64) -> Vec<Mat3> {
|
||||
(0..rows)
|
||||
.flat_map(|r| (0..columns).map(move |c| (r, c)))
|
||||
.filter(|rc| *rc != (0, 0))
|
||||
.map(|(r, c)| Mat3::translate(Vec2::new(c as f64 * column_distance, r as f64 * row_distance)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The transforms of a polar array's copies: `items` around `center` filling `fill` degrees,
|
||||
/// turned with it or moved as `base` turns.
|
||||
fn polar_mats(center: Vec2, items: u64, fill: f64, rotate: bool, base: Vec2) -> Vec<Mat3> {
|
||||
let full = (fill.abs() - 360.0).abs() < 1e-9;
|
||||
let gaps = if full { items } else { items.saturating_sub(1) }.max(1);
|
||||
let step = fill.to_radians() / gaps as f64;
|
||||
(1..items)
|
||||
.map(|k| {
|
||||
let a = step * k as f64;
|
||||
if rotate { Mat3::rotate_about(center, a) } else { Mat3::translate(base.rotate_about(center, a) - base) }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The DXF group 10 point of the last of `hs`, which a polar array that doesn't rotate its copies
|
||||
/// moves around the centre.
|
||||
fn last_point(s: &Session, hs: &[Handle]) -> Option<Vec2> {
|
||||
let d = s.doc().ok()?;
|
||||
hs.last().and_then(|h| d.entity(*h)).and_then(|e| crate::session::group_10(d, e))
|
||||
}
|
||||
|
||||
/// -ARRAY's JSON form.
|
||||
pub(super) fn run_classic(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let hs = targets(s, p)?;
|
||||
let polar = match str_param(p, "type").map(str::to_ascii_lowercase).as_deref() {
|
||||
None | Some("rectangular") => false,
|
||||
Some("polar") => true,
|
||||
Some(_) => return Err(bad("-array", "type must be \"rectangular\" or \"polar\"")),
|
||||
};
|
||||
// The size is checked before the copies' transforms are made.
|
||||
let mats = if polar {
|
||||
let center = point_req("-array", p, "center")?;
|
||||
let items = count_or("-array", p, "count", 1)?;
|
||||
check_size(s, items, hs.len())?;
|
||||
let base = point_param(p, "base").or_else(|| last_point(s, &hs)).unwrap_or(center);
|
||||
polar_mats(center, items, f64_or(p, "angle", 360.0), bool_or(p, "rotate", true), base)
|
||||
} else {
|
||||
let rows = count_or("-array", p, "rows", 1)?;
|
||||
let columns = count_or("-array", p, "cols", 1)?;
|
||||
check_size(s, rows.saturating_mul(columns), hs.len())?;
|
||||
rect_mats(rows, columns, f64_or(p, "rowSpacing", 0.0), f64_or(p, "colSpacing", 0.0))
|
||||
};
|
||||
let created = copy_all(s, &hs, mats.into_iter())?;
|
||||
Ok(json!({ "created": created }))
|
||||
}
|
||||
|
||||
impl ClassicM {
|
||||
/// Make the copies.
|
||||
fn finish(&mut self, s: &mut Session, mats: Vec<Mat3>) -> Result<Step> {
|
||||
check_size(s, mats.len() as u64 + 1, self.objs.len())?;
|
||||
copy_all(s, &self.objs, mats.into_iter())?;
|
||||
s.set_selection(Vec::new());
|
||||
Ok(Step::Done)
|
||||
}
|
||||
|
||||
/// After the rows and columns: the distances still needed, or the copies.
|
||||
fn after_counts(&mut self, s: &mut Session) -> Result<Step> {
|
||||
if self.rows > 1 {
|
||||
self.stage = Stage::RowDistance;
|
||||
} else if self.columns > 1 {
|
||||
self.stage = Stage::ColumnDistance;
|
||||
} else {
|
||||
return self.finish(s, Vec::new());
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
|
||||
fn rect(&mut self, s: &mut Session, column_distance: f64) -> Result<Step> {
|
||||
let mats = rect_mats(self.rows, self.columns, self.row_distance, column_distance);
|
||||
self.finish(s, mats)
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for ClassicM {
|
||||
fn name(&self) -> &'static str {
|
||||
"-ARRAY"
|
||||
}
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
self.sel = SelectPhase::begin(s);
|
||||
if self.sel.done {
|
||||
self.objs = self.sel.picked.clone();
|
||||
self.stage = Stage::Kind;
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
fn prompt(&self, s: &Session) -> Prompt {
|
||||
match self.stage {
|
||||
Stage::Select => self.sel.prompt(),
|
||||
Stage::Kind => Prompt::new("Enter the type of array", KEYWORDS)
|
||||
.kw(&["Rectangular", "Polar"])
|
||||
.default(if s.last_used.classic_array_polar { "P" } else { "R" }),
|
||||
Stage::Rows => Prompt::new("Enter the number of rows (---)", Accept::NUMBER).default("1"),
|
||||
Stage::Columns => Prompt::new("Enter the number of columns (|||)", Accept::NUMBER).default("1"),
|
||||
Stage::RowDistance => Prompt::new("Enter the distance between rows or specify unit cell (---)", Accept::POINT_OR_NUMBER),
|
||||
Stage::Corner(a) => Prompt::new("Specify opposite corner", Accept::POINT).corner(a),
|
||||
Stage::ColumnDistance => Prompt::new("Specify the distance between columns (|||)", Accept::NUMBER),
|
||||
Stage::Center => Prompt::new("Specify center point of array", Accept::POINT).kw(&["Base"]),
|
||||
Stage::Base => Prompt::new("Specify base point of objects", Accept::POINT),
|
||||
Stage::Items => Prompt::new("Enter the number of items in the array", Accept::NUMBER),
|
||||
Stage::Fill => Prompt::new("Specify the angle to fill (+=ccw, -=cw)", Accept::NUMBER).default("360"),
|
||||
Stage::Rotate => Prompt::new("Rotate arrayed objects?", KEYWORDS).kw(&["Yes", "No"]).default("Y"),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
let max = max_count(s, 1, self.objs.len());
|
||||
match (self.stage, i) {
|
||||
(Stage::Select, i) => match self.sel.feed(s, &i)? {
|
||||
SelOutcome::More => {}
|
||||
SelOutcome::Done(hs) => {
|
||||
self.objs = hs;
|
||||
self.stage = Stage::Kind;
|
||||
}
|
||||
SelOutcome::Empty => return Ok(Step::Done),
|
||||
},
|
||||
(Stage::Kind, i) => {
|
||||
let polar = match &i {
|
||||
Input::Enter => s.last_used.classic_array_polar,
|
||||
Input::Keyword(k) | Input::Text(k) => match k.trim().trim_start_matches('_').to_ascii_lowercase().chars().next() {
|
||||
Some('p') => true,
|
||||
Some('r') => false,
|
||||
_ => return Err(other("Invalid option keyword.")),
|
||||
},
|
||||
_ => return Ok(Step::Continue),
|
||||
};
|
||||
s.last_used.classic_array_polar = polar;
|
||||
self.stage = if polar { Stage::Center } else { Stage::Rows };
|
||||
}
|
||||
(Stage::Rows, i) => {
|
||||
if let Some(n) = count(&i, 1, max)? {
|
||||
self.rows = n;
|
||||
self.stage = Stage::Columns;
|
||||
}
|
||||
}
|
||||
// No more columns than MAXARRAY allows with the rows.
|
||||
(Stage::Columns, i) => {
|
||||
if let Some(n) = count(&i, 1, max_count(s, self.rows, self.objs.len()))? {
|
||||
self.columns = n;
|
||||
return self.after_counts(s);
|
||||
}
|
||||
}
|
||||
(Stage::RowDistance, Input::Point(a)) => self.stage = Stage::Corner(a),
|
||||
(Stage::RowDistance, Input::Text(t)) => {
|
||||
self.row_distance = number(&t).filter(|v| v.is_finite()).ok_or_else(|| other("Requires a distance."))?;
|
||||
if self.columns <= 1 {
|
||||
return self.rect(s, 0.0);
|
||||
}
|
||||
self.stage = Stage::ColumnDistance;
|
||||
}
|
||||
// A unit cell gives both distances.
|
||||
(Stage::Corner(a), Input::Point(b)) => {
|
||||
self.row_distance = b.y - a.y;
|
||||
return self.rect(s, b.x - a.x);
|
||||
}
|
||||
(Stage::ColumnDistance, Input::Text(t)) => {
|
||||
let d = number(&t).filter(|v| v.is_finite()).ok_or_else(|| other("Requires a distance."))?;
|
||||
return self.rect(s, d);
|
||||
}
|
||||
(Stage::Center, Input::Point(c)) => {
|
||||
self.center = c;
|
||||
self.stage = Stage::Items;
|
||||
}
|
||||
(Stage::Center, Input::Keyword(k)) if k == "Base" => self.stage = Stage::Base,
|
||||
(Stage::Base, Input::Point(b)) => {
|
||||
self.base = Some(b);
|
||||
self.stage = Stage::Center;
|
||||
}
|
||||
(Stage::Items, i @ Input::Text(_)) => {
|
||||
if let Some(n) = count(&i, 1, max)? {
|
||||
self.items = n;
|
||||
self.stage = Stage::Fill;
|
||||
}
|
||||
}
|
||||
(Stage::Fill, i) => {
|
||||
if let Some(a) = angle(&i, 360.0, &s.angle_settings())? {
|
||||
self.fill = a;
|
||||
self.stage = Stage::Rotate;
|
||||
}
|
||||
}
|
||||
(Stage::Rotate, i) => {
|
||||
let Some(rotate) = yes_no(&i, true) else { return Err(other("Invalid option keyword.")) };
|
||||
let base = self.base.or_else(|| last_point(s, &self.objs)).unwrap_or(self.center);
|
||||
let mats = polar_mats(self.center, self.items, self.fill, rotate, base);
|
||||
return self.finish(s, mats);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
}
|
||||
+160
-22
@@ -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);
|
||||
@@ -351,10 +365,18 @@ fn run_wblock(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = str_param(p, "path").ok_or_else(|| bad("wblock", "`path` is required"))?.to_string();
|
||||
let src = s.doc()?;
|
||||
let mut d = cadcraft_doc::Drawing::new_imperial();
|
||||
// The written drawing keeps the source's settings and styles, as AutoCAD's -WBLOCK does: its
|
||||
// units ($INSUNITS, $MEASUREMENT), so a block from a metric drawing is not declared in inches
|
||||
// and scaled on insert, and its defaults (text size, current styles, annotation scales).
|
||||
d.header = src.header.clone();
|
||||
d.layers = src.layers.clone();
|
||||
d.linetypes = src.linetypes.clone();
|
||||
d.text_styles = src.text_styles.clone();
|
||||
d.dim_styles = src.dim_styles.clone();
|
||||
d.mleader_styles = src.mleader_styles.clone();
|
||||
d.table_styles = src.table_styles.clone();
|
||||
d.mline_styles = src.mline_styles.clone();
|
||||
d.scales = src.scales.clone();
|
||||
d.blocks = src.blocks.clone();
|
||||
// The base point becomes the written drawing's origin, its insertion base: by default 0,0 for
|
||||
// objects and the block's base point for a block (`base` is then in block coordinates).
|
||||
@@ -566,17 +588,23 @@ 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,
|
||||
/// A Verify attribute asked again.
|
||||
Verify,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -587,9 +615,21 @@ 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)>,
|
||||
/// The attributes asked for: tag, prompt, default value, and Verify (asked again).
|
||||
attdefs: Vec<(String, String, String, bool)>,
|
||||
values: serde_json::Map<String, Value>,
|
||||
/// Verify attributes asked again so far.
|
||||
verified: usize,
|
||||
/// `*name`: insert the block's objects (one uniform scale factor, no attribute values).
|
||||
explode: bool,
|
||||
/// The file waiting for the Redefine answer.
|
||||
@@ -606,11 +646,22 @@ impl InsertM {
|
||||
} else {
|
||||
InsStage::Attribs
|
||||
};
|
||||
if self.stage == InsStage::Attribs && self.values.is_empty() && !self.attdefs.is_empty() {
|
||||
s.echo("Enter attribute values");
|
||||
}
|
||||
// Then the Verify attributes are asked again, with the values typed.
|
||||
if self.stage == InsStage::Attribs && self.values.len() >= self.attdefs.len() && self.verified < self.verifying().len() {
|
||||
if self.verified == 0 {
|
||||
s.echo("Verify attribute values");
|
||||
}
|
||||
self.stage = InsStage::Verify;
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
if self.stage == InsStage::Attribs
|
||||
&& 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)?;
|
||||
}
|
||||
@@ -619,20 +670,26 @@ impl InsertM {
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
|
||||
/// Block `name` chosen: collect its attribute prompts and ask for the insertion point.
|
||||
/// The Verify attributes asked for.
|
||||
fn verifying(&self) -> Vec<&(String, String, String, bool)> {
|
||||
self.attdefs.iter().filter(|a| a.3).collect()
|
||||
}
|
||||
|
||||
/// Block `name` chosen: collect its attribute prompts and ask for the insertion point. Constant
|
||||
/// and Preset attributes take their values without asking, and so do all of them with ATTREQ 0.
|
||||
fn choose(&mut self, s: &mut Session, name: &str) -> Result<()> {
|
||||
let Some(b) = s.doc()?.block(name).cloned() else {
|
||||
s.echo(format!("Block \"{name}\" not found."));
|
||||
return Ok(());
|
||||
};
|
||||
self.attdefs = if self.explode {
|
||||
self.attdefs = if self.explode || !s.settings.attreq {
|
||||
Vec::new()
|
||||
} else {
|
||||
b.entities
|
||||
.iter()
|
||||
.filter_map(|e| {
|
||||
if let EntityKind::AttDef(a) = &e.kind {
|
||||
(!a.constant).then(|| (a.tag.clone(), a.prompt.clone(), a.text.value.clone()))
|
||||
(!a.constant && !a.modes.preset).then(|| (a.tag.clone(), a.prompt.clone(), a.text.value.clone(), a.modes.verify))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -656,6 +713,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,20 +760,28 @@ 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();
|
||||
let (_, prompt, default, _) = self.attdefs.get(self.values.len()).cloned().unwrap_or_default();
|
||||
Prompt::new(if prompt.is_empty() { "Enter attribute value".into() } else { prompt }, Accept::TEXT).default(default)
|
||||
}
|
||||
InsStage::Verify => {
|
||||
let (tag, prompt) = self.verifying().get(self.verified).map(|a| (a.0.clone(), a.1.clone())).unwrap_or_default();
|
||||
let typed = self.values.get(&tag).and_then(Value::as_str).unwrap_or_default().to_string();
|
||||
Prompt::new(if prompt.is_empty() { "Enter attribute value".into() } else { prompt }, Accept::TEXT).default(typed)
|
||||
}
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
@@ -742,14 +824,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 +853,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 +887,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 +904,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 +916,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 => {
|
||||
@@ -816,7 +940,7 @@ impl Interactive for InsertM {
|
||||
return self.advance(s);
|
||||
}
|
||||
InsStage::Attribs => {
|
||||
let (tag, _, default) = self.attdefs.get(self.values.len()).cloned().unwrap_or_default();
|
||||
let (tag, _, default, _) = self.attdefs.get(self.values.len()).cloned().unwrap_or_default();
|
||||
let v = match i {
|
||||
Input::Text(t) => t,
|
||||
_ => default,
|
||||
@@ -824,6 +948,20 @@ impl Interactive for InsertM {
|
||||
self.values.insert(tag, Value::String(v));
|
||||
return self.advance(s);
|
||||
}
|
||||
// Enter keeps the value typed.
|
||||
InsStage::Verify => {
|
||||
let tag = self.verifying().get(self.verified).map(|a| a.0.clone()).unwrap_or_default();
|
||||
match i {
|
||||
Input::Text(t) => {
|
||||
self.values.insert(tag, Value::String(t));
|
||||
}
|
||||
Input::Enter => {}
|
||||
// A click or anything else is no answer: the value is asked for again.
|
||||
_ => return Ok(Step::Continue),
|
||||
}
|
||||
self.verified += 1;
|
||||
return self.advance(s);
|
||||
}
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
@@ -832,7 +970,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 +979,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"))
|
||||
|
||||
@@ -2,6 +2,7 @@ use super::{
|
||||
Accept, EntityKind, Input, Interactive, PolyVertex, Prompt, Result, Session, Step, Value, Vec2, added, bad, bool_or, curves, f64_or, lwpoly,
|
||||
number, other, point_param, point_req, rect_with_corners, size_param,
|
||||
};
|
||||
use crate::last_used::RectangModes;
|
||||
|
||||
// =====================================================================================
|
||||
// RECTANG with Area / Dimensions / Rotation / Width / Elevation / Thickness
|
||||
@@ -115,11 +116,58 @@ pub(crate) struct RectM2 {
|
||||
asking: Option<RAsk>,
|
||||
}
|
||||
|
||||
/// A rectangle's rotation as AutoCAD 2024 shows it: degrees from 0 to 360, two decimals.
|
||||
fn rotation_text(rotation: f64) -> String {
|
||||
let t = format!("{:.2}", rotation.to_degrees().rem_euclid(360.0));
|
||||
if t == "360.00" { "0.00".into() } else { t }
|
||||
}
|
||||
|
||||
/// AutoCAD 2024's line listing the RECTANG modes that are set, `None` when none is ("Current
|
||||
/// rectangle modes: Chamfer=2.2518 x 3.7663 Rotation=50.50"): distances in the drawing's
|
||||
/// units, the rotation as [`rotation_text`] shows it.
|
||||
fn modes_line(s: &Session, m: &RectangModes) -> Option<String> {
|
||||
let (u, p) = s.doc().map(|d| (d.header.i64("LUNITS", 2), d.header.i64("LUPREC", 4))).unwrap_or((2, 4));
|
||||
let d = |v: f64| crate::units::format_distance(v, u, p);
|
||||
let mut parts = Vec::new();
|
||||
if m.chamfer.0 > 0.0 || m.chamfer.1 > 0.0 {
|
||||
parts.push(format!("Chamfer={} x {}", d(m.chamfer.0), d(m.chamfer.1)));
|
||||
}
|
||||
if m.fillet > 0.0 {
|
||||
parts.push(format!("Fillet={}", d(m.fillet)));
|
||||
}
|
||||
if m.elevation != 0.0 {
|
||||
parts.push(format!("Elevation={}", d(m.elevation)));
|
||||
}
|
||||
if m.thickness != 0.0 {
|
||||
parts.push(format!("Thickness={}", d(m.thickness)));
|
||||
}
|
||||
if m.width > 0.0 {
|
||||
parts.push(format!("Width={}", d(m.width)));
|
||||
}
|
||||
let rot = rotation_text(m.rotation);
|
||||
if rot != "0.00" {
|
||||
parts.push(format!("Rotation={rot}"));
|
||||
}
|
||||
(!parts.is_empty()).then(|| format!("Current rectangle modes: {}", parts.join(" ")))
|
||||
}
|
||||
|
||||
impl RectM2 {
|
||||
/// The modes the next rectangle starts from.
|
||||
fn modes(&self) -> RectangModes {
|
||||
RectangModes {
|
||||
chamfer: self.chamfer,
|
||||
fillet: self.fillet,
|
||||
width: self.width,
|
||||
elevation: self.elevation,
|
||||
thickness: self.thickness,
|
||||
rotation: self.rotation,
|
||||
}
|
||||
}
|
||||
/// Adds the rectangle of full size `w` × `h` with the collected options, drawing it from
|
||||
/// the centre when centre mode is on, then applies the requested thickness.
|
||||
fn make(&self, s: &mut Session, w: f64, h: f64) -> Result<Step> {
|
||||
let Some(a) = self.first else { return Ok(Step::Cancel) };
|
||||
s.last_used.rectang = self.modes();
|
||||
if w.abs() < 1e-12 || h.abs() < 1e-12 {
|
||||
return Err(other("The rectangle has no area."));
|
||||
}
|
||||
@@ -155,6 +203,20 @@ impl Interactive for RectM2 {
|
||||
fn name(&self) -> &'static str {
|
||||
"RECTANG"
|
||||
}
|
||||
/// Starts from the modes the last rectangle left, and lists the ones that are set.
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
let m = s.last_used.rectang;
|
||||
self.chamfer = m.chamfer;
|
||||
self.fillet = m.fillet;
|
||||
self.width = m.width;
|
||||
self.elevation = m.elevation;
|
||||
self.thickness = m.thickness;
|
||||
self.rotation = m.rotation;
|
||||
if let Some(line) = modes_line(s, &m) {
|
||||
s.echo(line);
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
/// Asks for the pending option's value, or for the first and second points; in centre mode
|
||||
/// the first prompt asks for the centre instead of a corner.
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
@@ -173,9 +235,9 @@ impl Interactive for RectM2 {
|
||||
RAsk::AreaWidth => num("Enter rectangle width", 10.0),
|
||||
RAsk::DimL => num("Specify length for rectangles", self.dims.map(|d| d.0).unwrap_or(10.0)),
|
||||
RAsk::DimW => num("Specify width for rectangles", self.dims.map(|d| d.1).unwrap_or(10.0)),
|
||||
RAsk::Rotation => Prompt::new("Specify rotation angle or", Accept::POINT_OR_NUMBER)
|
||||
RAsk::Rotation => Prompt::new("Specify rotation angle", Accept::POINT_OR_NUMBER)
|
||||
.kw(&["Pick points"])
|
||||
.default(format!("{:.0}", self.rotation.to_degrees()))
|
||||
.default(rotation_text(self.rotation))
|
||||
.base_opt(self.first),
|
||||
RAsk::RotPick1 => Prompt::new("Specify first point", Accept::POINT),
|
||||
RAsk::RotPick2(b) => Prompt::new("Specify second point", Accept::POINT).base(b),
|
||||
@@ -207,8 +269,14 @@ impl Interactive for RectM2 {
|
||||
self.chamfer.1 = self.chamfer.0;
|
||||
self.asking = Some(RAsk::Chamfer2);
|
||||
}
|
||||
RAsk::Chamfer2 => self.chamfer.1 = val(&i, self.chamfer.1)?.max(0.0),
|
||||
RAsk::Fillet => self.fillet = val(&i, self.fillet)?.max(0.0),
|
||||
RAsk::Chamfer2 => {
|
||||
self.chamfer.1 = val(&i, self.chamfer.1)?.max(0.0);
|
||||
self.fillet = 0.0;
|
||||
}
|
||||
RAsk::Fillet => {
|
||||
self.fillet = val(&i, self.fillet)?.max(0.0);
|
||||
self.chamfer = (0.0, 0.0);
|
||||
}
|
||||
RAsk::Width => self.width = val(&i, self.width)?.max(0.0),
|
||||
RAsk::Elevation => self.elevation = val(&i, self.elevation)?,
|
||||
RAsk::Thickness => self.thickness = val(&i, self.thickness)?,
|
||||
@@ -260,6 +328,8 @@ impl Interactive for RectM2 {
|
||||
}
|
||||
}
|
||||
}
|
||||
// A mode is kept as soon as it is set, even if no rectangle follows.
|
||||
s.last_used.rectang = self.modes();
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
match i {
|
||||
|
||||
@@ -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();
|
||||
@@ -611,7 +996,8 @@ fn rectang_options_command_line() {
|
||||
s.cmdline("rectang 0,0 r p 0,0 1,1 d 2 2 5,5").unwrap();
|
||||
let pts = rect_pts(&s);
|
||||
assert!(pts.iter().any(|p| near(*p, 0.0, 8f64.sqrt())), "{pts:?}");
|
||||
s.cmdline("rectang w 0.5 c 0.5 0.5 0,0 4,4").unwrap();
|
||||
// RECTANG keeps the rotation of the rectangle before (45 degrees), as AutoCAD does: back to 0.
|
||||
s.cmdline("rectang w 0.5 c 0.5 0.5 0,0 r 0 4,4").unwrap();
|
||||
match last(&s) {
|
||||
EntityKind::LwPolyline(p) => assert!(p.const_width == 0.5 && p.vertices.len() == 8),
|
||||
k => panic!("{k:?}"),
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
+215
-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,191 @@ fn store(path: &str, bytes: &[u8]) -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The backup of the drawing file `path`, beside it. A DWG's is `<name>.bak`, as AutoCAD names it,
|
||||
/// in the extension's letter case (`plan.DWG` to `plan.BAK`); any other drawing appends `.bak`
|
||||
/// (`plan.dxf.bak`), so `plan.dwg` and `plan.dxf` in one folder never overwrite each other's backup.
|
||||
pub fn backup_path(path: &std::path::Path) -> std::path::PathBuf {
|
||||
match path.extension().map(|e| e.to_string_lossy()) {
|
||||
Some(e) if e.eq_ignore_ascii_case("dwg") => path.with_extension(
|
||||
e.chars().zip("bak".chars()).map(|(d, b)| if d.is_ascii_uppercase() { b.to_ascii_uppercase() } else { b }).collect::<String>(),
|
||||
),
|
||||
_ => {
|
||||
let mut name = path.as_os_str().to_os_string();
|
||||
name.push(".bak");
|
||||
name.into()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 +758,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 +857,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 +884,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 +894,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");
|
||||
|
||||
+101
-18
@@ -15,6 +15,7 @@ use super::{CommandSpec, bad, bool_or, f64_or, point_param, points_param, size_p
|
||||
use crate::{Accept, EngineError, Input, Interactive, Picker, Prompt, Result, Session, Step};
|
||||
|
||||
mod assoc;
|
||||
pub(crate) use assoc::detach;
|
||||
mod edit;
|
||||
mod pick;
|
||||
use super::has_doc;
|
||||
@@ -550,6 +551,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 +565,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 +587,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 +602,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 +628,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 +664,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 +708,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 +753,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 +771,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 +871,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 +887,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();
|
||||
|
||||
@@ -121,7 +121,7 @@ pub(super) fn same_shape(a: &[PolyVertex], b: &[PolyVertex]) -> bool {
|
||||
ba.min.dist(bb.min) <= eps && ba.max.dist(bb.max) <= eps && (shoelace(&pa).abs() - shoelace(&pb).abs()).abs() <= eps * (1.0 + size)
|
||||
}
|
||||
|
||||
pub(super) fn detach(h: &mut Hatch) {
|
||||
pub(crate) fn detach(h: &mut Hatch) {
|
||||
h.associative = false;
|
||||
h.loops.iter_mut().for_each(|l| l.sources.clear());
|
||||
}
|
||||
|
||||
@@ -34,7 +34,9 @@ pub fn polygon_vertices(center: Vec2, sides: usize, radius_pt: Vec2, inscribed:
|
||||
let r = d.len();
|
||||
let a0 = d.angle();
|
||||
let step = cadcraft_geom::TAU / n as f64;
|
||||
let (r, a0) = if inscribed { (r, a0) } else { (r / (step / 2.0).cos(), a0 - step / 2.0) };
|
||||
// Circumscribed: the picked point is an edge's midpoint and the first vertex is the end of
|
||||
// that edge counter-clockwise from it, as AutoCAD 2024 numbers them.
|
||||
let (r, a0) = if inscribed { (r, a0) } else { (r / (step / 2.0).cos(), a0 + step / 2.0) };
|
||||
(0..n).map(|i| PolyVertex::new(Vec2::polar(center, r, a0 + step * i as f64))).collect()
|
||||
}
|
||||
|
||||
@@ -62,43 +64,31 @@ pub fn polygon_from_edge(p1: Vec2, p2: Vec2, sides: usize) -> Vec<PolyVertex> {
|
||||
pts.into_iter().map(PolyVertex::new).collect()
|
||||
}
|
||||
|
||||
/// Rectangle vertices with fillet radius or chamfer distances at every corner.
|
||||
/// Rectangle vertices from corner `a` to the opposite corner `b`, with a fillet radius or
|
||||
/// chamfer distances at every corner, in AutoCAD 2024's order: from `a` along X to `b`'s X, then
|
||||
/// `b`, then back along Y. A chamfer cuts the edge into each corner by its first distance and the
|
||||
/// edge out of it by its second; each fillet arc starts on the edge into its corner. A fillet or
|
||||
/// chamfer that doesn't fit leaves the corners square.
|
||||
pub fn rect_with_corners(a: Vec2, b: Vec2, fillet: f64, chamfer: (f64, f64)) -> Vec<PolyVertex> {
|
||||
let (x0, x1) = (a.x.min(b.x), a.x.max(b.x));
|
||||
let (y0, y1) = (a.y.min(b.y), a.y.max(b.y));
|
||||
let w = x1 - x0;
|
||||
let h = y1 - y0;
|
||||
if fillet > 0.0 && fillet * 2.0 <= w.min(h) + 1e-12 {
|
||||
let r = fillet;
|
||||
let bul = cadcraft_geom::arc_to_bulge(std::f64::consts::FRAC_PI_2);
|
||||
return vec![
|
||||
PolyVertex::new(Vec2::new(x0 + r, y0)),
|
||||
PolyVertex::with_bulge(Vec2::new(x1 - r, y0), bul),
|
||||
PolyVertex::new(Vec2::new(x1, y0 + r)),
|
||||
PolyVertex::with_bulge(Vec2::new(x1, y1 - r), bul),
|
||||
PolyVertex::new(Vec2::new(x1 - r, y1)),
|
||||
PolyVertex::with_bulge(Vec2::new(x0 + r, y1), bul),
|
||||
PolyVertex::new(Vec2::new(x0, y1 - r)),
|
||||
PolyVertex::with_bulge(Vec2::new(x0, y0 + r), bul),
|
||||
];
|
||||
let corners = [a, Vec2::new(b.x, a.y), b, Vec2::new(a.x, b.y)];
|
||||
let short = (b.x - a.x).abs().min((b.y - a.y).abs());
|
||||
let (d_in, d_out, bulge) = if fillet > 0.0 && fillet * 2.0 <= short + 1e-12 {
|
||||
// Counter-clockwise round the corners (a positive bulge) when `b` is up and right of `a`, or
|
||||
// down and left.
|
||||
let sign = if (b.x - a.x) * (b.y - a.y) >= 0.0 { 1.0 } else { -1.0 };
|
||||
(fillet, fillet, sign * cadcraft_geom::arc_to_bulge(std::f64::consts::FRAC_PI_2))
|
||||
} else if chamfer.0 > 0.0 && chamfer.1 > 0.0 && chamfer.0 + chamfer.1 <= short + 1e-12 {
|
||||
(chamfer.0, chamfer.1, 0.0)
|
||||
} else {
|
||||
return corners.into_iter().map(PolyVertex::new).collect();
|
||||
};
|
||||
let mut out = Vec::with_capacity(8);
|
||||
for (i, cur) in corners.iter().enumerate() {
|
||||
let (Some(prev), Some(next)) = (corners.get((i + 3) % 4), corners.get((i + 1) % 4)) else { continue };
|
||||
out.push(PolyVertex::with_bulge(*cur + (*prev - *cur).normalized() * d_in, bulge));
|
||||
out.push(PolyVertex::new(*cur + (*next - *cur).normalized() * d_out));
|
||||
}
|
||||
let (c1, c2) = chamfer;
|
||||
if c1 > 0.0 && c2 > 0.0 && c1 + c1 <= w + 1e-12 && c2 + c2 <= h + 1e-12 {
|
||||
return [
|
||||
Vec2::new(x0 + c1, y0),
|
||||
Vec2::new(x1 - c1, y0),
|
||||
Vec2::new(x1, y0 + c2),
|
||||
Vec2::new(x1, y1 - c2),
|
||||
Vec2::new(x1 - c1, y1),
|
||||
Vec2::new(x0 + c1, y1),
|
||||
Vec2::new(x0, y1 - c2),
|
||||
Vec2::new(x0, y0 + c2),
|
||||
]
|
||||
.into_iter()
|
||||
.map(PolyVertex::new)
|
||||
.collect();
|
||||
}
|
||||
rect_vertices(Vec2::new(x0, y0), Vec2::new(x1, y1))
|
||||
out
|
||||
}
|
||||
|
||||
// ---------- the user's support files ----------
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("scale", "Scale", run_scale)
|
||||
.menu(&["Modify", "Scale"])
|
||||
.alias(&["sc"])
|
||||
.params("{handles?, base: [x,y], factor, copy?: bool}")
|
||||
.params("{handles?, base: [x,y] or [x,y,z] (default Z: ELEVATION), factor, copy?: bool}")
|
||||
.interactive(|_| Ok(Box::new(SelectThen::new(Op::Scale)))),
|
||||
CommandSpec::new("mirror", "Mirror", run_mirror)
|
||||
.menu(&["Modify", "Mirror"])
|
||||
|
||||
@@ -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"))?;
|
||||
@@ -209,12 +212,35 @@ pub(super) fn run_offset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "handle": nh.hex() }))
|
||||
}
|
||||
|
||||
/// The copy at the source's height, as AutoCAD 2024's OFFSET makes it: a line's end Zs, an arc's
|
||||
/// or circle's centre Z, a polyline's elevation (an ellipse's offset, a polyline, at its centre Z).
|
||||
fn keep_z(src: &EntityKind, k: &mut EntityKind) {
|
||||
match (src, k) {
|
||||
(EntityKind::Line(s), EntityKind::Line(l)) => {
|
||||
l.a.z = s.a.z;
|
||||
l.b.z = s.b.z;
|
||||
}
|
||||
(EntityKind::Circle(s), EntityKind::Circle(c)) => c.center.z = s.center.z,
|
||||
(EntityKind::Arc(s), EntityKind::Arc(a)) => a.center.z = s.center.z,
|
||||
(EntityKind::LwPolyline(s), EntityKind::LwPolyline(p)) => p.elevation = s.elevation,
|
||||
(EntityKind::Ellipse(s), EntityKind::LwPolyline(p)) => p.elevation = s.center.z,
|
||||
(EntityKind::Ray(s) | EntityKind::XLine(s), EntityKind::Ray(r) | EntityKind::XLine(r)) => r.base.z = s.base.z,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Add the offset copy `k` of `src` (on the current layer instead of the source's when
|
||||
/// `current_layer`), then erase the source when `erase` and its layer is not locked.
|
||||
fn add_offset(s: &mut Session, src: &Entity, k: EntityKind, current_layer: bool, erase: bool) -> Result<Handle> {
|
||||
/// `current_layer`), then erase the source when `erase` and its layer is not locked. The copy is
|
||||
/// at the source's height ([`keep_z`]); a circle's or arc's copy has no thickness, a polyline's
|
||||
/// keeps it, as AutoCAD 2024 makes them.
|
||||
fn add_offset(s: &mut Session, src: &Entity, mut k: EntityKind, current_layer: bool, erase: bool) -> Result<Handle> {
|
||||
keep_z(&src.kind, &mut k);
|
||||
let space = s.space();
|
||||
let d = s.doc_mut()?;
|
||||
let mut common = src.common.clone();
|
||||
if matches!(k, EntityKind::Circle(_) | EntityKind::Arc(_)) {
|
||||
common.thickness = 0.0;
|
||||
}
|
||||
if current_layer {
|
||||
common.layer = d.header.str("CLAYER", "0");
|
||||
}
|
||||
@@ -442,7 +468,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]);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::break_join::join;
|
||||
use super::explode::explode;
|
||||
use super::transform::{erase, transform_entities};
|
||||
use super::transform::{at_elevation, erase, scale_entities, transform_entities};
|
||||
use super::{
|
||||
Accept, EngineError, EntityKind, Handle, Input, Interactive, Mat3, Prompt, Result, SelOutcome, SelectPhase, Session, Step, Vec2, line, number,
|
||||
};
|
||||
@@ -242,7 +242,7 @@ impl SelectThen {
|
||||
let f = require_length(Some(f))?;
|
||||
// The next SCALE offers this factor.
|
||||
s.last_used.scale_factor = f;
|
||||
transform_entities(s, &self.objs, &Mat3::scale_about(base, f), self.copy_mode)?;
|
||||
scale_entities(s, &self.objs, at_elevation(s, base), f, self.copy_mode)?;
|
||||
s.set_selection(Vec::new());
|
||||
Ok(Step::Done)
|
||||
}
|
||||
@@ -439,13 +439,13 @@ impl Interactive for SelectThen {
|
||||
};
|
||||
if f > 1e-12 && f.is_finite() {
|
||||
s.last_used.scale_factor = f;
|
||||
transform_entities(s, &self.objs, &Mat3::scale_about(self.pts[0], f), self.copy_mode)?;
|
||||
scale_entities(s, &self.objs, at_elevation(s, self.pts[0]), f, self.copy_mode)?;
|
||||
}
|
||||
s.set_selection(Vec::new());
|
||||
Ok(Step::Done)
|
||||
}
|
||||
(Op::Mirror, 1, Input::Point(p)) => {
|
||||
if p.near(self.pts[0], 1e-12) {
|
||||
if p.near(self.pts[0], cadcraft_geom::EPS) {
|
||||
return Err(EngineError::Other("Mirror line points must differ.".into()));
|
||||
}
|
||||
self.pts.push(p);
|
||||
|
||||
@@ -1,8 +1,33 @@
|
||||
use cadcraft_geom::Vec3;
|
||||
|
||||
use super::{Entity, Handle, Mat3, Result, Session, Value, Vec2, bad, bool_or, f64_req, json, point_param, point_req, targets};
|
||||
|
||||
// ---------------- core operations ----------------
|
||||
|
||||
pub(crate) fn transform_entities(s: &mut Session, hs: &[Handle], m: &Mat3, copy: bool) -> Result<Vec<Handle>> {
|
||||
edit_entities(s, hs, copy, |e| e.kind.transform(m))
|
||||
}
|
||||
|
||||
/// Scale `hs` by `f` about `base` in 3D, as AutoCAD 2024's SCALE does: about the base point's XY,
|
||||
/// and every height of the objects about its Z, their thickness too.
|
||||
pub(crate) fn scale_entities(s: &mut Session, hs: &[Handle], base: Vec3, f: f64, copy: bool) -> Result<Vec<Handle>> {
|
||||
let m = Mat3::scale_about(base.xy(), f);
|
||||
edit_entities(s, hs, copy, |e| {
|
||||
e.kind.transform(&m);
|
||||
e.kind.scale_heights(base.z, f);
|
||||
e.common.thickness *= f;
|
||||
})
|
||||
}
|
||||
|
||||
/// `p` at the current elevation (ELEVATION), the Z AutoCAD gives a point entered without one.
|
||||
pub(crate) fn at_elevation(s: &Session, p: Vec2) -> Vec3 {
|
||||
let z = s.doc().map(|d| d.header.f64("ELEVATION", 0.0)).ok().filter(|z| z.is_finite());
|
||||
Vec3::new(p.x, p.y, z.unwrap_or(0.0))
|
||||
}
|
||||
|
||||
/// Apply `edit` to each of `hs` still in the drawing, or to a copy of each when `copy`; an object
|
||||
/// on a locked layer is left alone unless it is copied. The handles edited or made.
|
||||
fn edit_entities(s: &mut Session, hs: &[Handle], copy: bool, edit: impl Fn(&mut Entity)) -> Result<Vec<Handle>> {
|
||||
let d = s.doc_mut()?;
|
||||
let mut out = Vec::new();
|
||||
for h in hs {
|
||||
@@ -11,7 +36,7 @@ pub(crate) fn transform_entities(s: &mut Session, hs: &[Handle], m: &Mat3, copy:
|
||||
let Some(e) = d.entity(*h).map(|e| (**e).clone()) else { continue };
|
||||
let nh = d.new_handle();
|
||||
let mut ne = Entity { handle: nh, ..e };
|
||||
ne.kind.transform(m);
|
||||
edit(&mut ne);
|
||||
if let Some(st) = d.space_mut(&sp) {
|
||||
st.push(ne);
|
||||
out.push(nh);
|
||||
@@ -21,7 +46,7 @@ pub(crate) fn transform_entities(s: &mut Session, hs: &[Handle], m: &Mat3, copy:
|
||||
if locked {
|
||||
continue;
|
||||
}
|
||||
d.modify_entity(*h, |e| e.kind.transform(m))?;
|
||||
d.modify_entity(*h, &edit)?;
|
||||
out.push(*h);
|
||||
}
|
||||
}
|
||||
@@ -66,7 +91,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()));
|
||||
@@ -89,7 +117,12 @@ pub(super) fn run_scale(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if f <= 0.0 {
|
||||
return Err(bad("scale", "factor must be positive"));
|
||||
}
|
||||
let r = transform_entities(s, &hs, &Mat3::scale_about(base, f), bool_or(p, "copy", false))?;
|
||||
// A Z given with the base point, as AutoCAD takes one; without one, the current elevation.
|
||||
let base = match p.get("base").and_then(|b| b.get(2)) {
|
||||
Some(z) => z.as_f64().map(|z| Vec3::new(base.x, base.y, z)).ok_or_else(|| bad("scale", "`base` Z must be a number"))?,
|
||||
None => at_elevation(s, base),
|
||||
};
|
||||
let r = scale_entities(s, &hs, base, f, bool_or(p, "copy", false))?;
|
||||
Ok(json!({ "handles": r.iter().map(|h| h.hex()).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
@@ -97,7 +130,7 @@ pub(super) fn run_mirror(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let hs = targets(s, p)?;
|
||||
let a = point_req("mirror", p, "p1")?;
|
||||
let b = point_req("mirror", p, "p2")?;
|
||||
if a.near(b, 1e-12) {
|
||||
if a.near(b, cadcraft_geom::EPS) {
|
||||
return Err(bad("mirror", "mirror line points coincide"));
|
||||
}
|
||||
let erase_src = bool_or(p, "erase", false);
|
||||
|
||||
@@ -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,9 @@
|
||||
//! Properties: per-object property edits, current properties, MATCHPROP, linetypes, units, RENAME.
|
||||
//! Properties: per-object property edits, current properties, CHPROP, MATCHPROP, linetypes, units,
|
||||
//! RENAME.
|
||||
|
||||
mod change;
|
||||
mod chprop;
|
||||
mod dash_units;
|
||||
mod matchprop;
|
||||
mod rename;
|
||||
|
||||
@@ -18,6 +22,13 @@ 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("change", "Change", change::run_change)
|
||||
.alias(&["-ch"])
|
||||
.params("{handles?, point?: [x, y], elevation?} → {changed, ignored, differingZ, detachedHatches, locked}: the point moves the nearer end of each line to it (level or plumb with the other end under ORTHOMODE) and, when it is the one object, passes a circle through it or places a text, attribute definition or block reference at it (`ignored` otherwise); `elevation` sets the z of objects whose points share one (`differingZ` otherwise); objects on locked layers are left out (the command line asks Specify change point or [Properties], then about each circle, text, attribute definition and block reference in turn)")
|
||||
.interactive(|_| Ok(Box::new(change::ChangeM::new()))),
|
||||
CommandSpec::new("matchprop", "Match Properties", matchprop::run_matchprop)
|
||||
.menu(&["Modify", "Match Properties"])
|
||||
.alias(&["ma", "painter"])
|
||||
@@ -33,6 +44,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("lweight", "Lineweight...", run_lweight).menu(&["Format", "Lineweight..."]).alias(&["lw", "lineweight"]).params("{lineweight: mm | ByLayer}"),
|
||||
CommandSpec::new("ltscale", "Linetype Scale", run_ltscale).alias(&["lts"]).params("{scale}").interactive(|_| Ok(Box::new(LtscaleM))),
|
||||
CommandSpec::new("units", "Units...", run_units).menu(&["Format", "Units..."]).alias(&["un"]).params("{lunits?: 1..5, luprec?: 0..8, aunits?: 0..4, auprec?: 0..8, insunits?}"),
|
||||
CommandSpec::new("-units", "Units", run_units).alias(&["-un"]).params("(as UNITS)").interactive(|_| Ok(Box::new(dash_units::DashUnits::default()))),
|
||||
CommandSpec::new("thickness", "Thickness", run_thickness)
|
||||
.menu(&["Format", "Thickness"])
|
||||
.params("{thickness} (THICKNESS: the 3D thickness given to new lines, arcs, circles, points and polylines; may be negative) → {thickness}")
|
||||
@@ -153,7 +165,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 +659,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));
|
||||
}
|
||||
@@ -1171,6 +1188,11 @@ fn run_rename(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
run_dimstyle_rename(s, &json!({ "from": from, "to": to }))?;
|
||||
}
|
||||
"linetype" | "ltype" => {
|
||||
// Stored trimmed, as -RENAME types it: lookups compare names untrimmed.
|
||||
let to = to.trim().to_string();
|
||||
if !rename::valid_name(&to) {
|
||||
return Err(bad("rename", format!("`{to}` is not a valid linetype name")));
|
||||
}
|
||||
let d = s.doc_mut()?;
|
||||
if ["ByBlock", "ByLayer", "Continuous"].iter().any(|n| from.eq_ignore_ascii_case(n)) {
|
||||
return Err(bad("rename", format!("the {from} linetype cannot be renamed")));
|
||||
@@ -1218,6 +1240,37 @@ 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 rename_linetype_rejects_invalid_names() {
|
||||
let mut s = Session::new();
|
||||
s.execute("linetype", &json!({ "load": "DASHED" })).unwrap();
|
||||
for to in ["", " ", "A;B", "A|B", "*X"] {
|
||||
let r = s.execute("rename", &json!({ "table": "linetype", "from": "DASHED", "to": to }));
|
||||
assert!(r.is_err(), "renamed DASHED to {to:?}: {r:?}");
|
||||
assert!(s.doc().unwrap().linetype("DASHED").is_some());
|
||||
}
|
||||
s.execute("rename", &json!({ "table": "linetype", "from": "DASHED", "to": " DASH2 " })).unwrap();
|
||||
assert!(s.doc().unwrap().linetype("DASH2").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn properties_set_rejects_unloaded_linetype() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -0,0 +1,544 @@
|
||||
//! CHANGE: change objects by a change point, or change their properties, as AutoCAD 2024 does.
|
||||
//!
|
||||
//! "Specify change point or [Properties]" follows "Select objects", which leaves objects on locked
|
||||
//! layers out as CHPROP does. Properties is CHPROP's loop with Elev added: the elevation is set
|
||||
//! when the command ends, and objects whose points differ in z keep theirs ("Cannot change
|
||||
//! elevation of objects with differing Z coordinates."). A change point, or Enter, goes through the
|
||||
//! objects in the order they were selected. Measured from AutoLISP's `command` (accoreconsole,
|
||||
//! every object read back with `entget`):
|
||||
//! - each line moves its end nearer the point to it: (0,0)–(50,0) with (60,10) gives
|
||||
//! (0,0)–(60,10), with (−5,5) gives (−5,5)–(50,0). Under ORTHOMODE the moved end stays level or
|
||||
//! plumb with the other one ((60,0)). Enter leaves lines alone;
|
||||
//! - a circle passes through the point (centre (100,0) and (115,0): radius 15), or asks "Specify
|
||||
//! new circle radius <no change>";
|
||||
//! - a text is placed by the point (the alignment point of justified text), or asks "Specify new
|
||||
//! text insertion point <no change>"; then "Enter new text style", "Specify new height" (not
|
||||
//! with a style of fixed height, nor for Aligned text), "Specify new rotation angle" (not for
|
||||
//! Aligned or Fit text, whose two points set it) and "Enter new text". An
|
||||
//! attribute definition asks "Enter new tag", "Enter new prompt" and "Enter new default value" in
|
||||
//! place of the text. A style that does not exist is refused: `Style "5" not defined - use STYLE
|
||||
//! command.`;
|
||||
//! - a block reference is placed by the point, its attributes moving and turning with it, or asks
|
||||
//! "Specify new block insertion point <no change>"; then "Specify new block rotation angle";
|
||||
//! - the point places a circle, text or block reference only when it is the one object selected:
|
||||
//! with others, "Change point ignored" comes before that object's questions, which are asked as
|
||||
//! for Enter;
|
||||
//! - other objects (arcs, polylines…) are left alone, and with nothing to change it says "No
|
||||
//! changeable object selected".
|
||||
//!
|
||||
//! Elev sets the z of lines, 3D polylines and solids whose points share one, and of points,
|
||||
//! circles, arcs, ellipses, polylines, hatches (no longer associative: "Hatch boundary
|
||||
//! associativity removed."), texts, multiline texts and block references.
|
||||
|
||||
use std::collections::{HashSet, VecDeque};
|
||||
|
||||
use cadcraft_doc::{Attrib, EntityKind, HAlign, Handle, Text};
|
||||
use cadcraft_geom::{Mat3, Vec2, Vec3};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::super::machines::{SelOutcome, SelectPhase, number};
|
||||
use super::super::{bad, f64_req, point_req, targets};
|
||||
use super::chprop::{ChpropM, begin_unlocked, feed_unlocked, locked};
|
||||
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
/// A question CHANGE asks about one object.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Ask {
|
||||
/// Not a question: "Change point ignored" as the object comes up.
|
||||
Ignored,
|
||||
Radius,
|
||||
TextPoint,
|
||||
Style,
|
||||
Height,
|
||||
Rotation,
|
||||
Value,
|
||||
Tag,
|
||||
AttPrompt,
|
||||
Default,
|
||||
BlockPoint,
|
||||
BlockRotation,
|
||||
}
|
||||
|
||||
enum Stage {
|
||||
Select,
|
||||
Point,
|
||||
Properties(Box<ChpropM>),
|
||||
/// The questions left, in order, each about one object.
|
||||
Asking(VecDeque<(Handle, Ask)>),
|
||||
}
|
||||
|
||||
pub(super) struct ChangeM {
|
||||
select: SelectPhase,
|
||||
handles: Vec<Handle>,
|
||||
stage: Stage,
|
||||
}
|
||||
|
||||
impl ChangeM {
|
||||
pub(super) fn new() -> Self {
|
||||
ChangeM { select: SelectPhase::default(), handles: Vec::new(), stage: Stage::Select }
|
||||
}
|
||||
|
||||
/// Apply the change point `p` (`None`: Enter) and line up the questions it leaves.
|
||||
fn change_point(&mut self, s: &mut Session, p: Option<Vec2>) -> Result<Step> {
|
||||
let alone = self.handles.len() == 1;
|
||||
let ortho = s.drafting_settings().orthomode;
|
||||
let mut asks = VecDeque::new();
|
||||
let mut changeable = false;
|
||||
for &h in &self.handles {
|
||||
let Some(kind) = s.doc()?.entity(h).map(|e| e.kind.clone()) else { continue };
|
||||
if matches!(kind, EntityKind::Line(_)) {
|
||||
if let Some(p) = p {
|
||||
move_end(s, h, p, ortho)?;
|
||||
changeable = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let Some(questions) = questions(&kind) else { continue };
|
||||
changeable = true;
|
||||
let placed = p.filter(|_| alone);
|
||||
if p.is_some() && !alone {
|
||||
asks.push_back((h, Ask::Ignored));
|
||||
}
|
||||
if let Some(q) = placed {
|
||||
place(s, h, q)?;
|
||||
}
|
||||
// The point answers the first question: the radius or the insertion point.
|
||||
asks.extend(questions.iter().skip(usize::from(placed.is_some())).map(|a| (h, *a)));
|
||||
}
|
||||
if !changeable {
|
||||
s.echo("No changeable object selected");
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
self.stage = Stage::Asking(asks);
|
||||
self.advance(s)
|
||||
}
|
||||
|
||||
/// Pass over what needs no answer: say "Change point ignored", and skip what [`skipped`]
|
||||
/// says isn't asked. Done when nothing is left to ask.
|
||||
fn advance(&mut self, s: &mut Session) -> Result<Step> {
|
||||
let Stage::Asking(asks) = &mut self.stage else { return Ok(Step::Continue) };
|
||||
while let Some(&(h, ask)) = asks.front() {
|
||||
match ask {
|
||||
Ask::Ignored => s.echo("Change point ignored"),
|
||||
a if skipped(s, h, a) => {}
|
||||
_ => return Ok(Step::Continue),
|
||||
}
|
||||
asks.pop_front();
|
||||
}
|
||||
s.set_selection(Vec::new());
|
||||
Ok(Step::Done)
|
||||
}
|
||||
}
|
||||
|
||||
/// The questions about an object of `kind`, the first answered by the change point when it is
|
||||
/// the one object; `None` when the change point leaves it alone.
|
||||
fn questions(kind: &EntityKind) -> Option<&'static [Ask]> {
|
||||
Some(match kind {
|
||||
EntityKind::Circle(_) => &[Ask::Radius],
|
||||
EntityKind::Text(_) => &[Ask::TextPoint, Ask::Style, Ask::Height, Ask::Rotation, Ask::Value],
|
||||
EntityKind::AttDef(_) => &[Ask::TextPoint, Ask::Style, Ask::Height, Ask::Rotation, Ask::Tag, Ask::AttPrompt, Ask::Default],
|
||||
EntityKind::Insert(_) => &[Ask::BlockPoint, Ask::BlockRotation],
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn text_of(kind: &EntityKind) -> Option<&Text> {
|
||||
match kind {
|
||||
EntityKind::Text(t) | EntityKind::AttDef(Attrib { text: t, .. }) => Some(t),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn text_of_mut(kind: &mut EntityKind) -> Option<&mut Text> {
|
||||
match kind {
|
||||
EntityKind::Text(t) | EntityKind::AttDef(Attrib { text: t, .. }) => Some(t),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a circle, text or block reference is measured from: its centre, the point it is placed by
|
||||
/// (the alignment point of justified text) or its insertion point.
|
||||
fn anchor(kind: &EntityKind) -> Option<Vec2> {
|
||||
match kind {
|
||||
EntityKind::Circle(c) => Some(c.center.xy()),
|
||||
EntityKind::Insert(i) => Some(i.insert.xy()),
|
||||
k => text_of(k).map(Text::justify_point),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether CHANGE passes over `ask` for text `h`: the height of a text whose style has a fixed
|
||||
/// one or that is Aligned, and the rotation of Aligned or Fit text (their two points set it).
|
||||
fn skipped(s: &Session, h: Handle, ask: Ask) -> bool {
|
||||
let Ok(d) = s.doc() else { return false };
|
||||
let Some(t) = d.entity(h).and_then(|e| text_of(&e.kind)) else { return false };
|
||||
match ask {
|
||||
Ask::Height => t.halign == HAlign::Aligned || d.text_style(&t.style).is_some_and(|st| st.height > 0.0),
|
||||
Ask::Rotation => matches!(t.halign, HAlign::Aligned | HAlign::Fit),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Move the end of line `h` nearer `p` to it; under ortho, level or plumb with the other end.
|
||||
fn move_end(s: &mut Session, h: Handle, p: Vec2, ortho: bool) -> Result<()> {
|
||||
let settings = s.drafting_settings();
|
||||
s.doc_mut()?.modify_entity(h, |e| {
|
||||
if let EntityKind::Line(l) = &mut e.kind {
|
||||
let (end, other) = if l.a.xy().dist(p) <= l.b.xy().dist(p) { (&mut l.a, l.b) } else { (&mut l.b, l.a) };
|
||||
let q = if ortho { crate::snap::iso::constrain(&settings, other.xy(), p) } else { p };
|
||||
end.x = q.x;
|
||||
end.y = q.y;
|
||||
}
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Place circle, text or block reference `h` by `q`: a circle passes through it, a text or block
|
||||
/// reference moves there (with its attributes).
|
||||
fn place(s: &mut Session, h: Handle, q: Vec2) -> Result<()> {
|
||||
s.doc_mut()?.modify_entity(h, |e| {
|
||||
if let EntityKind::Circle(c) = &mut e.kind {
|
||||
let r = c.center.xy().dist(q);
|
||||
if r > 0.0 && r.is_finite() {
|
||||
c.radius = r;
|
||||
}
|
||||
} else if let Some(at) = anchor(&e.kind) {
|
||||
e.kind.transform(&Mat3::translate(q - at));
|
||||
}
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set a circle's radius or a text's height.
|
||||
fn set_length(s: &mut Session, h: Handle, v: f64) -> Result<()> {
|
||||
s.doc_mut()?.modify_entity(h, |e| match &mut e.kind {
|
||||
EntityKind::Circle(c) => c.radius = v,
|
||||
k => {
|
||||
if let Some(t) = text_of_mut(k) {
|
||||
t.height = v;
|
||||
}
|
||||
}
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set a text's rotation, or turn a block reference (and its attributes) to `angle` about its
|
||||
/// insertion point.
|
||||
fn set_rotation(s: &mut Session, h: Handle, angle: f64) -> Result<()> {
|
||||
s.doc_mut()?.modify_entity(h, |e| {
|
||||
if let EntityKind::Insert(i) = &e.kind {
|
||||
let m = Mat3::rotate_about(i.insert.xy(), angle - i.rotation);
|
||||
e.kind.transform(&m);
|
||||
} else if let Some(t) = text_of_mut(&mut e.kind) {
|
||||
t.rotation = cadcraft_geom::norm_angle(angle);
|
||||
}
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Take an answer to `ask` about `h`; `false` when it was refused (the question comes again).
|
||||
fn answer(s: &mut Session, h: Handle, ask: Ask, i: Input) -> Result<bool> {
|
||||
let at = s.doc()?.entity(h).and_then(|e| anchor(&e.kind)).unwrap_or(Vec2::ZERO);
|
||||
match (ask, i.clone()) {
|
||||
// Enter keeps what there is.
|
||||
(_, Input::Enter) => {}
|
||||
(Ask::TextPoint | Ask::BlockPoint, Input::Point(q)) => place(s, h, q)?,
|
||||
(Ask::TextPoint | Ask::BlockPoint, _) => return Err(EngineError::Other("Point or option keyword required.".into())),
|
||||
(Ask::Radius | Ask::Height, Input::Point(_) | Input::Text(_)) => {
|
||||
let v = match &i {
|
||||
Input::Point(q) => q.dist(at),
|
||||
Input::Text(t) => number(t).ok_or_else(|| EngineError::Other("Requires numeric distance or second point.".into()))?,
|
||||
_ => 0.0,
|
||||
};
|
||||
if !(v > 0.0 && v.is_finite()) {
|
||||
return Err(EngineError::Other("Value must be positive and nonzero.".into()));
|
||||
}
|
||||
set_length(s, h, v)?;
|
||||
}
|
||||
(Ask::Rotation | Ask::BlockRotation, Input::Point(q)) => set_rotation(s, h, at.angle_to(q))?,
|
||||
(Ask::Rotation | Ask::BlockRotation, Input::Text(t)) => {
|
||||
let a = s.angle_settings().direction(&t).ok_or_else(|| EngineError::Other("Requires an angle.".into()))?;
|
||||
set_rotation(s, h, a)?;
|
||||
}
|
||||
(Ask::Style, Input::Text(t) | Input::Keyword(t)) => {
|
||||
let name = t.trim();
|
||||
let Some(style) = s.doc()?.text_style(name).map(|st| st.name.clone()) else {
|
||||
s.echo(format!("Style \"{name}\" not defined - use STYLE command."));
|
||||
return Ok(false);
|
||||
};
|
||||
s.doc_mut()?.modify_entity(h, |e| {
|
||||
if let Some(t) = text_of_mut(&mut e.kind) {
|
||||
t.style = style;
|
||||
}
|
||||
})?;
|
||||
}
|
||||
(Ask::Value | Ask::Tag | Ask::AttPrompt | Ask::Default, Input::Text(t) | Input::Keyword(t)) => {
|
||||
s.doc_mut()?.modify_entity(h, |e| match (&mut e.kind, ask) {
|
||||
(EntityKind::AttDef(a), Ask::Tag) => a.tag = t,
|
||||
(EntityKind::AttDef(a), Ask::AttPrompt) => a.prompt = t,
|
||||
(k, _) => {
|
||||
if let Some(x) = text_of_mut(k) {
|
||||
x.value = t;
|
||||
}
|
||||
}
|
||||
})?;
|
||||
}
|
||||
_ => return Ok(false),
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// The question `ask` about `h`.
|
||||
fn ask_prompt(s: &Session, h: Handle, ask: Ask) -> Prompt {
|
||||
let d = s.doc().ok();
|
||||
let kind = d.and_then(|d| d.entity(h)).map(|e| &e.kind);
|
||||
let at = kind.and_then(anchor);
|
||||
let text = kind.and_then(text_of);
|
||||
// As the drawing shows directions: from ANGBASE in the ANGDIR sense (ANGBASE 90° shows a text
|
||||
// at 0° as 270.00).
|
||||
let deg = |a: f64| format!("{:.2}", s.angle_settings().shown(a).to_degrees());
|
||||
match ask {
|
||||
Ask::Radius => Prompt::new("Specify new circle radius", Accept::POINT_OR_NUMBER).default("no change").base_opt(at).distance(),
|
||||
Ask::TextPoint => Prompt::new("Specify new text insertion point", Accept::POINT).default("no change"),
|
||||
Ask::BlockPoint => Prompt::new("Specify new block insertion point", Accept::POINT).default("no change"),
|
||||
Ask::Style => Prompt::new("Enter new text style", Accept::TEXT).default(text.map(|t| t.style.clone()).unwrap_or_default()),
|
||||
Ask::Height => Prompt::new("Specify new height", Accept::POINT_OR_NUMBER)
|
||||
.default(format!("{:.4}", text.map_or(0.0, |t| t.height)))
|
||||
.base_opt(at)
|
||||
.distance(),
|
||||
Ask::Rotation => {
|
||||
Prompt::new("Specify new rotation angle", Accept::POINT_OR_NUMBER).default(deg(text.map_or(0.0, |t| t.rotation))).base_opt(at)
|
||||
}
|
||||
Ask::BlockRotation => {
|
||||
let r = match kind {
|
||||
Some(EntityKind::Insert(i)) => i.rotation,
|
||||
_ => 0.0,
|
||||
};
|
||||
Prompt::new("Specify new block rotation angle", Accept::POINT_OR_NUMBER).default(deg(r)).base_opt(at)
|
||||
}
|
||||
Ask::Value => Prompt::new("Enter new text", Accept::TEXT).default(text.map(|t| t.value.clone()).unwrap_or_default()),
|
||||
Ask::Default => Prompt::new("Enter new default value", Accept::TEXT).default(text.map(|t| t.value.clone()).unwrap_or_default()),
|
||||
Ask::Tag | Ask::AttPrompt => {
|
||||
let (msg, v) = match (kind, ask) {
|
||||
(Some(EntityKind::AttDef(a)), Ask::Tag) => ("Enter new tag", a.tag.clone()),
|
||||
(Some(EntityKind::AttDef(a)), _) => ("Enter new prompt", a.prompt.clone()),
|
||||
(_, Ask::Tag) => ("Enter new tag", String::new()),
|
||||
_ => ("Enter new prompt", String::new()),
|
||||
};
|
||||
Prompt::new(msg, Accept::TEXT).default(v)
|
||||
}
|
||||
Ask::Ignored => Prompt::new("", Accept::TEXT),
|
||||
}
|
||||
}
|
||||
|
||||
/// The one z of `zs`, to rounding; `None` when they differ.
|
||||
fn shared_z(zs: impl IntoIterator<Item = f64>) -> Option<f64> {
|
||||
let mut zs = zs.into_iter();
|
||||
let first = zs.next().unwrap_or(0.0);
|
||||
zs.all(|z| (z - first).abs() <= 1e-9 * (1.0 + first.abs())).then_some(first)
|
||||
}
|
||||
|
||||
/// The elevation of an object Elev sets: `Some(None)` when its points differ in z (a line between
|
||||
/// two heights), `None` for the kinds Elev leaves alone.
|
||||
pub(super) fn elevation_of(kind: &EntityKind) -> Option<Option<f64>> {
|
||||
match kind {
|
||||
EntityKind::Line(l) => Some(shared_z([l.a.z, l.b.z])),
|
||||
EntityKind::Polyline3d(p) => Some(shared_z(p.points.iter().map(|q| q.z))),
|
||||
EntityKind::Solid(so) | EntityKind::Trace(so) => Some(shared_z(so.corners.iter().map(|c| c.z))),
|
||||
EntityKind::Point(p) => Some(Some(p.p.z)),
|
||||
EntityKind::Circle(c) => Some(Some(c.center.z)),
|
||||
EntityKind::Arc(a) => Some(Some(a.center.z)),
|
||||
EntityKind::Ellipse(e) => Some(Some(e.center.z)),
|
||||
EntityKind::LwPolyline(p) => Some(Some(p.elevation)),
|
||||
EntityKind::Hatch(h) => Some(Some(h.elevation)),
|
||||
EntityKind::MText(t) => Some(Some(t.insert.z)),
|
||||
EntityKind::Insert(i) => Some(Some(i.insert.z)),
|
||||
k => text_of(k).map(|t| Some(t.insert.z)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Move `text` up by `dz`.
|
||||
fn raise_text(text: &mut Text, dz: f64) {
|
||||
for p in std::iter::once(&mut text.insert).chain(text.align_pt.as_mut()) {
|
||||
p.z += dz;
|
||||
}
|
||||
}
|
||||
|
||||
fn set_elevation(kind: &mut EntityKind, z: f64) {
|
||||
let set = |p: &mut Vec3| p.z = z;
|
||||
match kind {
|
||||
EntityKind::Line(l) => {
|
||||
set(&mut l.a);
|
||||
set(&mut l.b);
|
||||
}
|
||||
EntityKind::Polyline3d(p) => p.points.iter_mut().for_each(set),
|
||||
EntityKind::Solid(so) | EntityKind::Trace(so) => so.corners.iter_mut().for_each(set),
|
||||
EntityKind::Point(p) => set(&mut p.p),
|
||||
EntityKind::Circle(c) => set(&mut c.center),
|
||||
EntityKind::Arc(a) => set(&mut a.center),
|
||||
EntityKind::Ellipse(e) => set(&mut e.center),
|
||||
EntityKind::LwPolyline(p) => p.elevation = z,
|
||||
EntityKind::Hatch(h) => {
|
||||
h.elevation = z;
|
||||
crate::cmd::hatch::detach(h);
|
||||
}
|
||||
EntityKind::MText(t) => set(&mut t.insert),
|
||||
// A block reference's attributes go up with it.
|
||||
EntityKind::Insert(i) => {
|
||||
let dz = z - i.insert.z;
|
||||
set(&mut i.insert);
|
||||
for a in &mut i.attribs {
|
||||
raise_text(&mut a.text, dz);
|
||||
if let Some(ml) = &mut a.multiline {
|
||||
ml.mtext.insert.z += dz;
|
||||
}
|
||||
}
|
||||
}
|
||||
k => {
|
||||
if let Some(t) = text_of_mut(k) {
|
||||
let dz = z - t.insert.z;
|
||||
raise_text(t, dz);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What [`elevate`] did.
|
||||
pub(super) struct Elevated {
|
||||
/// The objects whose elevation was set.
|
||||
pub(super) set: Vec<Handle>,
|
||||
/// Objects whose points differ in z, which keep theirs.
|
||||
pub(super) differing: usize,
|
||||
/// Associative hatches, which no longer are.
|
||||
pub(super) detached: usize,
|
||||
}
|
||||
|
||||
/// Set the elevation of the objects among `hs` whose points share one.
|
||||
pub(super) fn elevate(s: &mut Session, hs: &[Handle], z: f64) -> Result<Elevated> {
|
||||
let d = s.doc_mut()?;
|
||||
let mut r = Elevated { set: Vec::new(), differing: 0, detached: 0 };
|
||||
for &h in hs {
|
||||
let Some(e) = d.entity(h) else { continue };
|
||||
match elevation_of(&e.kind) {
|
||||
Some(Some(_)) => {
|
||||
r.detached += usize::from(matches!(&e.kind, EntityKind::Hatch(hatch) if hatch.associative));
|
||||
d.modify_entity(h, |e| set_elevation(&mut e.kind, z))?;
|
||||
r.set.push(h);
|
||||
}
|
||||
Some(None) => r.differing += 1,
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// Properties ▸ Elev as the command ends: set the elevation, and say what was left alone or
|
||||
/// changed besides.
|
||||
pub(super) fn elevate_and_say(s: &mut Session, hs: &[Handle], z: f64) -> Result<()> {
|
||||
let r = elevate(s, hs, z)?;
|
||||
if r.differing > 0 {
|
||||
s.echo("Cannot change elevation of objects with differing Z coordinates.");
|
||||
}
|
||||
if r.detached > 0 {
|
||||
s.echo("Hatch boundary associativity removed.");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// CHANGE's JSON form: `point` changes the objects as the change point does (`ignored`: circles,
|
||||
/// texts and block references it leaves alone because others were selected too), `elevation` as
|
||||
/// Properties ▸ Elev does (`differingZ`: objects whose points differ in z; `detachedHatches`:
|
||||
/// associative hatches that no longer are); objects on locked layers are left out (`locked`).
|
||||
pub(super) fn run_change(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// A point or an elevation that can't be read is refused, and so is a call with neither.
|
||||
let point = p.get("point").map(|_| point_req("change", p, "point")).transpose()?;
|
||||
let elevation = p.get("elevation").map(|_| f64_req("change", p, "elevation")).transpose()?;
|
||||
if point.is_none() && elevation.is_none() {
|
||||
return Err(bad("change", "`point` or `elevation` is required"));
|
||||
}
|
||||
let hs = targets(s, p)?;
|
||||
let locked = locked(s, &hs);
|
||||
let free: Vec<Handle> = hs.into_iter().filter(|h| !locked.contains(h)).collect();
|
||||
let (mut changed, mut ignored, mut differing, mut detached) = (HashSet::new(), 0, 0, 0);
|
||||
if let Some(q) = point {
|
||||
let ortho = s.drafting_settings().orthomode;
|
||||
for &h in &free {
|
||||
let Some(kind) = s.doc()?.entity(h).map(|e| e.kind.clone()) else { continue };
|
||||
if matches!(kind, EntityKind::Line(_)) {
|
||||
move_end(s, h, q, ortho)?;
|
||||
changed.insert(h);
|
||||
} else if questions(&kind).is_some() {
|
||||
if free.len() == 1 {
|
||||
place(s, h, q)?;
|
||||
changed.insert(h);
|
||||
} else {
|
||||
ignored += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(z) = elevation {
|
||||
let r = elevate(s, &free, z)?;
|
||||
changed.extend(r.set);
|
||||
(differing, detached) = (r.differing, r.detached);
|
||||
}
|
||||
Ok(json!({ "changed": changed.len(), "ignored": ignored, "differingZ": differing, "detachedHatches": detached, "locked": locked.len() }))
|
||||
}
|
||||
|
||||
impl Interactive for ChangeM {
|
||||
fn name(&self) -> &'static str {
|
||||
"CHANGE"
|
||||
}
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
self.select = begin_unlocked(s);
|
||||
if self.select.done {
|
||||
self.handles = self.select.picked.clone();
|
||||
self.stage = Stage::Point;
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
fn prompt(&self, s: &Session) -> Prompt {
|
||||
match &self.stage {
|
||||
Stage::Select => self.select.prompt(),
|
||||
Stage::Point => Prompt::new("Specify change point", Accept::POINT).kw(&["Properties"]),
|
||||
Stage::Properties(m) => m.prompt(s),
|
||||
Stage::Asking(asks) => asks.front().map_or_else(|| Prompt::new("", Accept::TEXT), |&(h, ask)| ask_prompt(s, h, ask)),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
match &mut self.stage {
|
||||
Stage::Select => {
|
||||
return Ok(match feed_unlocked(&mut self.select, s, i)? {
|
||||
SelOutcome::More => Step::Continue,
|
||||
SelOutcome::Done(hs) => {
|
||||
self.handles = hs;
|
||||
self.stage = Stage::Point;
|
||||
Step::Continue
|
||||
}
|
||||
SelOutcome::Empty => {
|
||||
s.set_selection(Vec::new());
|
||||
Step::Done
|
||||
}
|
||||
});
|
||||
}
|
||||
Stage::Properties(m) => return m.input(s, i),
|
||||
Stage::Asking(asks) => {
|
||||
let Some(&(h, ask)) = asks.front() else { return Ok(Step::Done) };
|
||||
if !answer(s, h, ask, i)? {
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
asks.pop_front();
|
||||
return self.advance(s);
|
||||
}
|
||||
Stage::Point => {}
|
||||
}
|
||||
match i {
|
||||
Input::Keyword(k) if k == "Properties" => {
|
||||
self.stage = Stage::Properties(Box::new(ChpropM::change_properties(self.handles.clone())));
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Input::Point(p) => self.change_point(s, Some(p)),
|
||||
Input::Enter => self.change_point(s, None),
|
||||
_ => Err(EngineError::Other("Point or option keyword required.".into())),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
//! 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"];
|
||||
|
||||
/// CHANGE's Properties: CHPROP's list with Elev.
|
||||
const CHANGE_PROPERTIES: [&str; 8] = ["Color", "Elev", "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,
|
||||
Elevation,
|
||||
}
|
||||
|
||||
pub(super) struct ChpropM {
|
||||
select: SelectPhase,
|
||||
handles: Vec<Handle>,
|
||||
/// `None` while objects are selected.
|
||||
ask: Option<Ask>,
|
||||
/// CHANGE's Properties: Elev is on the list.
|
||||
change: bool,
|
||||
/// The elevation typed at Elev, set when the command ends.
|
||||
elevation: Option<f64>,
|
||||
}
|
||||
|
||||
impl ChpropM {
|
||||
pub(super) fn new() -> Self {
|
||||
ChpropM { select: SelectPhase::default(), handles: Vec::new(), ask: None, change: false, elevation: None }
|
||||
}
|
||||
|
||||
/// CHANGE's Properties option on `handles`.
|
||||
pub(super) fn change_properties(handles: Vec<Handle>) -> Self {
|
||||
ChpropM { select: SelectPhase::default(), handles, ask: Some(Ask::Property), change: true, elevation: None }
|
||||
}
|
||||
|
||||
fn keywords(&self) -> &'static [&'static str] {
|
||||
if self.change { &CHANGE_PROPERTIES } else { &PROPERTIES }
|
||||
}
|
||||
|
||||
/// The selection's common elevation, or "varies" (as a line between two heights does alone).
|
||||
fn common_elevation(&self, s: &Session) -> String {
|
||||
let Ok(d) = s.doc() else { return String::new() };
|
||||
let zs: Vec<Option<f64>> = self.handles.iter().filter_map(|h| d.entity(*h)).filter_map(|e| super::change::elevation_of(&e.kind)).collect();
|
||||
match zs.first() {
|
||||
None => format!("{:.4}", 0.0),
|
||||
Some(Some(z)) if zs.iter().all(|o| *o == Some(*z)) => format!("{z:.4}"),
|
||||
Some(_) => "varies".into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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::Elevation => match number(word).filter(|v| v.is_finite()) {
|
||||
Some(z) => self.elevation = Some(z),
|
||||
None => {
|
||||
s.echo("Requires a number.");
|
||||
return Ok(false);
|
||||
}
|
||||
},
|
||||
Ask::Property => {}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
/// The objects among `hs` on locked layers.
|
||||
pub(super) 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.")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Start selecting objects, leaving those on locked layers out of the pickfirst selection (and
|
||||
/// saying how many); `done` when some of it is left.
|
||||
pub(super) fn begin_unlocked(s: &mut Session) -> SelectPhase {
|
||||
let mut select = SelectPhase::begin(s);
|
||||
if select.done {
|
||||
let locked = locked(s, &select.picked);
|
||||
if !locked.is_empty() {
|
||||
select.picked.retain(|h| !locked.contains(h));
|
||||
say_locked(s, locked.len());
|
||||
s.set_selection(select.picked.clone());
|
||||
// Nothing left of the pickfirst selection: ask for objects instead.
|
||||
select.done = !select.picked.is_empty();
|
||||
}
|
||||
}
|
||||
select
|
||||
}
|
||||
|
||||
/// Feed `i` to `select`, leaving picked objects on locked layers out and saying how many after
|
||||
/// what was found.
|
||||
pub(super) fn feed_unlocked(select: &mut SelectPhase, s: &mut Session, i: Input) -> Result<SelOutcome> {
|
||||
let (i, n) = match i {
|
||||
Input::Pick(hs) if !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 = select.feed(s, &i)?;
|
||||
say_locked(s, n);
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
/// 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 = begin_unlocked(s);
|
||||
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(self.keywords()),
|
||||
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())),
|
||||
Ask::Elevation => Prompt::new("Specify new elevation", Accept::NUMBER).default(self.common_elevation(s)),
|
||||
}
|
||||
}
|
||||
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.
|
||||
return Ok(match feed_unlocked(&mut self.select, s, i)? {
|
||||
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) => {
|
||||
// CHANGE sets the elevation as it ends.
|
||||
if let Some(z) = self.elevation {
|
||||
super::change::elevate_and_say(s, &self.handles, z)?;
|
||||
}
|
||||
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,
|
||||
"Elev" => Ask::Elevation,
|
||||
_ => Ask::Transparency,
|
||||
});
|
||||
}
|
||||
(Ask::Property, Input::Text(t)) => {
|
||||
let t = t.trim().trim_start_matches('_').to_ascii_lowercase();
|
||||
let n = self.keywords().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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
//! -UNITS: the drawing's units at the command line, with AutoCAD 2024's prompts and listings.
|
||||
|
||||
use std::f64::consts::TAU;
|
||||
|
||||
use crate::units::{AngleSettings, format_angle};
|
||||
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
const REPORT_FORMATS: [&str; 10] = [
|
||||
"Report formats: (Examples)",
|
||||
" 1. Scientific 1.55E+01",
|
||||
" 2. Decimal 15.50",
|
||||
" 3. Engineering 1'-3.50\"",
|
||||
" 4. Architectural 1'-3 1/2\"",
|
||||
" 5. Fractional 15 1/2",
|
||||
"With the exception of Engineering and Architectural formats,",
|
||||
"these formats can be used with any basic unit of measurement.",
|
||||
"For example, Decimal mode is perfect for metric units as well",
|
||||
"as decimal English units.",
|
||||
];
|
||||
|
||||
const ANGLE_SYSTEMS: [&str; 6] = [
|
||||
"Systems of angle measure: (Examples)",
|
||||
" 1. Decimal degrees 45.0000",
|
||||
" 2. Degrees/minutes/seconds 45d0'0\"",
|
||||
" 3. Grads 50.0000g",
|
||||
" 4. Radians 0.7854r",
|
||||
" 5. Surveyor's units N 45d0'0\" E",
|
||||
];
|
||||
|
||||
const DENOMINATOR: &str = "Enter denominator of smallest fraction to display";
|
||||
|
||||
/// The drawing's units, as -UNITS shows and asks them.
|
||||
struct Units {
|
||||
lunits: i64,
|
||||
luprec: i64,
|
||||
aunits: i64,
|
||||
auprec: i64,
|
||||
angbase: f64,
|
||||
clockwise: bool,
|
||||
}
|
||||
|
||||
impl Units {
|
||||
fn of(s: &Session) -> Units {
|
||||
let Ok(d) = s.doc() else { return Units { lunits: 2, luprec: 4, aunits: 0, auprec: 0, angbase: 0.0, clockwise: false } };
|
||||
let h = &d.header;
|
||||
let angbase = h.f64("ANGBASE", 0.0);
|
||||
Units {
|
||||
lunits: h.i64("LUNITS", 2).clamp(1, 5),
|
||||
luprec: h.i64("LUPREC", 4).clamp(0, 8),
|
||||
aunits: h.i64("AUNITS", 0).clamp(0, 4),
|
||||
auprec: h.i64("AUPREC", 0).clamp(0, 8),
|
||||
angbase: if angbase.is_finite() { angbase } else { 0.0 },
|
||||
clockwise: h.i64("ANGDIR", 0) != 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Architectural and fractional units give their precision as a fraction's denominator.
|
||||
fn fractional(&self) -> bool {
|
||||
matches!(self.lunits, 4 | 5)
|
||||
}
|
||||
|
||||
/// Direction `a` (radians counterclockwise from +X) in the angle format, measured as the
|
||||
/// drawing measures angles but from +X: -UNITS shows north as 270 when angles turn clockwise.
|
||||
fn direction_text(&self, a: f64) -> String {
|
||||
format_angle(if self.clockwise { -a } else { a }, self.aunits, self.auprec)
|
||||
}
|
||||
|
||||
/// A typed direction of angle 0, in radians counterclockwise from +X: an amount in the angle
|
||||
/// format, or with its own unit (`45d`, `0.5r`, `100g`, a bearing `N45dE`, and in surveyor's
|
||||
/// units N, E, S or W), measured from +X as the drawing measures angles.
|
||||
fn direction(&self, t: &str) -> Option<f64> {
|
||||
let cardinal = match t.trim().to_ascii_lowercase().as_str() {
|
||||
"e" if self.aunits == 4 => Some(0.0),
|
||||
"n" if self.aunits == 4 => Some(90f64.to_radians()),
|
||||
"w" if self.aunits == 4 => Some(180f64.to_radians()),
|
||||
"s" if self.aunits == 4 => Some(270f64.to_radians()),
|
||||
_ => None,
|
||||
};
|
||||
let a = cardinal.or_else(|| AngleSettings { aunits: self.aunits, ..AngleSettings::default() }.amount(t))?;
|
||||
let a = if self.clockwise { -a } else { a };
|
||||
a.is_finite().then(|| a.rem_euclid(TAU))
|
||||
}
|
||||
}
|
||||
|
||||
/// An integer answer in `lo..=hi`, or AutoCAD's message for one that isn't.
|
||||
fn integer(t: &str, lo: i64, hi: i64) -> Result<i64> {
|
||||
let v = t.trim().parse::<i64>().map_err(|_| EngineError::Other("Requires an integer value.".into()))?;
|
||||
if (lo..=hi).contains(&v) { Ok(v) } else { Err(EngineError::Other(format!("Requires an integer between {lo} and {hi}."))) }
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Default)]
|
||||
enum Ask {
|
||||
#[default]
|
||||
LengthFormat,
|
||||
LengthPrecision,
|
||||
AngleFormat,
|
||||
AnglePrecision,
|
||||
Direction,
|
||||
Clockwise,
|
||||
}
|
||||
|
||||
/// -UNITS: the length format and precision, the angle format and precision, the direction of
|
||||
/// angle 0 and whether angles turn clockwise, asked in that order. As in AutoCAD, each answer is
|
||||
/// kept as it is given (Esc keeps the ones before it), and choosing another format resets its
|
||||
/// precision to 4, the precision then offered.
|
||||
#[derive(Default)]
|
||||
pub(super) struct DashUnits {
|
||||
ask: Ask,
|
||||
}
|
||||
|
||||
impl Interactive for DashUnits {
|
||||
fn name(&self) -> &'static str {
|
||||
"-UNITS"
|
||||
}
|
||||
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
for l in REPORT_FORMATS {
|
||||
s.echo(l);
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
|
||||
fn prompt(&self, s: &Session) -> Prompt {
|
||||
let u = Units::of(s);
|
||||
match self.ask {
|
||||
Ask::LengthFormat => Prompt::new("Enter choice, 1 to 5", Accept::NUMBER).default(u.lunits.to_string()),
|
||||
Ask::LengthPrecision if u.fractional() => {
|
||||
Prompt::new("(1, 2, 4, 8, 16, 32, 64, 128, or 256)", Accept::NUMBER).default((1_i64 << u.luprec).to_string())
|
||||
}
|
||||
Ask::LengthPrecision => {
|
||||
Prompt::new("Enter number of digits to right of decimal point (0 to 8)", Accept::NUMBER).default(u.luprec.to_string())
|
||||
}
|
||||
Ask::AngleFormat => Prompt::new("Enter choice, 1 to 5", Accept::NUMBER).default((u.aunits + 1).to_string()),
|
||||
Ask::AnglePrecision => {
|
||||
Prompt::new("Enter number of fractional places for display of angles (0 to 8)", Accept::NUMBER).default(u.auprec.to_string())
|
||||
}
|
||||
Ask::Direction => {
|
||||
Prompt::new(format!("Enter direction for angle {}", u.direction_text(0.0)), Accept::TEXT).default(u.direction_text(u.angbase))
|
||||
}
|
||||
Ask::Clockwise => Prompt::new("Measure angles clockwise?", Accept::TEXT).kw(&["Yes", "No"]).default(if u.clockwise { "Y" } else { "N" }),
|
||||
}
|
||||
}
|
||||
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
let u = Units::of(s);
|
||||
let text = match &i {
|
||||
Input::Text(t) => Some(t.as_str()),
|
||||
Input::Enter => None,
|
||||
Input::Keyword(k) => Some(k.as_str()),
|
||||
_ => return Ok(Step::Continue),
|
||||
};
|
||||
let h = &mut s.doc_mut()?.header;
|
||||
match self.ask {
|
||||
Ask::LengthFormat => {
|
||||
let l = text.map_or(Ok(u.lunits), |t| integer(t, 1, 5))?;
|
||||
if l != u.lunits {
|
||||
h.set_i64("LUNITS", l);
|
||||
h.set_i64("LUPREC", 4);
|
||||
}
|
||||
self.ask = Ask::LengthPrecision;
|
||||
if matches!(l, 4 | 5) {
|
||||
s.echo(DENOMINATOR);
|
||||
}
|
||||
}
|
||||
Ask::LengthPrecision => {
|
||||
let prec = if u.fractional() {
|
||||
let den = text.map_or(Some(1 << u.luprec), |t| t.trim().parse::<i64>().ok());
|
||||
let Some(p) = den.and_then(|d| (0..=8).find(|p| 1_i64 << p == d)) else {
|
||||
s.echo("Invalid choice.");
|
||||
s.echo(DENOMINATOR);
|
||||
return Ok(Step::Continue);
|
||||
};
|
||||
p
|
||||
} else {
|
||||
text.map_or(Ok(u.luprec), |t| integer(t, 0, 8))?
|
||||
};
|
||||
h.set_i64("LUPREC", prec);
|
||||
self.ask = Ask::AngleFormat;
|
||||
for l in ANGLE_SYSTEMS {
|
||||
s.echo(l);
|
||||
}
|
||||
}
|
||||
Ask::AngleFormat => {
|
||||
let a = text.map_or(Ok(u.aunits + 1), |t| integer(t, 1, 5))? - 1;
|
||||
if a != u.aunits {
|
||||
h.set_i64("AUNITS", a);
|
||||
h.set_i64("AUPREC", 4);
|
||||
}
|
||||
self.ask = Ask::AnglePrecision;
|
||||
}
|
||||
Ask::AnglePrecision => {
|
||||
h.set_i64("AUPREC", text.map_or(Ok(u.auprec), |t| integer(t, 0, 8))?);
|
||||
self.ask = Ask::Direction;
|
||||
let u = Units::of(s);
|
||||
s.echo(format!("Direction for angle {}:", u.direction_text(0.0)));
|
||||
for (name, deg) in [("East 3", 0.0), ("North 12", 90.0), ("West 9", 180.0), ("South 6", 270.0)] {
|
||||
s.echo(format!(" {name} o'clock = {}", u.direction_text(f64::to_radians(deg))));
|
||||
}
|
||||
}
|
||||
Ask::Direction => {
|
||||
if let Some(t) = text {
|
||||
let a = u.direction(t).ok_or_else(|| EngineError::Other("Requires valid numeric angle or two points.".into()))?;
|
||||
h.set_f64("ANGBASE", a);
|
||||
}
|
||||
self.ask = Ask::Clockwise;
|
||||
}
|
||||
Ask::Clockwise => {
|
||||
let clockwise = match text.map(|t| t.trim().trim_start_matches('_').to_ascii_lowercase()) {
|
||||
None => u.clockwise,
|
||||
Some(t) if t == "y" || t == "yes" => true,
|
||||
Some(t) if t == "n" || t == "no" => false,
|
||||
Some(_) => return Err(EngineError::Other("Invalid option keyword.".into())),
|
||||
};
|
||||
h.set_i64("ANGDIR", i64::from(clockwise));
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ use serde_json::json;
|
||||
use super::super::bad;
|
||||
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
fn valid_name(n: &str) -> bool {
|
||||
pub(super) fn valid_name(n: &str) -> bool {
|
||||
!n.trim().is_empty() && n.len() <= 255 && !n.starts_with('*') && !n.chars().any(|c| "<>/\\\":;?*|,=`".contains(c))
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -333,6 +333,9 @@ fn call(name: &str, args: &[Val]) -> Result<Val, String> {
|
||||
"min" | "max" => {
|
||||
arity(1, MAX_ARGS)?;
|
||||
let vs = (0..args.len()).map(n).collect::<Result<Vec<_>, _>>()?;
|
||||
if vs.iter().any(|v| !v.is_finite()) {
|
||||
return Err("result is not a finite number".into());
|
||||
}
|
||||
let f = if name == "min" { f64::min } else { f64::max };
|
||||
Ok(Val::Num(vs.into_iter().reduce(f).unwrap_or(0.0)))
|
||||
}
|
||||
@@ -597,6 +600,10 @@ mod tests {
|
||||
assert!(ev("[1").is_err());
|
||||
assert!(ev(&"[".repeat(5000)).is_err());
|
||||
assert_eq!(ev("min(3,1,2)"), Ok(Val::Num(1.0)));
|
||||
assert!(ev("min(sqrt(-1), 5)").is_err());
|
||||
assert!(ev("max(5, ln(-1))").is_err());
|
||||
assert!(ev("min(exp(1000), 5)").is_err());
|
||||
assert!(ev("max(exp(1000), 5)").is_err());
|
||||
assert_eq!(ev("1e-3*2"), Ok(Val::Num(0.002)));
|
||||
assert!(ev("max(1,2,3,4,5,6,7,8,9)").is_err());
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -25,6 +25,10 @@ pub struct LastUsed {
|
||||
pub mleader_block: String,
|
||||
/// BREAKLINE's Block, Size and Extension.
|
||||
pub breakline: cmd::BreaklineOptions,
|
||||
/// -ARRAY's type: Polar instead of Rectangular.
|
||||
pub classic_array_polar: bool,
|
||||
/// RECTANG's chamfer, fillet, width, elevation, thickness and rotation.
|
||||
pub rectang: RectangModes,
|
||||
}
|
||||
|
||||
impl Default for LastUsed {
|
||||
@@ -39,10 +43,26 @@ impl Default for LastUsed {
|
||||
mleader: MLeaderOptions::default(),
|
||||
mleader_block: String::new(),
|
||||
breakline: cmd::BreaklineOptions::default(),
|
||||
classic_array_polar: false,
|
||||
rectang: RectangModes::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The modes RECTANG keeps from one rectangle to the next, as AutoCAD 2024 does. Chamfer and
|
||||
/// fillet exclude each other: setting one clears the other.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct RectangModes {
|
||||
/// First and second chamfer distances.
|
||||
pub chamfer: (f64, f64),
|
||||
pub fillet: f64,
|
||||
pub width: f64,
|
||||
pub elevation: f64,
|
||||
pub thickness: f64,
|
||||
/// Radians.
|
||||
pub rotation: f64,
|
||||
}
|
||||
|
||||
/// Which part of a multileader MLEADER places first.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum LeaderOrder {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -60,20 +60,43 @@ pub(super) fn atoi(a: &[Value]) -> R {
|
||||
Ok(Value::Int(t.get(..end).and_then(|n| n.parse::<i64>().ok()).map(|n| n as i32).unwrap_or(0)))
|
||||
}
|
||||
|
||||
/// C's `atof`: the longest number at the start; 0.0 when there is none.
|
||||
/// C's `atof`: the number token at the start; 0.0 when there is none.
|
||||
/// An overflowing token clamps to the largest finite value of its sign.
|
||||
pub(super) fn atof(a: &[Value]) -> R {
|
||||
arity(a, 1, 1)?;
|
||||
let s = string(&a[0])?;
|
||||
// The longest prefix (of the first 64 characters) that reads as a finite number.
|
||||
let t: String = s.trim_start().chars().take(64).collect();
|
||||
let ends: Vec<usize> = t.char_indices().map(|(i, _)| i).skip(1).chain(std::iter::once(t.len())).collect();
|
||||
let v = ends.iter().rev().find_map(|&e| t.get(..e).and_then(|p| p.parse::<f64>().ok()).filter(|v| v.is_finite() && !p_is_word(&t[..e])));
|
||||
Ok(Value::Real(v.unwrap_or(0.0)))
|
||||
}
|
||||
|
||||
/// Words Rust reads as numbers that C's `atof` doesn't ("inf", "nan", "infinity").
|
||||
fn p_is_word(p: &str) -> bool {
|
||||
p.chars().any(|c| c.is_ascii_alphabetic() && !matches!(c, 'e' | 'E'))
|
||||
let b = s.trim_start().as_bytes();
|
||||
let digits = |mut i: usize| {
|
||||
while b.get(i).is_some_and(u8::is_ascii_digit) {
|
||||
i += 1;
|
||||
}
|
||||
i
|
||||
};
|
||||
let mut i = usize::from(matches!(b.first(), Some(b'-' | b'+')));
|
||||
let int_end = digits(i);
|
||||
let mut end = int_end;
|
||||
let mut mantissa = int_end > i;
|
||||
if b.get(end) == Some(&b'.') {
|
||||
let frac_end = digits(end + 1);
|
||||
if mantissa || frac_end > end + 1 {
|
||||
mantissa = true;
|
||||
end = frac_end;
|
||||
}
|
||||
}
|
||||
if mantissa && matches!(b.get(end), Some(b'e' | b'E')) {
|
||||
i = end + 1 + usize::from(matches!(b.get(end + 1), Some(b'-' | b'+')));
|
||||
let exp_end = digits(i);
|
||||
if exp_end > i {
|
||||
end = exp_end;
|
||||
}
|
||||
}
|
||||
let v = if mantissa { b.get(..end).and_then(|t| std::str::from_utf8(t).ok()).and_then(|t| t.parse::<f64>().ok()) } else { None };
|
||||
let v = match v {
|
||||
Some(v) if v.is_finite() => v,
|
||||
Some(v) if v.is_infinite() => f64::MAX.copysign(v),
|
||||
_ => 0.0,
|
||||
};
|
||||
Ok(Value::Real(v))
|
||||
}
|
||||
|
||||
pub(super) fn chr(a: &[Value]) -> R {
|
||||
|
||||
@@ -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,37 @@ 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));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn atof_out_of_range_exponent_reads_whole_token() {
|
||||
let mut s = Session::new();
|
||||
// Overflow must not fall back to a shorter prefix such as "1e40".
|
||||
assert_eq!(ok(&mut s, "(> (atof \"1e400\") 1e300)"), "T");
|
||||
assert_eq!(ok(&mut s, "(< (atof \"-1e400\") -1e300)"), "T");
|
||||
assert_eq!(ok(&mut s, "(= (atof \"1e-400\") 0.0)"), "T");
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -13,3 +13,5 @@ mod files;
|
||||
mod interactive;
|
||||
mod picking;
|
||||
mod zoom;
|
||||
|
||||
pub(crate) use picking::group_10;
|
||||
|
||||
@@ -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.
|
||||
pub(crate) 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()))
|
||||
}
|
||||
|
||||
@@ -127,6 +127,9 @@ pub struct Settings {
|
||||
pub polardist: f64,
|
||||
/// AUDITCTL: 1 creates an audit report (.adt) file when AUDIT is run (default: 0).
|
||||
pub auditctl: bool,
|
||||
/// ATTREQ: 0 inserts blocks with their attributes' default values instead of asking for them
|
||||
/// (default: 1).
|
||||
pub attreq: bool,
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
@@ -191,6 +194,7 @@ impl Default for Settings {
|
||||
polarsnap: false,
|
||||
polardist: 0.0,
|
||||
auditctl: false,
|
||||
attreq: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,6 +94,28 @@ fn hidden_layers(d: &Drawing) -> impl Iterator<Item = &str> {
|
||||
d.layers.iter().filter(|l| !l.visible()).map(|l| l.name.as_str())
|
||||
}
|
||||
|
||||
/// A copy of `tree` with `removed` taken out and `inserted` added, or `None` when the caller must
|
||||
/// bulk-load instead. rstar 0.13 can panic ("This is a bug in rstar.") when inserting after removals
|
||||
/// in a bulk-loaded tree (#2468), and picking is outside the command guard, so that panic would end
|
||||
/// the app. The web build can't catch a panic, so it always bulk-loads.
|
||||
fn patch_tree(tree: &RTree<Item>, removed: impl Iterator<Item = Item>, inserted: impl Iterator<Item = Item>) -> Option<RTree<Item>> {
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
return None;
|
||||
}
|
||||
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
let mut tree = tree.clone();
|
||||
for it in removed {
|
||||
tree.remove(&it);
|
||||
}
|
||||
for it in inserted {
|
||||
tree.insert(it);
|
||||
}
|
||||
tree
|
||||
}))
|
||||
.map_err(|_| log::warn!("spatial index: rstar failed to patch the tree; rebuilding it"))
|
||||
.ok()
|
||||
}
|
||||
|
||||
impl SpatialIndex {
|
||||
/// Build an index over a store (no caching).
|
||||
pub fn build(d: &Drawing, store: &EntityStore) -> SpatialIndex {
|
||||
@@ -127,23 +149,11 @@ impl SpatialIndex {
|
||||
offset = offset.saturating_add(n);
|
||||
}
|
||||
// Patch the previous tree when most of it survives; otherwise bulk-load.
|
||||
let tree = match prev {
|
||||
Some(p) if kept.len() * 2 >= chunks.len().max(1) => {
|
||||
let mut tree = p.tree.clone();
|
||||
for (_, c) in p.chunks.iter().filter(|(_, c)| !kept.contains(&c.serial)) {
|
||||
for it in c.items() {
|
||||
tree.remove(&it);
|
||||
}
|
||||
}
|
||||
for c in &fresh {
|
||||
for it in c.items() {
|
||||
tree.insert(it);
|
||||
}
|
||||
}
|
||||
tree
|
||||
}
|
||||
_ => RTree::bulk_load(chunks.iter().flat_map(|(_, c)| c.items()).collect()),
|
||||
};
|
||||
let patched = prev.filter(|_| kept.len() * 2 >= chunks.len().max(1)).and_then(|p| {
|
||||
let removed = p.chunks.iter().filter(|(_, c)| !kept.contains(&c.serial)).flat_map(|(_, c)| c.items());
|
||||
patch_tree(&p.tree, removed, fresh.iter().flat_map(|c| c.items()))
|
||||
});
|
||||
let tree = patched.unwrap_or_else(|| RTree::bulk_load(chunks.iter().flat_map(|(_, c)| c.items()).collect()));
|
||||
let offsets = chunks.iter().map(|(off, c)| (c.serial, *off)).collect();
|
||||
let infinite = chunks.iter().flat_map(|(off, c)| c.infinite.iter().map(move |i| off + i)).collect();
|
||||
SpatialIndex {
|
||||
@@ -367,6 +377,40 @@ mod tests {
|
||||
Some(Arc::new(SpatialIndex::build(d, &d.model)))
|
||||
}
|
||||
|
||||
/// The edits of #2468, on rstar alone: its insert panics for seed 8. A failed patch falls back
|
||||
/// to a bulk load, and every tree holds exactly the live items.
|
||||
#[test]
|
||||
fn rstar_patch_failures_fall_back_to_a_rebuild() {
|
||||
fn item(r: &mut Rng, s: u32, i: u32) -> Item {
|
||||
let (x, y, w, h) = (r.f() * 1000.0, r.f() * 1000.0, r.f() * 50.0, r.f() * 50.0);
|
||||
GeomWithData::new(Rectangle::from_corners([x, y], [x + w, y + h]), (s, i))
|
||||
}
|
||||
for seed in 1..=10u64 {
|
||||
let mut r = Rng(seed * 7919);
|
||||
let n_chunks = 4 + (r.f() * 20.0) as u32;
|
||||
let mut chunks: Vec<Vec<Item>> = (0..n_chunks)
|
||||
.map(|s| {
|
||||
let n = 1 + (r.f() * 60.0) as u32;
|
||||
(0..n).map(|i| item(&mut r, s, i)).collect()
|
||||
})
|
||||
.collect();
|
||||
let mut tree = RTree::bulk_load(chunks.iter().flatten().cloned().collect());
|
||||
for serial in (n_chunks..).take(300) {
|
||||
let c = (r.f() * chunks.len() as f64) as usize;
|
||||
let n = (chunks[c].len() as i64 + if r.f() < 0.5 { -1 } else { 1 }).max(0) as u32;
|
||||
let fresh: Vec<Item> = (0..n).map(|i| item(&mut r, serial, i)).collect();
|
||||
let patched = patch_tree(&tree, chunks[c].iter().cloned(), fresh.iter().cloned());
|
||||
chunks[c] = fresh;
|
||||
tree = patched.unwrap_or_else(|| RTree::bulk_load(chunks.iter().flatten().cloned().collect()));
|
||||
let mut held: Vec<(u32, u32)> = tree.iter().map(|it| it.data).collect();
|
||||
let mut live: Vec<(u32, u32)> = chunks.iter().flatten().map(|it| it.data).collect();
|
||||
held.sort_unstable();
|
||||
live.sort_unstable();
|
||||
assert_eq!(held, live, "seed {seed}, serial {serial}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pick_parity_with_scan() {
|
||||
for seed in [3, 11, 97] {
|
||||
|
||||
@@ -8,6 +8,7 @@ mod table;
|
||||
|
||||
const SESSION_VARS: &[&str] = &[
|
||||
"AUDITCTL",
|
||||
"ATTREQ",
|
||||
"OSMODE",
|
||||
"OSNAPHATCH",
|
||||
"ANNOAUTOSCALE",
|
||||
@@ -70,6 +71,7 @@ const SESSION_VARS: &[&str] = &[
|
||||
"FONTFALLBACK",
|
||||
"SAVETIME",
|
||||
"SAVEFILEPATH",
|
||||
"ISAVEBAK",
|
||||
];
|
||||
|
||||
pub fn get(s: &Session, name: &str) -> Option<Value> {
|
||||
@@ -154,6 +156,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)),
|
||||
@@ -279,7 +282,9 @@ pub fn set(s: &mut Session, name: &str, v: &Value) -> Result<()> {
|
||||
let bad = || EngineError::BadParams { cmd: "setvar".into(), msg: format!("invalid value for {n}") };
|
||||
let st = &mut s.settings;
|
||||
match n.as_str() {
|
||||
"OSMODE" => st.osmode = v.as_u64().or_else(|| v.as_str().and_then(|s| s.trim().parse().ok())).ok_or_else(bad)? as u32 & 0x7fff,
|
||||
"OSMODE" => {
|
||||
st.osmode = v.as_u64().or_else(|| v.as_str().and_then(|s| s.trim().parse().ok())).filter(|x| *x <= 0x7fff).ok_or_else(bad)? as u32
|
||||
}
|
||||
// The `color` command's input: ByLayer, ByBlock, a name, an index or a true colour.
|
||||
"CECOLOR" => crate::cmd::set_current_color_value(s, v).ok_or_else(bad)?,
|
||||
"OSNAPHATCH" => st.osnaphatch = as_bool(v).ok_or_else(bad)?,
|
||||
@@ -366,6 +371,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 +381,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 +402,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)),
|
||||
@@ -405,7 +427,7 @@ pub fn set(s: &mut Session, name: &str, v: &Value) -> Result<()> {
|
||||
// Keep the header type stable for known numeric vars.
|
||||
match d.header.get(&n) {
|
||||
Some(cadcraft_doc::HVal::Real(_)) => {
|
||||
d.header.set_f64(&n, val.as_f64().ok_or_else(bad)?);
|
||||
d.header.set_f64(&n, val.as_f64().filter(|f| f.is_finite()).ok_or_else(bad)?);
|
||||
return Ok(());
|
||||
}
|
||||
Some(cadcraft_doc::HVal::Int(_)) => {
|
||||
|
||||
@@ -81,8 +81,11 @@ const LINEWEIGHTS: [i64; 24] = [0, 5, 9, 13, 15, 18, 20, 25, 30, 35, 40, 50, 53,
|
||||
|
||||
/// On/off session settings: name, read, write.
|
||||
type Toggle = (&'static str, fn(&Settings) -> bool, fn(&mut Settings, bool));
|
||||
const SETTINGS: &[Toggle] =
|
||||
&[("AUDITCTL", |s| s.auditctl, |s, v| s.auditctl = v), ("TRANSPARENCYDISPLAY", |s| s.transparency_display, |s, v| s.transparency_display = v)];
|
||||
const SETTINGS: &[Toggle] = &[
|
||||
("AUDITCTL", |s| s.auditctl, |s, v| s.auditctl = v),
|
||||
("ATTREQ", |s| s.attreq, |s, v| s.attreq = v),
|
||||
("TRANSPARENCYDISPLAY", |s| s.transparency_display, |s, v| s.transparency_display = v),
|
||||
];
|
||||
|
||||
fn header_var(name: &str) -> Option<&'static (&'static str, Kind, Def)> {
|
||||
HEADER.iter().find(|(n, _, _)| *n == name)
|
||||
|
||||
+76
-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,29 @@ 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 setvar_rejects_out_of_range_osmode() {
|
||||
let mut s = Session::new();
|
||||
s.execute("setvar", &json!({"name": "OSMODE", "value": 4133})).unwrap();
|
||||
for v in [4294967296u64, 65536, 32768] {
|
||||
assert!(s.execute("setvar", &json!({"name": "OSMODE", "value": v})).is_err(), "{v}");
|
||||
assert_eq!(sysvars::get(&s, "OSMODE"), Some(json!(4133)), "{v}");
|
||||
}
|
||||
s.execute("setvar", &json!({"name": "OSMODE", "value": 0x7fff})).unwrap();
|
||||
assert_eq!(sysvars::get(&s, "OSMODE"), Some(json!(0x7fff)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_input_pointer_settings() {
|
||||
let mut s = Session::new();
|
||||
@@ -680,25 +712,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 +1446,47 @@ 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();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setvar_rejects_non_finite_text_for_real_variables() {
|
||||
let mut s = Session::new();
|
||||
for name in ["LTSCALE", "TEXTSIZE", "SNAPANG"] {
|
||||
let before = s.execute("getvar", &json!({"name": name})).unwrap();
|
||||
for text in ["nan", "inf", "-inf"] {
|
||||
let r = s.execute("setvar", &json!({"name": name, "value": text}));
|
||||
assert!(r.is_err(), "SETVAR {name} {text} was accepted");
|
||||
assert_eq!(s.execute("getvar", &json!({"name": name})).unwrap(), before);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+139
-15
@@ -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 {
|
||||
@@ -133,7 +172,16 @@ pub fn format_angle(a: f64, aunits: i64, auprec: i64) -> String {
|
||||
} else {
|
||||
("S", "E", deg - 270.0)
|
||||
};
|
||||
format!("{ns} {} {ew}", format_dms(ang, prec))
|
||||
// A bearing shown as due north, south, east or west is that letter, as AutoCAD 2024
|
||||
// shows it ("E" for 0), except to whole degrees ("N 90d E").
|
||||
let dms = format_dms(ang, prec);
|
||||
if prec > 0 && dms == format_dms(0.0, prec) {
|
||||
ns.to_string()
|
||||
} else if prec > 0 && dms == format_dms(90.0, prec) {
|
||||
ew.to_string()
|
||||
} else {
|
||||
format!("{ns} {dms} {ew}")
|
||||
}
|
||||
}
|
||||
_ => format!("{:.*}", prec, wrap_full_turn(deg, 360.0, prec)),
|
||||
}
|
||||
@@ -293,6 +341,9 @@ fn parse_units(s: &str, aunits: i64) -> Option<(f64, bool)> {
|
||||
((num(g)? * 0.9).to_radians(), false)
|
||||
} else if let Some(b) = bearing(&t) {
|
||||
(b, true)
|
||||
} else if let Some(b) = (aunits == 4).then(|| cardinal(&t)).flatten() {
|
||||
// A due bearing as surveyor's units show it: one letter.
|
||||
(b, true)
|
||||
} else if let Some(v) = num(&t) {
|
||||
let a = match aunits {
|
||||
2 => (v * 0.9).to_radians(),
|
||||
@@ -306,6 +357,17 @@ fn parse_units(s: &str, aunits: i64) -> Option<(f64, bool)> {
|
||||
v.0.is_finite().then_some(v)
|
||||
}
|
||||
|
||||
/// A due bearing (`n`, `e`, `s`, `w`, lower case) as radians counterclockwise from +X.
|
||||
fn cardinal(t: &str) -> Option<f64> {
|
||||
match t {
|
||||
"e" => Some(0.0),
|
||||
"n" => Some(PI / 2.0),
|
||||
"w" => Some(PI),
|
||||
"s" => Some(1.5 * PI),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A surveyor's bearing (`n45d30'e`, lower case) as radians counterclockwise from +X.
|
||||
fn bearing(t: &str) -> Option<f64> {
|
||||
let north = t.starts_with('n');
|
||||
@@ -435,11 +497,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");
|
||||
@@ -476,6 +565,41 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn surveyors_units_read_a_due_bearing_typed_as_one_letter() {
|
||||
// What surveyor's units show for a due bearing can be typed back, e.g. `@10<E`.
|
||||
let a = AngleSettings { aunits: 4, angbase: 1.0, clockwise: true };
|
||||
for (t, want) in [("E", 0.0), ("n", PI / 2.0), ("W", PI), ("s", 1.5 * PI)] {
|
||||
let got = a.direction(t).unwrap();
|
||||
assert!((got - want).abs() < 1e-12, "{t}: {got}");
|
||||
}
|
||||
// Other angle units don't take a bare letter.
|
||||
assert_eq!(AngleSettings { aunits: 0, angbase: 0.0, clockwise: false }.direction("E"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn surveyors_units_show_a_due_bearing_as_one_letter() {
|
||||
// AutoCAD 2024's ANGTOS in surveyor's units (measured): due east, north, west and south are
|
||||
// one letter, also when the bearing only rounds to one; to whole degrees east stays a
|
||||
// bearing.
|
||||
let cases = [
|
||||
(0.0, 0, "N 90d E"),
|
||||
(0.0, 1, "E"),
|
||||
(0.0, 2, "E"),
|
||||
(0.0, 4, "E"),
|
||||
(PI / 2.0, 1, "N"),
|
||||
(PI, 4, "W"),
|
||||
(3.0 * PI / 2.0, 4, "S"),
|
||||
(0.00001, 2, "E"),
|
||||
(1.0, 1, "N 32d42' E"),
|
||||
(1.0, 3, "N 32d42'15\" E"),
|
||||
(1.0, 7, "N 32d42'15.194\" E"),
|
||||
];
|
||||
for (angle, prec, expected) in cases {
|
||||
assert_eq!(format_angle(angle, 4, prec), expected, "{angle} AUPREC {prec}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rounding_to_a_full_turn_wraps_to_zero() {
|
||||
let almost_turn = 359.9999_f64.to_radians();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -185,6 +185,14 @@ fn autosave_files_are_written_in_the_background_and_removed_on_exit() {
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
assert!(std::fs::read(&path).unwrap().ends_with(b"EOF\n"));
|
||||
// The file exists before the write finishes (the folder sync comes after the rename); until
|
||||
// it has, the drawing is busy and the change drawn below would not be planned.
|
||||
for _ in 0..500 {
|
||||
if !a.writing() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
// While the app runs, its lock keeps its files out of recovery. SAVETIME and SAVEFILEPATH
|
||||
// are system variables (process-wide, so set in this test only).
|
||||
assert_eq!(cadcraft_engine::sysvars::get(&s, "SAVETIME"), Some(json!(10)));
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user