mirror of
https://github.com/storytold/cadcraft.git
synced 2026-11-03 08:52:05 -06:00
Compare commits
211
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d43ce63cc0 | ||
|
|
fdfe4d95a8 | ||
|
|
f389d6a72e | ||
|
|
eacdbe43ee | ||
|
|
096b3f9be4 | ||
|
|
bf4d51bf61 | ||
|
|
2ac500925b | ||
|
|
5ec0d215d8 | ||
|
|
3f57f8d3d4 | ||
|
|
4e874d089a | ||
|
|
3bb515485d | ||
|
|
e4cc7ffc77 | ||
|
|
03101e8d77 | ||
|
|
0bc025965a | ||
|
|
03db6261e4 | ||
|
|
cd19b205b7 | ||
|
|
69e946586f | ||
|
|
fbc47b48e8 | ||
|
|
86f3fdc9e9 | ||
|
|
7690d36bb0 | ||
|
|
7bc76f94ca | ||
|
|
6660d88785 | ||
|
|
beb6c48a59 | ||
|
|
990ff2ce9f | ||
|
|
8de925e1d3 | ||
|
|
97679d8cff | ||
|
|
9972a739c5 | ||
|
|
0f56c16d00 | ||
|
|
a60d437f0a | ||
|
|
f2cb7e6e8f | ||
|
|
0e7c71759c | ||
|
|
facae4a9a6 | ||
|
|
ea127d4702 | ||
|
|
ad95791c20 | ||
|
|
e6352d955e | ||
|
|
4a17ee78c2 | ||
|
|
c0306b1af3 | ||
|
|
25ca905815 | ||
|
|
fbd6e676f8 | ||
|
|
0674391100 | ||
|
|
f4c68e18fc | ||
|
|
ed08716f30 | ||
|
|
ab15e42c6c | ||
|
|
c4ac8761e8 | ||
|
|
a277346c2f | ||
|
|
3146b47656 | ||
|
|
dae4b7b2f5 | ||
|
|
ebc67fa81d | ||
|
|
fa03bf2a29 | ||
|
|
b006110dc5 | ||
|
|
2034d6899e | ||
|
|
e8bcc38e34 | ||
|
|
152617f4df | ||
|
|
ccbc4fa854 | ||
|
|
f0b46e34dc | ||
|
|
06c5760ea6 | ||
|
|
2d02a5faa5 | ||
|
|
5cc342b033 | ||
|
|
fb05c78590 | ||
|
|
5e9c478bfb | ||
|
|
e187c5fecb | ||
|
|
c72c0eebfb | ||
|
|
4a9371ad2c | ||
|
|
0e2bff3b2b | ||
|
|
210cfc2a1d | ||
|
|
520394858c | ||
|
|
020d81a27e | ||
|
|
4b4ec8043e | ||
|
|
7400119120 | ||
|
|
258c89fbb0 | ||
|
|
d7ef7da872 | ||
|
|
9c84141e38 | ||
|
|
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 | ||
|
|
57e6a89d10 | ||
|
|
282ad11e60 | ||
|
|
768d25bb7d | ||
|
|
ba3196e3f7 | ||
|
|
8038de04e4 | ||
|
|
8d1bd19ee2 | ||
|
|
43c41b8e68 | ||
|
|
18d3aa4ed8 | ||
|
|
189bfcbf08 | ||
|
|
8252d0e9bd | ||
|
|
29584fa4c4 | ||
|
|
6350c5b145 | ||
|
|
50df1284a3 | ||
|
|
16ae23424d | ||
|
|
dda6562a97 | ||
|
|
2be869a154 | ||
|
|
6ec6d97ea0 | ||
|
|
3ba2362ec9 | ||
|
|
0ae424c771 | ||
|
|
b883d94bf7 | ||
|
|
1e96a3d50c | ||
|
|
7baa970381 | ||
|
|
590b97a321 | ||
|
|
f536bf406f | ||
|
|
d6de2db6db | ||
|
|
86d010eaac | ||
|
|
dc0564a7a7 | ||
|
|
f8b09e92e5 | ||
|
|
b3158b56a8 | ||
|
|
fc8270d8c5 | ||
|
|
b301d5782d | ||
|
|
7546277128 | ||
|
|
2d2be155dd | ||
|
|
f883454d7d | ||
|
|
9b4c1fb840 | ||
|
|
912558635e | ||
|
|
42667d1332 | ||
|
|
d15de75406 | ||
|
|
dcbcb6ab32 | ||
|
|
0d9e62bc9a | ||
|
|
3243271d4e | ||
|
|
1013b482a8 | ||
|
|
303bc97f9a | ||
|
|
b9b7b3d439 | ||
|
|
1d987904f9 | ||
|
|
1d6c72db11 | ||
|
|
61fefc3ed9 | ||
|
|
74e77f8a92 | ||
|
|
f817ea9a3a | ||
|
|
31dba6e7a7 | ||
|
|
58687dda2d | ||
|
|
3ccfad60dc | ||
|
|
35b4332ee2 | ||
|
|
9a1f200795 | ||
|
|
bcf0dbc5e8 | ||
|
|
c2695f6ab0 | ||
|
|
7efb32b455 | ||
|
|
ab8953067a | ||
|
|
afc12e4dce | ||
|
|
89771962fc | ||
|
|
49fdbff529 | ||
|
|
2eecaef6c2 | ||
|
|
b6c1ab74f9 | ||
|
|
c5d186ae92 | ||
|
|
9ff37ba262 | ||
|
|
c2cb5b8610 | ||
|
|
c45b49acc7 | ||
|
|
4226a2610a | ||
|
|
263eba2ef6 | ||
|
|
b17ae78cec | ||
|
|
336243e0af | ||
|
|
9de2b8bbdc | ||
|
|
4398a1aab1 | ||
|
|
8b8e921f6a | ||
|
|
62b9b5acd4 | ||
|
|
069d276fe5 | ||
|
|
4fdde4f301 | ||
|
|
0646dca86e | ||
|
|
ea02e45099 | ||
|
|
7af20d0bb2 | ||
|
|
6ed822ec30 | ||
|
|
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.
|
||||
|
||||
+11
-9
@@ -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,14 +65,16 @@ 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 |
|
||||
| `crates/ui-egui/src/i18n/zh.tsv` | @mayloglog | Original Simplified Chinese translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
|
||||
| `crates/ui-egui/src/i18n/sv.tsv` | Erik Holmbom (@holmbom) with Claude Code (AI-assisted) | Original Swedish translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied; terminology and the running app reviewed by a native speaker |
|
||||
| `crates/ui-egui/src/i18n/it.tsv` | Emanuele Bottelli (AI-assisted) | Original Italian translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
|
||||
| `crates/ui-egui/src/i18n/it.tsv` | Emanuele Bottelli (AI-assisted); terminology corrections: Andrea Brandi (@starise) with OpenAI Codex (AI-assisted) | Original Italian translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied; targeted Chamfer terminology review by a native speaker |
|
||||
| `crates/ui-egui/src/i18n/ko.tsv` | @huk082 (AI-assisted) | Original Korean translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied; no font assets added |
|
||||
| `crates/ui-egui/src/i18n/ar.tsv` | @hasan-sdaif | Original Arabic translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
|
||||
| `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
+54
-19
@@ -614,7 +614,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft"
|
||||
version = "0.14.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"cadcraft-engine",
|
||||
"cadcraft-io",
|
||||
@@ -632,6 +632,7 @@ dependencies = [
|
||||
"muda",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"rfd",
|
||||
"ron",
|
||||
"serde_json",
|
||||
"sys-locale",
|
||||
"thiserror 2.0.21",
|
||||
@@ -643,7 +644,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-cli"
|
||||
version = "0.14.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"cadcraft-engine",
|
||||
"cadcraft-io",
|
||||
@@ -659,14 +660,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-color"
|
||||
version = "0.14.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-constraints"
|
||||
version = "0.14.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"cadcraft-doc",
|
||||
"cadcraft-geom",
|
||||
@@ -676,7 +677,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-doc"
|
||||
version = "0.14.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-geom",
|
||||
@@ -687,14 +688,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-dwg"
|
||||
version = "0.14.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"acadrust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-dxf"
|
||||
version = "0.14.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"log",
|
||||
@@ -703,7 +704,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-engine"
|
||||
version = "0.14.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-constraints",
|
||||
@@ -724,24 +725,25 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-fonts"
|
||||
version = "0.14.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"cadcraft-geom",
|
||||
"harfrust 0.14.0",
|
||||
"icu_segmenter",
|
||||
"skrifa 0.48.0",
|
||||
"unicode-bidi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-geom"
|
||||
version = "0.14.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-io"
|
||||
version = "0.14.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"acadrust",
|
||||
"cadcraft-color",
|
||||
@@ -762,7 +764,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-mcp"
|
||||
version = "0.14.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"cadcraft-engine",
|
||||
"cadcraft-io",
|
||||
@@ -775,7 +777,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-render"
|
||||
version = "0.14.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-doc",
|
||||
@@ -789,7 +791,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-ui-egui"
|
||||
version = "0.14.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-doc",
|
||||
@@ -815,7 +817,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-web"
|
||||
version = "0.14.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"cadcraft-engine",
|
||||
"cadcraft-io",
|
||||
@@ -1115,6 +1117,15 @@ version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48"
|
||||
|
||||
[[package]]
|
||||
name = "core_maths"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30"
|
||||
dependencies = [
|
||||
"libm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
@@ -1191,13 +1202,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",
|
||||
@@ -2434,6 +2446,29 @@ dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_segmenter"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "82d07aafccd67af15d02512a6adf5896fbc5ed00f2e99b471d2efa14016db3db"
|
||||
dependencies = [
|
||||
"core_maths",
|
||||
"icu_collections",
|
||||
"icu_locale_fallback",
|
||||
"icu_provider",
|
||||
"icu_segmenter_data",
|
||||
"potential_utf",
|
||||
"smallvec",
|
||||
"utf8_iter",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_segmenter_data"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae293c039020f9ec10710af98d29ce6aa2051486638b49c9a6409f3b4a9e98ad"
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "1.1.0"
|
||||
@@ -5893,7 +5928,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.14.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"serde_json",
|
||||
@@ -6114,9 +6149,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",
|
||||
|
||||
+6
-2
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
# default-members
|
||||
|
||||
[workspace.package]
|
||||
version = "0.14.0"
|
||||
version = "0.17.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -51,6 +51,9 @@ skrifa = "0.48"
|
||||
# so the graph has both) and the Unicode Bidirectional Algorithm for right-to-left drawing text.
|
||||
harfrust = "0.14"
|
||||
unicode-bidi = "0.3.18"
|
||||
# Line-break opportunities between Thai words for MTEXT word wrap (Thai writes no spaces between
|
||||
# words): UAX #14 with ICU4X's compiled-in LSTM word model (not its dictionaries).
|
||||
icu_segmenter = { version = "~2.3", default-features = false, features = ["compiled_data", "lstm"] }
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
|
||||
encoding_rs = "0.8.35"
|
||||
# XML for the SVG reader (streaming parser, no entity expansion beyond the five predefined ones)
|
||||
@@ -65,7 +68,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 }
|
||||
|
||||
|
||||
@@ -230,6 +230,11 @@ How image exports (PNG 2400×1600, SVG, PDF) are framed: `convert` fits the mode
|
||||
rectangle, centred and scaled without distortion; `--view extents` or `--view current` forces
|
||||
either framing. To frame a selection set, `zoom {"mode":"object"}` after selecting.
|
||||
|
||||
When reading a file leaves something out (records a DXF reader can't place, object records of a
|
||||
damaged DWG), `info` reports it (`skipped`, `readNotes`), `convert` warns on stderr and `run`
|
||||
prints the DWG notes with its log; the exit code stays 0. MCP `open` replies with the notes as
|
||||
`warnings`.
|
||||
|
||||
MCP tools include `command_line` (type at the prompt), `execute` (any command with JSON),
|
||||
`inspect_drawing`, `query_entities`, `render` (returns a PNG) and, when connected to the app,
|
||||
`screenshot` and `ui_click`. The desktop app's JSON control channel is documented in
|
||||
@@ -377,7 +382,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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::args::ConvertArgs;
|
||||
use super::error::Result;
|
||||
use super::output::{Streams, export_framed, export_window, skipped_message, undrawn_message};
|
||||
use super::output::{Streams, export_framed, export_window, read_note_messages, skipped_message, undrawn_message};
|
||||
use super::{open, unicode};
|
||||
use cadcraft_engine::Session;
|
||||
|
||||
@@ -9,7 +9,8 @@ pub(crate) fn convert(a: &ConvertArgs, io: &mut Streams) -> Result {
|
||||
let framing = a.framing.framing();
|
||||
let mut s = Session::empty();
|
||||
open(&mut s, &a.input)?;
|
||||
if let Some(m) = skipped_message(s.doc()?, i) {
|
||||
let d = s.doc()?;
|
||||
for m in read_note_messages(d, i).into_iter().chain(skipped_message(d, i)) {
|
||||
io.errln(format_args!("{m}"));
|
||||
}
|
||||
if cadcraft_io::is_image(&a.output)
|
||||
|
||||
@@ -122,10 +122,21 @@ pub(crate) fn export_window(s: &Session, framing: Framing, before: Option<&[(u64
|
||||
/// Write `bytes` to `out` through the engine's atomic writer (an exclusively created sibling file
|
||||
/// renamed over `out`), so a failed or interrupted write leaves the previous file untouched. An
|
||||
/// existing `out` is resolved first: the rename would otherwise replace a symlink with a file and
|
||||
/// leave the file it points to (`--in-place` through a link) unchanged.
|
||||
pub(crate) fn write_atomic(out: &Path, bytes: &[u8]) -> Result<(), OutputError> {
|
||||
/// leave the file it points to (`--in-place` through a link) unchanged. Folders missing above `out`
|
||||
/// are created, and the topmost one returned for the caller to report.
|
||||
pub(crate) fn write_atomic(out: &Path, bytes: &[u8]) -> Result<Option<std::path::PathBuf>, OutputError> {
|
||||
let target = std::fs::canonicalize(out).unwrap_or_else(|_| out.to_path_buf());
|
||||
cadcraft_engine::atomic_write::write_atomic(&target, bytes).map_err(|source| OutputError::Write { path: out.to_path_buf(), source })
|
||||
let folder = cadcraft_engine::atomic_write::missing_folder(&target);
|
||||
cadcraft_engine::atomic_write::write_atomic_making_folders(&target, bytes)
|
||||
.map_err(|source| OutputError::Write { path: out.to_path_buf(), source })?;
|
||||
Ok(folder)
|
||||
}
|
||||
|
||||
/// Say that `out` went into a folder that didn't exist, so a mistyped path is noticed.
|
||||
pub(crate) fn report_new_folder(folder: Option<std::path::PathBuf>, io: &mut Streams) {
|
||||
if let Some(folder) = folder {
|
||||
io.errln(format_args!("created the folder {}, which did not exist", folder.display()));
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn export(s: &Session, out: &Path, io: &mut Streams) -> Result {
|
||||
@@ -135,7 +146,8 @@ pub(crate) fn export(s: &Session, out: &Path, io: &mut Streams) -> Result {
|
||||
pub(crate) fn export_framed(s: &Session, out: &Path, window: Option<Bounds2>, io: &mut Streams) -> Result {
|
||||
let d = s.doc()?;
|
||||
let bytes = cadcraft_io::write_framed(d, out, window)?;
|
||||
write_atomic(out, &bytes)?;
|
||||
let folder = write_atomic(out, &bytes)?;
|
||||
report_new_folder(folder, io);
|
||||
io.errln(format_args!("wrote {} ({} bytes)", out.display(), bytes.len()));
|
||||
if cadcraft_engine::cmd::file::drops_unknown(out) {
|
||||
let lost = cadcraft_engine::cmd::file::not_saved(d);
|
||||
@@ -165,6 +177,12 @@ pub(crate) fn undrawn_message(d: &cadcraft_engine::doc::Drawing, path: &str) ->
|
||||
Some(format!("{path}: {n} {entities} not drawn ({})", list.join(", ")))
|
||||
}
|
||||
|
||||
/// The reader's notes on what it left out of `path` (a damaged DWG's unreadable object records,
|
||||
/// #859), one warning line each; empty for a complete read.
|
||||
pub(crate) fn read_note_messages(d: &cadcraft_engine::doc::Drawing, path: &str) -> Vec<String> {
|
||||
d.read_notes.iter().map(|note| format!("{path}: {note}")).collect()
|
||||
}
|
||||
|
||||
/// The warning for the records of `path` the reader could not place, or `None` when there are none.
|
||||
pub(crate) fn skipped_message(d: &cadcraft_engine::doc::Drawing, path: &str) -> Option<String> {
|
||||
let n = d.skipped_records.values().fold(0usize, |a, n| a.saturating_add(*n));
|
||||
@@ -175,3 +193,20 @@ pub(crate) fn skipped_message(d: &cadcraft_engine::doc::Drawing, path: &str) ->
|
||||
let records = if n == 1 { "record" } else { "records" };
|
||||
Some(format!("{path}: {n} {records} could not be read and are left out ({})", list.join(", ")))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::read_note_messages;
|
||||
|
||||
#[test]
|
||||
fn read_notes_become_one_warning_each() {
|
||||
let mut d = cadcraft_engine::doc::Drawing::new_imperial();
|
||||
assert!(read_note_messages(&d, "a.dwg").is_empty());
|
||||
d.read_notes =
|
||||
vec!["DWG: 3 of 10 object records couldn't be read and were left out (LINE × 3); saving over the original file would lose them".into()];
|
||||
assert_eq!(
|
||||
read_note_messages(&d, "a.dwg"),
|
||||
vec!["a.dwg: DWG: 3 of 10 object records couldn't be read and were left out (LINE × 3); saving over the original file would lose them"]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::args::{SampleArgs, SampleName};
|
||||
use super::error::Result;
|
||||
use super::output::{Streams, write_atomic};
|
||||
use super::output::{Streams, report_new_folder, write_atomic};
|
||||
|
||||
pub(crate) fn sample(a: &SampleArgs, io: &mut Streams) -> Result {
|
||||
let d = match a.name {
|
||||
@@ -8,7 +8,8 @@ pub(crate) fn sample(a: &SampleArgs, io: &mut Streams) -> Result {
|
||||
SampleName::Floorplan => cadcraft_engine::sample::floor_plan(),
|
||||
};
|
||||
let bytes = cadcraft_io::write(&d, &a.output)?;
|
||||
write_atomic(&a.output, &bytes)?;
|
||||
let folder = write_atomic(&a.output, &bytes)?;
|
||||
report_new_folder(folder, io);
|
||||
io.errln(format_args!("wrote {} ({} bytes)", a.output.display(), bytes.len()));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
//! A damaged DWG opens partially (#859); `info` lists the reader's notes on what it left out
|
||||
//! (`readNotes`) and `convert` warns with them before writing the copy, so a script or agent sees
|
||||
//! the loss. The exit code stays 0.
|
||||
|
||||
use std::process::{Command, Output};
|
||||
|
||||
fn cli(args: &[&str]) -> Output {
|
||||
Command::new(env!("CARGO_BIN_EXE_cadcraft-cli")).args(args).output().unwrap()
|
||||
}
|
||||
|
||||
/// A DWG of 200 lines, and a copy damaged so that the reader skips some of its records (found the
|
||||
/// way the reader's own test finds one: flipping a few bytes at a time across the objects area).
|
||||
fn dwg_and_damaged_copy() -> (Vec<u8>, Vec<u8>) {
|
||||
let mut dxf = String::from("0\nSECTION\n2\nENTITIES\n");
|
||||
for i in 0..200 {
|
||||
dxf += &format!("0\nLINE\n8\n0\n10\n{i}\n20\n0\n11\n{i}\n21\n10\n");
|
||||
}
|
||||
dxf += "0\nENDSEC\n0\nEOF\n";
|
||||
let d = cadcraft_io::read(dxf.as_bytes(), "lines.dxf").unwrap();
|
||||
let opts = cadcraft_io::SaveOptions { version: Some(cadcraft_io::Version::R2000), binary: false };
|
||||
let dwg = cadcraft_io::write_with(&d, "lines.dwg", &opts).unwrap();
|
||||
let damaged = (dwg.len() / 4..dwg.len() * 3 / 5)
|
||||
.step_by(97)
|
||||
.map(|at| {
|
||||
let mut b = dwg.clone();
|
||||
b[at..at + 6].iter_mut().for_each(|x| *x ^= 0xA5);
|
||||
b
|
||||
})
|
||||
.find(|b| cadcraft_io::read(b, "damaged.dwg").is_ok_and(|d| !d.read_notes.is_empty()))
|
||||
.expect("no damaged copy read partially");
|
||||
(dwg, damaged)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn info_lists_and_convert_warns_about_read_notes() {
|
||||
let dir = std::env::temp_dir().join(format!("cc-cli-read-notes-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let (whole, damaged) = dwg_and_damaged_copy();
|
||||
let path = dir.join("damaged.dwg");
|
||||
std::fs::write(&path, &damaged).unwrap();
|
||||
let path = path.to_str().unwrap();
|
||||
|
||||
let out = cli(&["info", path]);
|
||||
assert!(out.status.success(), "{:?}: {}", out.status, String::from_utf8_lossy(&out.stderr));
|
||||
let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
|
||||
let notes = v["readNotes"].as_array().unwrap();
|
||||
assert_eq!(notes.len(), 1, "{v}");
|
||||
assert!(notes[0].as_str().unwrap().contains(" couldn't be read and "), "{v}");
|
||||
|
||||
let copy = dir.join("copy.dxf");
|
||||
let out = cli(&["convert", path, copy.to_str().unwrap()]);
|
||||
assert!(out.status.success(), "{:?}: {}", out.status, String::from_utf8_lossy(&out.stderr));
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
let warnings: Vec<&str> = err.lines().filter(|l| l.contains(" couldn't be read and ")).collect();
|
||||
assert_eq!(warnings.len(), 1, "{err}");
|
||||
assert!(warnings[0].starts_with(&format!("{path}: DWG: ")), "{err}");
|
||||
assert!(copy.exists());
|
||||
|
||||
// A complete read: no notes, and no warning.
|
||||
let intact = dir.join("whole.dwg");
|
||||
std::fs::write(&intact, &whole).unwrap();
|
||||
let out = cli(&["info", intact.to_str().unwrap()]);
|
||||
let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
|
||||
assert_eq!(v["readNotes"], serde_json::json!([]));
|
||||
let out = cli(&["convert", intact.to_str().unwrap(), copy.to_str().unwrap()]);
|
||||
assert!(out.status.success());
|
||||
assert!(!String::from_utf8_lossy(&out.stderr).contains("couldn't be read"), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,9 @@ log = { workspace = true }
|
||||
clap = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
# Reads eframe's app.ron to tell whether a window state was saved (main.rs `saved_window`).
|
||||
# Already in Cargo.lock through eframe; same version so nothing is built twice.
|
||||
ron = "0.12"
|
||||
|
||||
[build-dependencies]
|
||||
winresource = "0.1"
|
||||
@@ -50,6 +53,9 @@ muda = { version = "0.21", default-features = false }
|
||||
# View ▸ Accessibility ▸ Animation Effects ▸ Use System Setting: the system's Reduce motion
|
||||
# (system_animations.rs). Already in Cargo.lock through muda.
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSWorkspace", "NSAccessibility"] }
|
||||
# The same winit eframe uses (the storytold fork, patched in the workspace): its opt-in that turns
|
||||
# AppKit's terminate (Cmd+Q, Dock Quit) into a close request, so unsaved changes are asked about.
|
||||
winit = { version = "0.30", default-features = false }
|
||||
|
||||
# Rust HID backends: no proprietary SDK or C HID library.
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
|
||||
@@ -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() {
|
||||
|
||||
+84
-19
@@ -127,7 +127,8 @@ impl eframe::App for App {
|
||||
self.cad.cancel_screenshots();
|
||||
self.cad.shutdown_autosave();
|
||||
}
|
||||
/// Preferences (the interface theme) survive restarts; window and egui state are not kept.
|
||||
/// Preferences (the interface theme) survive restarts; eframe saves the window state itself
|
||||
/// (`persist_window`); egui state is not kept.
|
||||
fn save(&mut self, storage: &mut dyn eframe::Storage) {
|
||||
storage.set_string(cadcraft_ui_egui::options::PROFILE_KEY, self.cad.profile_json());
|
||||
storage.set_string(cadcraft_ui_egui::PREFS_KEY, self.cad.prefs_json());
|
||||
@@ -194,8 +195,51 @@ fn filters_for_save(name: &str) -> [(&'static str, &'static str); 6] {
|
||||
filters
|
||||
}
|
||||
|
||||
/// Whether eframe's storage file (`app.ron`, a map of keys to strings) holds a saved main window
|
||||
/// state. An unreadable or malformed file counts as none, as it does for eframe.
|
||||
fn saved_window(app_ron: &str) -> bool {
|
||||
ron::from_str::<std::collections::HashMap<String, String>>(app_ron).is_ok_and(|kv| kv.contains_key(WINDOW_KEY))
|
||||
}
|
||||
|
||||
/// The window state from the last session is saved and will be restored (#2595).
|
||||
fn restores_window() -> bool {
|
||||
eframe::storage_dir(APP_ID).and_then(|dir| std::fs::read_to_string(dir.join("app.ron")).ok()).is_some_and(|ron| saved_window(&ron))
|
||||
}
|
||||
|
||||
/// The main window: its size, title bar, placement and rendering needs. `restoring`: a window state
|
||||
/// saved by the last session is restored instead of opening centred.
|
||||
fn window_options(restoring: bool) -> 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),
|
||||
// The size, position and maximized state are kept between sessions (#2595); eframe keeps a
|
||||
// restored window on the monitors that are connected now.
|
||||
persist_window: true,
|
||||
// The first launch opens centred on its screen, not cascaded from the top-left corner
|
||||
// (#1129); later launches go back where the window was (eframe would centre over that).
|
||||
// Wayland compositors place windows themselves.
|
||||
centered: !restoring,
|
||||
// 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";
|
||||
|
||||
/// The key eframe saves the main window state under in its storage.
|
||||
const WINDOW_KEY: &str = "window";
|
||||
|
||||
/// Stencil bits of the window's render pass (see `NativeOptions::stencil_buffer`).
|
||||
const STENCIL_BITS: u8 = 8;
|
||||
|
||||
@@ -218,7 +262,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 +300,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(restores_window())
|
||||
};
|
||||
if let Some(icon) = app_icon() {
|
||||
options.viewport = options.viewport.with_icon(icon);
|
||||
@@ -300,6 +329,16 @@ fn main() -> eframe::Result {
|
||||
b.with_x11();
|
||||
}));
|
||||
}
|
||||
// macOS: Quit CADCraft (Cmd+Q), the Dock's Quit and logout end the app through AppKit's
|
||||
// terminate, which would skip the unsaved-changes prompt (#2157). winit turns each into a
|
||||
// close request for the window instead, so it asks first, as the close button does.
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
options.event_loop_builder = Some(Box::new(|b| {
|
||||
use winit::platform::macos::EventLoopBuilderExtMacOS;
|
||||
b.with_close_requested_on_terminate(true);
|
||||
}));
|
||||
}
|
||||
eframe::run_native(
|
||||
"CADCraft",
|
||||
options,
|
||||
@@ -383,7 +422,7 @@ fn main() -> eframe::Result {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{filters_for_save, follow_desktop};
|
||||
use super::{filters_for_save, follow_desktop, saved_window, window_options};
|
||||
|
||||
#[test]
|
||||
fn save_dialog_offers_dwg_with_the_drawing_format_first() {
|
||||
@@ -401,6 +440,32 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// #1129: the first launch opens centred, not cascaded from the top-left corner.
|
||||
#[test]
|
||||
fn the_first_launch_opens_centred() {
|
||||
let o = window_options(false);
|
||||
assert!(o.centered, "centred on launch");
|
||||
assert!(o.persist_window, "the window state is saved for the next launch");
|
||||
}
|
||||
|
||||
/// #2595: a saved window state (size, position, maximized) is restored, not centred over.
|
||||
#[test]
|
||||
fn a_saved_window_reopens_where_it_was() {
|
||||
let o = window_options(true);
|
||||
assert!(!o.centered, "centring would move a restored window");
|
||||
assert!(o.persist_window);
|
||||
}
|
||||
|
||||
/// Only a readable `app.ron` with a window entry counts as a saved window state.
|
||||
#[test]
|
||||
fn a_saved_window_is_read_from_app_ron() {
|
||||
assert!(saved_window(r#"{"cadcraft.prefs": "{}", "window": "(maximized: true)"}"#));
|
||||
assert!(!saved_window(r#"{"cadcraft.prefs": "{\"window\":1}"}"#), "a value mentioning it is not the key");
|
||||
assert!(!saved_window(""));
|
||||
assert!(!saved_window("{\"window\": "), "truncated");
|
||||
assert!(!saved_window("not ron at all"));
|
||||
}
|
||||
|
||||
/// 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,17 @@ 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
|
||||
|
||||
[people.starise]
|
||||
real_name = "Andrea Brandi"
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -11,16 +11,13 @@ use std::collections::HashSet;
|
||||
use cadcraft_doc::{Constraint, Drawing, Handle};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::model::Model;
|
||||
use crate::{Comp, MAX_CONSTRAINTS, SolveError, SolveOptions, build_budgeted, lm};
|
||||
use crate::model::{Model, Prep};
|
||||
use crate::{Comp, MAX_CONSTRAINTS, MAX_EVAL_WORK, SolveError, SolveOptions, build_with, lm};
|
||||
|
||||
/// Most free parameters for which per-object freedom is analysed (null-space basis size).
|
||||
const MAX_NULL_COLS: usize = 600;
|
||||
/// Largest Jacobian (rows × columns × min) whose rank is computed.
|
||||
const MAX_RANK_WORK: f64 = 4e8;
|
||||
/// Largest residual-evaluation work (rows × free × per-residual cost, including spline
|
||||
/// reconstruction) allowed before building the Jacobian during numerical differentiation.
|
||||
const MAX_EVAL_WORK: f64 = 2e9;
|
||||
/// Most objects listed in one report.
|
||||
const MAX_OBJECTS: usize = 10_000;
|
||||
|
||||
@@ -139,8 +136,8 @@ fn plural(n: usize, one: &str, many: &str) -> String {
|
||||
/// (constrained or not) and the constraint components they belong to.
|
||||
pub fn dof(d: &Drawing, handles: &[Handle]) -> Result<DofReport, SolveError> {
|
||||
let cons: Vec<&Constraint> = d.constraints.iter().take(MAX_CONSTRAINTS).collect();
|
||||
let mut members_left = crate::model::MAX_RIGID_WORK;
|
||||
let (comps, _) = build_budgeted(d, &cons, &SolveOptions::default(), &mut members_left)?;
|
||||
let mut prep = Prep::new(crate::model::MAX_RIGID_WORK);
|
||||
let (comps, _) = build_with(d, &cons, &SolveOptions::default(), &mut prep)?;
|
||||
let want = |h: Handle| handles.is_empty() || handles.contains(&h);
|
||||
let mut objects = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
@@ -179,7 +176,7 @@ pub fn dof(d: &Drawing, handles: &[Handle]) -> Result<DofReport, SolveError> {
|
||||
if seen.contains(&h) {
|
||||
continue;
|
||||
}
|
||||
let m = Model::build_budgeted(d, &[h], &mut members_left);
|
||||
let m = Model::build_with(d, &[h], &mut prep);
|
||||
let Some(s) = m.slots.first() else { continue };
|
||||
seen.insert(h);
|
||||
free_objects = true;
|
||||
@@ -237,5 +234,36 @@ mod budget_tests {
|
||||
};
|
||||
let comp = Comp { model, built: vec![endpoint], weights: vec![1.0; 2], held: Vec::new(), tol: 1e-9 };
|
||||
assert!(analyse(&comp).dof.is_none());
|
||||
// Solving it is refused for the same reason, before any Jacobian is built.
|
||||
assert!(matches!(comp.run(None), Err(SolveError::TooLarge)));
|
||||
}
|
||||
|
||||
/// Many Tangent constraints on one large fit spline rebuild it once while being interpreted,
|
||||
/// and the solve is refused by its rebuild budget instead of running.
|
||||
#[test]
|
||||
fn repeated_spline_tangents_rebuild_once_and_solve_is_bounded() {
|
||||
use cadcraft_doc::{Constraint, Line};
|
||||
use cadcraft_geom::Vec3;
|
||||
let mut d = Drawing::default();
|
||||
let pts: Vec<_> = (0..400).map(|i| Vec2::new(i as f64, (i % 3) as f64)).collect();
|
||||
let h = d.add(&Space::Model, Common::default(), EntityKind::Spline(Spline::fit_with(&pts, false, Default::default()))).unwrap();
|
||||
let l =
|
||||
d.add(&Space::Model, Common::default(), EntityKind::Line(Line { a: Vec3::new(-1.0, 0.0, 0.0), b: Vec3::new(-1.0, 5.0, 0.0) })).unwrap();
|
||||
for id in 1..=200 {
|
||||
d.constraints.push(Constraint {
|
||||
id,
|
||||
kind: ConstraintKind::Tangent,
|
||||
refs: vec![GeomRef::whole(h), GeomRef::whole(l)],
|
||||
name: String::new(),
|
||||
expr: String::new(),
|
||||
});
|
||||
}
|
||||
let cons: Vec<&Constraint> = d.constraints.iter().collect();
|
||||
let (comps, skipped) = crate::build(&d, &cons, &SolveOptions::default()).unwrap();
|
||||
assert!(skipped.is_empty());
|
||||
let comp = comps.first().unwrap();
|
||||
assert_eq!(comp.built.len(), 200);
|
||||
assert!(matches!(comp.run(None), Err(SolveError::TooLarge)));
|
||||
assert!(crate::purge_invalid(&mut d).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ pub use dof::{DofReport, DofStatus, ObjectDof, dof};
|
||||
pub use expr::{Env, ExprError};
|
||||
pub use model::{ref_points, rigid_points, supported};
|
||||
|
||||
use model::Model;
|
||||
use model::{Model, Prep};
|
||||
use residual::{Built, Form};
|
||||
|
||||
/// Most constraints one drawing may hold.
|
||||
@@ -50,6 +50,11 @@ pub const MAX_CONSTRAINTS: usize = 5000;
|
||||
const MAX_CONFLICT_PROBES: usize = 48;
|
||||
/// Solver iterations.
|
||||
const MAX_ITER: usize = 200;
|
||||
/// Largest residual-evaluation work (including spline rebuilds) one numerical Jacobian may take,
|
||||
/// shared by solving, redundancy checks and DOF analysis.
|
||||
pub(crate) const MAX_EVAL_WORK: f64 = 2e9;
|
||||
/// Residual evaluations a solver iteration makes besides its Jacobian (damped trial steps).
|
||||
const TRIAL_EVALS: f64 = 14.0;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, thiserror::Error)]
|
||||
pub enum SolveError {
|
||||
@@ -251,12 +256,41 @@ impl Comp {
|
||||
pub(crate) fn rows(&self, skip: Option<usize>) -> usize {
|
||||
self.built.iter().enumerate().filter(|(i, _)| Some(*i) != skip).map(|(_, b)| b.rows).sum()
|
||||
}
|
||||
/// Work of one solver iteration's residual evaluations over `free`: every residual twice per
|
||||
/// free parameter, plus a spline rebuild each time a spline's own defining points move (rebuilds
|
||||
/// are shared by residuals at the same points, see [`Model`]).
|
||||
pub(crate) fn eval_work(&self, free: &[usize], skip: Option<usize>) -> f64 {
|
||||
let rows = self.rows(skip) as f64;
|
||||
let evals = 2.0 * free.len() as f64 + TRIAL_EVALS;
|
||||
let mut splines: Vec<Handle> = Vec::new();
|
||||
for (i, b) in self.built.iter().enumerate() {
|
||||
if Some(i) != skip && b.spline_reconstruct_cost(&self.model) > 0.0 {
|
||||
splines.extend(b.refs.iter().map(|r| r.handle).filter(|h| self.model.spline_reconstruct_cost(*h) > 0.0));
|
||||
}
|
||||
}
|
||||
splines.sort_unstable();
|
||||
splines.dedup();
|
||||
let rebuilds: f64 = splines
|
||||
.iter()
|
||||
.filter_map(|h| self.model.slot(*h))
|
||||
.map(|s| {
|
||||
let own = free.iter().filter(|j| (s.off..s.off + s.len).contains(*j)).count() as f64;
|
||||
(2.0 * own + TRIAL_EVALS + 1.0) * self.model.spline_reconstruct_cost(s.handle)
|
||||
})
|
||||
.sum();
|
||||
evals * rows + rebuilds
|
||||
}
|
||||
fn run(&self, skip: Option<usize>) -> Result<lm::Outcome, SolveError> {
|
||||
let free = self.free(skip);
|
||||
let rows = self.rows(skip);
|
||||
if rows > lm::MAX_ROWS || (rows as f64) * (rows as f64) * (free.len() as f64) > 4e8 {
|
||||
return Err(SolveError::TooLarge);
|
||||
}
|
||||
// Numerical differentiation rebuilds a fit spline through a dense solve for every move of
|
||||
// its own fit points: refuse before iterating when that alone is too much (CWE-407).
|
||||
if self.eval_work(&free, skip) > MAX_EVAL_WORK {
|
||||
return Err(SolveError::TooLarge);
|
||||
}
|
||||
let f = |x: &[f64], out: &mut Vec<f64>| self.eval(x, out, skip);
|
||||
let mut o = lm::solve(&f, &self.model.x0, &free, &self.weights, self.tol, MAX_ITER);
|
||||
// A solution the ellipses would be stored differently from (axes swapped, minor axis
|
||||
@@ -410,24 +444,24 @@ fn slot_scales(model: &Model, s: &model::Slot) -> Vec<(usize, f64)> {
|
||||
|
||||
/// Build the component systems. Constraints that cannot be interpreted are returned as skipped.
|
||||
pub(crate) fn build(d: &Drawing, cons: &[&Constraint], opts: &SolveOptions) -> Result<(Vec<Comp>, Vec<(u32, String)>), SolveError> {
|
||||
let mut members_left = model::MAX_RIGID_WORK;
|
||||
build_budgeted(d, cons, opts, &mut members_left)
|
||||
build_with(d, cons, opts, &mut Prep::new(model::MAX_RIGID_WORK))
|
||||
}
|
||||
|
||||
/// Build constraint components with one caller-owned rigid-member sampling budget shared by every model.
|
||||
/// Build constraint components sharing one caller-owned geometry preparation (block sampling and
|
||||
/// its member budget, spline rebuilds) between every model.
|
||||
/// Return constraints that cannot be interpreted as skipped entries.
|
||||
pub(crate) fn build_budgeted(
|
||||
pub(crate) fn build_with(
|
||||
d: &Drawing,
|
||||
cons: &[&Constraint],
|
||||
opts: &SolveOptions,
|
||||
members_left: &mut usize,
|
||||
prep: &mut Prep,
|
||||
) -> Result<(Vec<Comp>, Vec<(u32, String)>), SolveError> {
|
||||
let mut e = env(d);
|
||||
let mut skipped = Vec::new();
|
||||
let mut comps = Vec::new();
|
||||
for group in components(cons) {
|
||||
let handles: Vec<Handle> = group.iter().flat_map(|c| c.refs.iter().map(|r| r.handle)).collect();
|
||||
let mut model = Model::build_budgeted(d, &handles, members_left);
|
||||
let mut model = Model::build_with(d, &handles, prep);
|
||||
let mut built = Vec::new();
|
||||
for c in group {
|
||||
let target = if c.kind.is_dimensional() { constraint_value(&mut e, c)? } else { 0.0 };
|
||||
@@ -640,6 +674,11 @@ pub fn all_satisfied(d: &Drawing) -> bool {
|
||||
|
||||
/// Check that a constraint's references make sense for its type.
|
||||
pub fn validate(d: &Drawing, c: &Constraint) -> Result<(), String> {
|
||||
validate_with(d, c, &mut Prep::new(model::MAX_RIGID_WORK))
|
||||
}
|
||||
|
||||
/// [`validate`] sharing block samples and spline rebuilds with other checks of the same drawing.
|
||||
fn validate_with(d: &Drawing, c: &Constraint, prep: &mut Prep) -> Result<(), String> {
|
||||
let handles: Vec<Handle> = c.refs.iter().map(|r| r.handle).collect();
|
||||
for h in &handles {
|
||||
match d.entity(*h) {
|
||||
@@ -648,7 +687,7 @@ pub fn validate(d: &Drawing, c: &Constraint) -> Result<(), String> {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut model = Model::build(d, &handles);
|
||||
let mut model = Model::build_with(d, &handles, prep);
|
||||
interpret(c, &mut model, 1.0).map(|_| ())
|
||||
}
|
||||
|
||||
@@ -665,7 +704,12 @@ pub fn measure(d: &Drawing, kind: ConstraintKind, refs: &[GeomRef]) -> Result<f6
|
||||
/// Returns the removed ids.
|
||||
pub fn purge_invalid(d: &mut Drawing) -> Vec<u32> {
|
||||
let mut gone = Vec::new();
|
||||
let keep: Vec<bool> = d.constraints.iter().map(|c| validate(d, c).is_ok()).collect();
|
||||
// One preparation for every check: each block is sampled and each spline rebuilt once,
|
||||
// however many constraints refer to them (CWE-407). Its member budget is not shared, so
|
||||
// a constraint is never dropped because earlier ones spent it: every block is still
|
||||
// examined at most once, up to its own member limit.
|
||||
let mut prep = Prep::new(usize::MAX);
|
||||
let keep: Vec<bool> = d.constraints.iter().map(|c| validate_with(d, c, &mut prep).is_ok()).collect();
|
||||
let mut i = 0;
|
||||
d.constraints.retain(|c| {
|
||||
let k = keep.get(i).copied().unwrap_or(true);
|
||||
@@ -752,6 +796,9 @@ pub fn add(d: &mut Drawing, mut c: Constraint, opts: &SolveOptions) -> Result<u3
|
||||
&& comp.built.get(pos).is_some_and(|b| b.form == Form::Lock)
|
||||
{
|
||||
let before = comp.free(Some(pos));
|
||||
if comp.eval_work(&before, Some(pos)) > MAX_EVAL_WORK {
|
||||
return Err(SolveError::TooLarge);
|
||||
}
|
||||
let after = comp.free(None);
|
||||
let locked: Vec<usize> = before.iter().enumerate().filter(|(_, j)| !after.contains(j)).map(|(k, _)| k).collect();
|
||||
let rows_old = comp.rows(Some(pos));
|
||||
|
||||
+193
-62
@@ -14,9 +14,11 @@
|
||||
//! Constraints may append auxiliary unknowns after the entity parameters (contact parameters of
|
||||
//! a tangency between curves); they are never written back.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::rc::Rc;
|
||||
|
||||
use cadcraft_doc::{Drawing, EntityKind, GeomRef, Handle, Insert, Sub};
|
||||
use cadcraft_doc::{Block, Drawing, EntityKind, GeomRef, Handle, Insert, Sub};
|
||||
use cadcraft_geom::{Spline as GSpline, Vec2, ccw_sweep};
|
||||
|
||||
use crate::curves::{Ell, Frame, clamped, spline_end};
|
||||
@@ -70,6 +72,9 @@ pub(crate) struct Slot {
|
||||
pub len: usize,
|
||||
}
|
||||
|
||||
/// The spline last rebuilt for each handle, with the defining points it was rebuilt from.
|
||||
type SplineCache = HashMap<Handle, (Vec<Vec2>, Rc<GSpline>)>;
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct Model {
|
||||
pub slots: Vec<Slot>,
|
||||
@@ -79,20 +84,75 @@ pub(crate) struct Model {
|
||||
splines: HashMap<Handle, GSpline>,
|
||||
/// Block reference rigid points relative to the insertion point at rotation 0.
|
||||
rigid: HashMap<Handle, Vec<Vec2>>,
|
||||
/// Rebuilt splines, shared with every model of the same [`Prep`]: residuals that ask for the
|
||||
/// same spline at the same defining points (several constraints on one spline, or derivatives
|
||||
/// taken along other objects' parameters) reuse one rebuild instead of a dense solve each.
|
||||
rebuilt: Rc<RefCell<SplineCache>>,
|
||||
}
|
||||
|
||||
/// Geometry preparation shared by every model built for one operation (a solve, a DOF report, a
|
||||
/// purge): rigid samples per block, a case-insensitive block-name index and rebuilt splines. A
|
||||
/// block referenced by many Inserts is sampled once, an unknown block name costs one hashed
|
||||
/// lookup, and a spline referenced by many constraints is rebuilt once (CWE-407).
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct Prep {
|
||||
/// Block members that may still be examined across the operation.
|
||||
members_left: usize,
|
||||
/// Block-local rigid samples by the name an Insert gives (`None`: no such block).
|
||||
blocks: HashMap<String, Option<Rc<[Vec2]>>>,
|
||||
/// Lower-cased block name to the stored name it resolves to, built on the first exact miss.
|
||||
folded: Option<HashMap<String, String>>,
|
||||
rebuilt: Rc<RefCell<SplineCache>>,
|
||||
}
|
||||
|
||||
impl Prep {
|
||||
/// Preparation that may examine at most `members` block members in all.
|
||||
pub(crate) fn new(members: usize) -> Prep {
|
||||
Prep { members_left: members, blocks: HashMap::new(), folded: None, rebuilt: Rc::default() }
|
||||
}
|
||||
|
||||
/// The block an Insert names, as [`Drawing::block`] resolves it (exact name, else the first
|
||||
/// stored name equal ignoring ASCII case), without scanning every block on each miss.
|
||||
fn resolve<'d>(&mut self, d: &'d Drawing, name: &str) -> Option<&'d Block> {
|
||||
if let Some(b) = d.blocks.get(name) {
|
||||
return Some(b);
|
||||
}
|
||||
let folded = self.folded.get_or_insert_with(|| {
|
||||
let mut m = HashMap::with_capacity(d.blocks.len());
|
||||
for k in d.blocks.keys() {
|
||||
m.entry(k.to_ascii_lowercase()).or_insert_with(|| k.clone());
|
||||
}
|
||||
m
|
||||
});
|
||||
d.blocks.get(folded.get(&name.to_ascii_lowercase())?).map(|b| &**b)
|
||||
}
|
||||
|
||||
/// Finite characteristic points of a block in its own coordinates, sampled once per name.
|
||||
fn block_points(&mut self, d: &Drawing, name: &str) -> Option<Rc<[Vec2]>> {
|
||||
if let Some(s) = self.blocks.get(name) {
|
||||
return s.clone();
|
||||
}
|
||||
let sampled = self.resolve(d, name).map(|b| sample_block(b, &mut self.members_left));
|
||||
self.blocks.insert(name.to_string(), sampled.clone());
|
||||
sampled
|
||||
}
|
||||
|
||||
/// World positions of an Insert's rigid points, at most [`MAX_RIGID_POINTS`].
|
||||
fn rigid_points(&mut self, d: &Drawing, ins: &Insert) -> Vec<Vec2> {
|
||||
let Some(local) = self.block_points(d, &ins.block) else { return Vec::new() };
|
||||
let Some(block) = self.resolve(d, &ins.block) else { return Vec::new() };
|
||||
let m = ins.transform(block.base.xy());
|
||||
local.iter().map(|p| m.apply(*p)).filter(|p| p.is_finite()).take(MAX_RIGID_POINTS).collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl Model {
|
||||
/// Work for a spline that an endpoint residual actually reconstructs. Fit interpolation
|
||||
/// uses dense Gaussian elimination (cubic); control splines clone and evaluate their template.
|
||||
pub(crate) fn spline_reconstruct_cost(&self, handle: Handle) -> f64 {
|
||||
let Some(s) = self.splines.get(&handle) else { return 0.0 };
|
||||
let n = s.fit.len() as f64;
|
||||
if fit_mode(s) {
|
||||
n * n * n
|
||||
} else {
|
||||
let n = s.control.len() as f64;
|
||||
n * n
|
||||
}
|
||||
let Some(Shape::Spline { n, fit, .. }) = self.slot(handle).map(|s| s.shape) else { return 0.0 };
|
||||
let n = n as f64;
|
||||
if fit { n * n * n } else { n * n }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,23 +175,18 @@ pub fn supported(kind: &EntityKind) -> bool {
|
||||
/// centres, vertices, nested insertion points) of the block's own objects, at most
|
||||
/// [`MAX_RIGID_POINTS`]. Nested blocks are not entered.
|
||||
pub fn rigid_points(d: &Drawing, ins: &Insert) -> Vec<Vec2> {
|
||||
let mut members_left = MAX_RIGID_MEMBERS;
|
||||
rigid_points_budgeted(d, ins, &mut members_left)
|
||||
Prep::new(MAX_RIGID_MEMBERS).rigid_points(d, ins)
|
||||
}
|
||||
|
||||
/// Sample finite block points within per-insert and caller-shared member limits.
|
||||
/// Members that contribute no points still consume traversal work; nested blocks are not entered.
|
||||
fn rigid_points_budgeted(d: &Drawing, ins: &Insert, members_left: &mut usize) -> Vec<Vec2> {
|
||||
let Some(block) = d.block(&ins.block) else { return Vec::new() };
|
||||
let m = ins.transform(block.base.xy());
|
||||
/// Sample a block's finite characteristic points in block coordinates, within the per-block
|
||||
/// member limit and the caller-shared `members_left`. Members that contribute no points still
|
||||
/// consume traversal work; nested blocks are not entered.
|
||||
fn sample_block(block: &Block, members_left: &mut usize) -> Rc<[Vec2]> {
|
||||
let mut out = Vec::new();
|
||||
// Bound the members examined, not only the points collected: entities that contribute no
|
||||
// points (Text, Ray, …) still cost traversal, so a block full of them — scanned again for
|
||||
// every Insert — would otherwise be unbounded (CWE-407).
|
||||
let mut examined = 0usize;
|
||||
for e in block.entities.iter() {
|
||||
examined += 1;
|
||||
if out.len() >= MAX_RIGID_POINTS || examined > MAX_RIGID_MEMBERS || *members_left == 0 {
|
||||
// points (Text, Ray, …) still cost traversal (CWE-407).
|
||||
for (examined, e) in block.entities.iter().enumerate() {
|
||||
if out.len() >= MAX_RIGID_POINTS || examined >= MAX_RIGID_MEMBERS || *members_left == 0 {
|
||||
break;
|
||||
}
|
||||
*members_left -= 1;
|
||||
@@ -148,10 +203,10 @@ fn rigid_points_budgeted(d: &Drawing, ins: &Insert, members_left: &mut usize) ->
|
||||
EntityKind::LwPolyline(p) => p.vertices.iter().take(MAX_RIGID_POINTS).map(|v| v.p).collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
out.extend(pts.into_iter().map(|p| m.apply(p)).filter(|p| p.is_finite()));
|
||||
out.extend(pts.into_iter().filter(|p| p.is_finite()));
|
||||
}
|
||||
out.truncate(MAX_RIGID_POINTS);
|
||||
out
|
||||
out.into()
|
||||
}
|
||||
|
||||
/// Constraint points of an object as the solver sees them (`Sub`, position): end points,
|
||||
@@ -187,13 +242,12 @@ fn fit_mode(s: &GSpline) -> bool {
|
||||
impl Model {
|
||||
/// Build a model for the given entities. Unsupported or missing entities are skipped.
|
||||
pub fn build(d: &Drawing, handles: &[Handle]) -> Model {
|
||||
let mut members_left = MAX_RIGID_WORK;
|
||||
Self::build_budgeted(d, handles, &mut members_left)
|
||||
Self::build_with(d, handles, &mut Prep::new(MAX_RIGID_WORK))
|
||||
}
|
||||
|
||||
/// Add the parameters of one supported entity to `m`, and return its shape; `None` when it is
|
||||
/// left out (too large, degenerate or unsupported).
|
||||
fn add_params(m: &mut Model, d: &Drawing, h: Handle, kind: &EntityKind, off: usize, members_left: &mut usize) -> Option<Shape> {
|
||||
fn add_params(m: &mut Model, d: &Drawing, h: Handle, kind: &EntityKind, off: usize, prep: &mut Prep) -> Option<Shape> {
|
||||
let shape = match kind {
|
||||
EntityKind::Line(l) => {
|
||||
m.x0.extend([l.a.x, l.a.y, l.b.x, l.b.y]);
|
||||
@@ -243,15 +297,22 @@ impl Model {
|
||||
// fit point; a control polygon's ends are its first and last points when the
|
||||
// knots are clamped.
|
||||
let ends = !sp.closed && (fit || (clamped(sp, false) && clamped(sp, true)));
|
||||
// Missing weights default to 1 in eval; only excess weights are unused.
|
||||
let mut kept = sp.clone();
|
||||
// Keep only what rebuilding reads, so each rebuild copies no unused data: missing
|
||||
// weights default to 1 in eval and only excess weights are unused; a control
|
||||
// spline's fit points are dropped when its control points move (write_back), and
|
||||
// a fit spline is rebuilt from its parameters, not from its old control net.
|
||||
let mut kept = if fit {
|
||||
GSpline { fit_opts: sp.fit_opts, closed: sp.closed, ..GSpline::default() }
|
||||
} else {
|
||||
GSpline { fit: Vec::new(), ..sp.clone() }
|
||||
};
|
||||
kept.weights.truncate(kept.control.len());
|
||||
m.splines.insert(h, kept);
|
||||
Shape::Spline { n, fit, ends }
|
||||
}
|
||||
EntityKind::Insert(ins) => {
|
||||
let base = Vec2::new(ins.insert.x, ins.insert.y);
|
||||
let pts: Vec<Vec2> = rigid_points_budgeted(d, ins, members_left).into_iter().map(|p| (p - base).rotate(-ins.rotation)).collect();
|
||||
let pts: Vec<Vec2> = prep.rigid_points(d, ins).into_iter().map(|p| (p - base).rotate(-ins.rotation)).collect();
|
||||
m.x0.extend([ins.insert.x, ins.insert.y, ins.rotation]);
|
||||
let n = pts.len();
|
||||
m.rigid.insert(h, pts);
|
||||
@@ -262,16 +323,17 @@ impl Model {
|
||||
Some(shape)
|
||||
}
|
||||
|
||||
/// Build solver parameters for supported entities while sharing rigid-member sampling work with other models.
|
||||
pub(crate) fn build_budgeted(d: &Drawing, handles: &[Handle], members_left: &mut usize) -> Model {
|
||||
let mut m = Model::default();
|
||||
/// Build solver parameters for supported entities, sharing block sampling, its member budget
|
||||
/// and spline rebuilds with every other model built from `prep`.
|
||||
pub(crate) fn build_with(d: &Drawing, handles: &[Handle], prep: &mut Prep) -> Model {
|
||||
let mut m = Model { rebuilt: Rc::clone(&prep.rebuilt), ..Model::default() };
|
||||
for &h in handles {
|
||||
if m.index.contains_key(&h) {
|
||||
continue;
|
||||
}
|
||||
let Some(e) = d.entity(h) else { continue };
|
||||
let off = m.x0.len();
|
||||
let Some(shape) = Self::add_params(&mut m, d, h, &e.kind, off, members_left) else { continue };
|
||||
let Some(shape) = Self::add_params(&mut m, d, h, &e.kind, off, prep) else { continue };
|
||||
if m.x0.len() > MAX_PARAMS || m.x0.get(off..).unwrap_or(&[]).iter().any(|v| !v.is_finite()) {
|
||||
m.x0.truncate(off);
|
||||
continue;
|
||||
@@ -373,18 +435,20 @@ impl Model {
|
||||
self.slot(r.handle).is_some_and(|s| matches!(s.shape, Shape::Spline { .. }))
|
||||
}
|
||||
|
||||
/// The spline of a reference with the defining points taken from `x`.
|
||||
fn spline(&self, x: &[f64], s: &Slot) -> Option<GSpline> {
|
||||
/// The spline of a reference with the defining points taken from `x`. The last rebuild of
|
||||
/// each spline is kept, so asking again at the same points costs a comparison, not a rebuild.
|
||||
fn spline(&self, x: &[f64], s: &Slot) -> Option<Rc<GSpline>> {
|
||||
let Shape::Spline { n, fit, .. } = s.shape else { return None };
|
||||
let t = self.splines.get(&s.handle)?;
|
||||
let pts = (0..n).map(|i| p2(x, s.off + 2 * i)).collect::<Option<Vec<_>>>()?;
|
||||
Some(if fit {
|
||||
t.refit(&pts, t.closed)
|
||||
} else {
|
||||
let mut sp = t.clone();
|
||||
sp.control = pts;
|
||||
sp
|
||||
})
|
||||
if let Some((at, sp)) = self.rebuilt.borrow().get(&s.handle)
|
||||
&& *at == pts
|
||||
{
|
||||
return Some(Rc::clone(sp));
|
||||
}
|
||||
let sp = Rc::new(if fit { t.refit(&pts, t.closed) } else { GSpline { control: pts.clone(), ..t.clone() } });
|
||||
self.rebuilt.borrow_mut().insert(s.handle, (pts, Rc::clone(&sp)));
|
||||
Some(sp)
|
||||
}
|
||||
|
||||
/// The start (`at_end` false) or end of a curve reference, in the curve's own direction:
|
||||
@@ -421,7 +485,7 @@ impl Model {
|
||||
let e = self.ell_at(x, o, true)?;
|
||||
e.frame(if at_end { e.t1 } else { e.t0 })
|
||||
}
|
||||
(Shape::Spline { ends: true, .. }, Sub::Whole | Sub::Start | Sub::End) => spline_end(&self.spline(x, s)?, at_end),
|
||||
(Shape::Spline { ends: true, .. }, Sub::Whole | Sub::Start | Sub::End) => spline_end(&*self.spline(x, s)?, at_end),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -590,7 +654,8 @@ impl Model {
|
||||
}
|
||||
let shape = s.shape;
|
||||
// A control-point spline loses its fit data once its control points move.
|
||||
let spline = self.spline(x, s).map(|mut sp| {
|
||||
let spline = self.spline(x, s).map(|sp| {
|
||||
let mut sp = GSpline::clone(&sp);
|
||||
if matches!(shape, Shape::Spline { fit: false, .. }) {
|
||||
sp.fit.clear();
|
||||
}
|
||||
@@ -712,21 +777,21 @@ mod budget_tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A second model receives no sampled block points after the first model spends the shared member budget.
|
||||
#[test]
|
||||
fn rigid_sampling_budget_is_shared_between_models() {
|
||||
let mut d = Drawing::default();
|
||||
let mut block = Block::new("BAR");
|
||||
for _ in 0..3 {
|
||||
fn add_block(d: &mut Drawing, name: &str, lines: usize) {
|
||||
let mut block = Block::new(name);
|
||||
for _ in 0..lines {
|
||||
block.entities.push(Entity {
|
||||
handle: d.new_handle(),
|
||||
common: Common::default(),
|
||||
kind: EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(1.0, 0.0, 0.0) }),
|
||||
});
|
||||
}
|
||||
d.blocks.insert("BAR".into(), Arc::new(block));
|
||||
let ins = Insert {
|
||||
block: "BAR".into(),
|
||||
d.blocks.insert(name.into(), Arc::new(block));
|
||||
}
|
||||
|
||||
fn insert(block: &str) -> Insert {
|
||||
Insert {
|
||||
block: block.into(),
|
||||
insert: Vec3::ZERO,
|
||||
scale: Vec3::new(1.0, 1.0, 1.0),
|
||||
rotation: 0.0,
|
||||
@@ -735,13 +800,79 @@ mod budget_tests {
|
||||
rows: 1,
|
||||
col_spacing: 0.0,
|
||||
row_spacing: 0.0,
|
||||
};
|
||||
let h = d.add(&Space::Model, Common::default(), EntityKind::Insert(ins)).unwrap();
|
||||
let mut budget = 2;
|
||||
let first = Model::build_budgeted(&d, &[h], &mut budget);
|
||||
assert_eq!(first.rigid[&h].len(), 4);
|
||||
assert_eq!(budget, 0);
|
||||
let next = Model::build_budgeted(&d, &[h], &mut budget);
|
||||
assert!(next.rigid[&h].is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
fn add_insert(d: &mut Drawing, block: &str) -> Handle {
|
||||
d.add(&Space::Model, Common::default(), EntityKind::Insert(insert(block))).unwrap()
|
||||
}
|
||||
|
||||
/// The member budget is shared by every model of one preparation, and a block it already
|
||||
/// sampled is reused by later Inserts without examining its members again.
|
||||
#[test]
|
||||
fn rigid_sampling_budget_is_shared_and_blocks_sampled_once() {
|
||||
let mut d = Drawing::default();
|
||||
add_block(&mut d, "BAR", 3);
|
||||
add_block(&mut d, "BAZ", 3);
|
||||
let (bar, baz, bar2) = (add_insert(&mut d, "BAR"), add_insert(&mut d, "BAZ"), add_insert(&mut d, "BAR"));
|
||||
let mut prep = Prep::new(2);
|
||||
let first = Model::build_with(&d, &[bar], &mut prep);
|
||||
assert_eq!(first.rigid[&bar].len(), 4);
|
||||
assert_eq!(prep.members_left, 0);
|
||||
let other = Model::build_with(&d, &[baz], &mut prep);
|
||||
assert!(other.rigid[&baz].is_empty());
|
||||
let again = Model::build_with(&d, &[bar2], &mut prep);
|
||||
assert_eq!(again.rigid[&bar2].len(), 4);
|
||||
}
|
||||
|
||||
/// Block names resolve ignoring ASCII case, as `Drawing::block` does, through one index.
|
||||
#[test]
|
||||
fn block_names_resolve_ignoring_case() {
|
||||
let mut d = Drawing::default();
|
||||
add_block(&mut d, "Bar", 2);
|
||||
let (lower, missing) = (add_insert(&mut d, "bAR"), add_insert(&mut d, "nope"));
|
||||
let mut prep = Prep::new(MAX_RIGID_WORK);
|
||||
let m = Model::build_with(&d, &[lower, missing], &mut prep);
|
||||
assert_eq!(m.rigid[&lower].len(), 4);
|
||||
assert!(m.rigid[&missing].is_empty());
|
||||
assert!(prep.folded.as_ref().is_some_and(|f| f.len() == 1));
|
||||
assert_eq!(rigid_points(&d, &insert("BAR")).len(), 4);
|
||||
}
|
||||
|
||||
/// A spline asked for again at the same defining points is not rebuilt, also from another
|
||||
/// model of the same preparation; moved points rebuild it.
|
||||
#[test]
|
||||
fn spline_rebuilds_are_reused_at_the_same_points() {
|
||||
let mut d = Drawing::default();
|
||||
let pts: Vec<Vec2> = (0..6).map(|i| Vec2::new(i as f64, (i % 2) as f64)).collect();
|
||||
let h = d.add(&Space::Model, Common::default(), EntityKind::Spline(GSpline::fit_with(&pts, false, Default::default()))).unwrap();
|
||||
let mut prep = Prep::new(MAX_RIGID_WORK);
|
||||
let a = Model::build_with(&d, &[h], &mut prep);
|
||||
let b = Model::build_with(&d, &[h], &mut prep);
|
||||
let (sa, sb) = (a.slot(h).unwrap(), b.slot(h).unwrap());
|
||||
assert!(matches!(sa.shape, Shape::Spline { fit: true, .. }));
|
||||
let first = a.spline(&a.x0, sa).unwrap();
|
||||
assert!(Rc::ptr_eq(&first, &a.spline(&a.x0, sa).unwrap()));
|
||||
assert!(Rc::ptr_eq(&first, &b.spline(&b.x0, sb).unwrap()));
|
||||
let mut moved = a.x0.clone();
|
||||
moved[1] += 0.5;
|
||||
let other = a.spline(&moved, sa).unwrap();
|
||||
assert!(!Rc::ptr_eq(&first, &other));
|
||||
assert_eq!(other.fit[0], Vec2::new(0.0, 0.5));
|
||||
}
|
||||
|
||||
/// A control spline's template keeps no fit points and no excess weights.
|
||||
#[test]
|
||||
fn control_template_drops_unused_data() {
|
||||
let mut d = Drawing::default();
|
||||
let mut sp = GSpline::from_control(vec![Vec2::ZERO, Vec2::new(1.0, 2.0), Vec2::new(3.0, 0.0), Vec2::new(4.0, 1.0)], 3);
|
||||
sp.fit = vec![Vec2::ZERO; 10_000];
|
||||
sp.fit_opts.tolerance = 0.5;
|
||||
sp.weights = vec![1.0; 10_000];
|
||||
let h = d.add(&Space::Model, Common::default(), EntityKind::Spline(sp)).unwrap();
|
||||
let m = Model::build(&d, &[h]);
|
||||
let t = &m.splines[&h];
|
||||
assert!(t.fit.is_empty());
|
||||
assert_eq!(t.weights.len(), 4);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,7 +194,9 @@ pub struct Action {
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")]
|
||||
pub enum ActionKind {
|
||||
/// Moves the vertices inside `frame` of the objects listed (all of an object not listed).
|
||||
/// Moves the points of its objects inside `frame`: a line's or polyline's points inside it
|
||||
/// (the whole of one outside it), any other object whole. `vertices` are the point indices the
|
||||
/// file lists for each object, used only when the frame can't be tested (see `apply`).
|
||||
Stretch {
|
||||
frame: Vec<Vec2>,
|
||||
#[serde(serialize_with = "hex_vertices")]
|
||||
|
||||
@@ -403,7 +403,31 @@ fn move_entity(e: &mut Entity, vertices: Option<&[i32]>, v: Vec2) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether point `p` lies inside a stretch frame (two corners, or a polygon).
|
||||
/// Most points of a stretch frame whose objects' points are tested against it one by one. A frame
|
||||
/// is two corners or a short polygon; testing every point of every object against every edge of a
|
||||
/// longer one would grow with their product, so the action's own vertex lists are used instead.
|
||||
const MAX_FRAME_POINTS: usize = 1000;
|
||||
|
||||
/// The indices of `points` inside stretch frame `frame`, or `None` when none is: an object the
|
||||
/// action selects that lies outside its frame moves whole.
|
||||
fn inside(frame: &[Vec2], points: impl Iterator<Item = Vec2>) -> Option<Vec<i32>> {
|
||||
let idx: Vec<i32> = points.enumerate().filter(|(_, p)| in_frame(frame, *p)).filter_map(|(i, _)| i32::try_from(i).ok()).collect();
|
||||
(!idx.is_empty()).then_some(idx)
|
||||
}
|
||||
|
||||
/// The distance from `p` to the segment `a`-`b`, however short (`Line::dist` takes one shorter than
|
||||
/// `EPS` for its first point). It is measured from `a`, so a short segment far from the origin keeps
|
||||
/// the digits rounding there would drop.
|
||||
fn segment_dist(a: Vec2, b: Vec2, p: Vec2) -> f64 {
|
||||
let d = b - a;
|
||||
let w = p - a;
|
||||
let l2 = d.len2();
|
||||
let t = if l2 > 0.0 { (w.dot(d) / l2).clamp(0.0, 1.0) } else { 0.0 };
|
||||
(w - d * t).len()
|
||||
}
|
||||
|
||||
/// Whether point `p` lies inside a stretch frame (two corners, or a polygon). A point on its edge is
|
||||
/// inside, for a polygon as for two corners: within a billionth of the edge's length of it.
|
||||
fn in_frame(frame: &[Vec2], p: Vec2) -> bool {
|
||||
match frame {
|
||||
[a, b] => p.x >= a.x.min(b.x) && p.x <= a.x.max(b.x) && p.y >= a.y.min(b.y) && p.y <= a.y.max(b.y),
|
||||
@@ -412,6 +436,9 @@ fn in_frame(frame: &[Vec2], p: Vec2) -> bool {
|
||||
let mut j = f.len() - 1;
|
||||
for i in 0..f.len() {
|
||||
let (Some(pi), Some(pj)) = (f.get(i), f.get(j)) else { break };
|
||||
if segment_dist(*pj, *pi, p) <= 1e-9 * pi.dist(*pj) {
|
||||
return true;
|
||||
}
|
||||
if (pi.y > p.y) != (pj.y > p.y) && p.x < (pj.x - pi.x) * (p.y - pi.y) / (pj.y - pi.y) + pi.x {
|
||||
inside = !inside;
|
||||
}
|
||||
@@ -464,7 +491,10 @@ fn apply(
|
||||
_ => 0.0,
|
||||
})
|
||||
};
|
||||
let mult = if mult == 0.0 { 1.0 } else { mult };
|
||||
// A zero multiplier would move nothing. AutoCAD 2024 moves such an action by the
|
||||
// parameter's change and leaves its angle offset out (measured on a stretch action written
|
||||
// with multiplier 0, angle offset 1 and group 280 = 1).
|
||||
let (mult, angle) = if mult == 0.0 { (1.0, 0.0) } else { (mult, angle) };
|
||||
let v = Vec2::new(comp(0), comp(1)).rotate(angle) * mult;
|
||||
if !(v.x.is_finite() && v.y.is_finite()) || (v.x == 0.0 && v.y == 0.0) {
|
||||
continue;
|
||||
@@ -473,6 +503,8 @@ fn apply(
|
||||
// this action's selection, so a selection that repeats a handle does not re-scan the
|
||||
// vertex list and re-clone its index vector per occurrence (CWE-400).
|
||||
let vtx_by_handle: HashMap<Handle, &Vec<i32>> = vertices.iter().rev().map(|(vh, i)| (*vh, i)).collect();
|
||||
// Whether the frame (two corners or a polygon) is tested point by point.
|
||||
let framed = (2..=MAX_FRAME_POINTS).contains(&frame.len());
|
||||
let mut handled: HashSet<Handle> = HashSet::new();
|
||||
for h in &a.selection {
|
||||
if !handled.insert(*h) {
|
||||
@@ -502,7 +534,18 @@ fn apply(
|
||||
continue;
|
||||
}
|
||||
let Some(c) = copy_of(h) else { continue };
|
||||
let idx = vtx_by_handle.get(h).map(|i| (*i).clone());
|
||||
// The points of a line or polyline that move are those inside the frame where the
|
||||
// dynamic block has them, all of them when none is, as AutoCAD 2024 moves them. It
|
||||
// doesn't go by the vertex lists the action stores, which a block from another program
|
||||
// can get wrong. A polyline's vertices are in its own plane: in the block's when its
|
||||
// extrusion is exactly along z (a mirrored one is read flipped into it). Any tilt, however
|
||||
// small, moves them by as much as the elevation times the tilt, so a tilted one keeps the
|
||||
// stored list.
|
||||
let idx = match def.entities.get(*h).map(|e| (&e.kind, e.common.extrusion)) {
|
||||
Some((EntityKind::Line(l), _)) if framed => inside(frame, [l.a.xy(), l.b.xy()].into_iter()),
|
||||
Some((EntityKind::LwPolyline(pl), n)) if framed && n.x == 0.0 && n.y == 0.0 => inside(frame, pl.vertices.iter().map(|v| v.p)),
|
||||
_ => vtx_by_handle.get(h).map(|i| (*i).clone()),
|
||||
};
|
||||
moves.entry(c).or_default().push((idx, v));
|
||||
}
|
||||
}
|
||||
|
||||
+101
-12
@@ -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 curves::{Arc, Circle, Ellipse, Face3d, Line, LwPolyline, Point, PolyfaceMesh, 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,
|
||||
@@ -84,6 +86,7 @@ pub enum EntityKind {
|
||||
Solid(Solid),
|
||||
Trace(Solid),
|
||||
Face3d(Face3d),
|
||||
PolyfaceMesh(PolyfaceMesh),
|
||||
Region(Region),
|
||||
Viewport(Viewport),
|
||||
Image(Image),
|
||||
@@ -138,6 +141,7 @@ impl EntityKind {
|
||||
EntityKind::Solid(_) => "2D Solid",
|
||||
EntityKind::Trace(_) => "Trace",
|
||||
EntityKind::Face3d(_) => "3D Face",
|
||||
EntityKind::PolyfaceMesh(_) => "Polyface Mesh",
|
||||
EntityKind::Region(_) => "Region",
|
||||
EntityKind::Viewport(_) => "Viewport",
|
||||
EntityKind::Image(_) => "Raster Image",
|
||||
@@ -173,6 +177,7 @@ impl EntityKind {
|
||||
EntityKind::Solid(_) => "SOLID",
|
||||
EntityKind::Trace(_) => "TRACE",
|
||||
EntityKind::Face3d(_) => "3DFACE",
|
||||
EntityKind::PolyfaceMesh(_) => "POLYLINE",
|
||||
EntityKind::Region(_) => "REGION",
|
||||
EntityKind::Viewport(_) => "VIEWPORT",
|
||||
EntityKind::Image(_) => "IMAGE",
|
||||
@@ -216,21 +221,17 @@ impl EntityKind {
|
||||
let c = &s.corners;
|
||||
vec![Prim::Fill(vec![c[0].xy(), c[1].xy(), c[3].xy(), c[2].xy()])]
|
||||
}
|
||||
EntityKind::Face3d(f) => {
|
||||
let c = &f.corners;
|
||||
let pts = [c[0].xy(), c[1].xy(), c[2].xy(), c[3].xy(), c[0].xy()];
|
||||
pts.windows(2).filter_map(|w| Some(Prim::Seg(Segment::Line(GLine::new(*w.first()?, *w.get(1)?))))).collect()
|
||||
EntityKind::Face3d(f) => f.visible_edges().into_iter().map(|(a, b)| Prim::Seg(Segment::Line(GLine::new(a.xy(), b.xy())))).collect(),
|
||||
// Each face's visible edges (an edge flagged hidden, by a negative index, isn't drawn).
|
||||
EntityKind::PolyfaceMesh(m) => {
|
||||
m.faces3d().iter().flat_map(Face3d::visible_edges).map(|(a, b)| Prim::Seg(Segment::Line(GLine::new(a.xy(), b.xy())))).collect()
|
||||
}
|
||||
EntityKind::Region(r) => r.segments().into_iter().map(Prim::Seg).collect(),
|
||||
EntityKind::Hatch(h) => {
|
||||
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();
|
||||
@@ -426,6 +427,7 @@ impl EntityKind {
|
||||
}
|
||||
EntityKind::Solid(so) | EntityKind::Trace(so) => so.corners.iter_mut().for_each(t3),
|
||||
EntityKind::Face3d(f) => f.corners.iter_mut().for_each(t3),
|
||||
EntityKind::PolyfaceMesh(pm) => pm.vertices.iter_mut().for_each(t3),
|
||||
EntityKind::Region(r) => {
|
||||
for v in r.loops.iter_mut().flat_map(|l| l.vertices.iter_mut()) {
|
||||
v.p = m.apply(v.p);
|
||||
@@ -467,6 +469,92 @@ 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::PolyfaceMesh(m) => m.vertices.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.
|
||||
@@ -528,6 +616,7 @@ impl EntityKind {
|
||||
}
|
||||
EntityKind::Solid(s) | EntityKind::Trace(s) => s.corners.iter().map(|c| c.xy()).collect(),
|
||||
EntityKind::Face3d(f) => f.corners.iter().map(|c| c.xy()).collect(),
|
||||
EntityKind::PolyfaceMesh(m) => m.vertices.iter().map(|v| v.xy()).collect(),
|
||||
EntityKind::Region(r) => vec![r.bounds().center()],
|
||||
EntityKind::Viewport(v) => {
|
||||
let c = v.center.xy();
|
||||
@@ -535,7 +624,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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,6 +69,52 @@ pub struct Face3d {
|
||||
pub hidden_edges: u8,
|
||||
}
|
||||
|
||||
impl Face3d {
|
||||
/// The edges drawn: side i (corner i to the next) unless bit i of `hidden_edges` hides it,
|
||||
/// without the zero-length side of a triangle.
|
||||
pub fn visible_edges(&self) -> Vec<(Vec3, Vec3)> {
|
||||
let c = self.corners;
|
||||
(0..4).filter(|i| self.hidden_edges & (1 << i) == 0).map(|i| (c[i], c[(i + 1) % 4])).filter(|(a, b)| a != b).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// POLYLINE polyface mesh (DXF flag 64): its vertices, and faces naming three or four of them
|
||||
/// by 1-based index as DXF stores them (0: no fourth vertex; a negative index hides the edge
|
||||
/// that starts at that vertex).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct PolyfaceMesh {
|
||||
pub vertices: Vec<Vec3>,
|
||||
pub faces: Vec<[i32; 4]>,
|
||||
}
|
||||
|
||||
impl PolyfaceMesh {
|
||||
/// The faces as 3D faces (what EXPLODE makes). Faces with fewer than three vertices or an
|
||||
/// index outside the vertex list are skipped.
|
||||
pub fn faces3d(&self) -> Vec<Face3d> {
|
||||
self.faces.iter().filter_map(|f| self.face3d(f)).collect()
|
||||
}
|
||||
/// One face record as a 3D face: `None` when it names fewer than three vertices before a 0,
|
||||
/// or one outside the vertex list.
|
||||
pub fn face3d(&self, f: &[i32; 4]) -> Option<Face3d> {
|
||||
let mut corners: Vec<Vec3> = Vec::with_capacity(4);
|
||||
let mut hidden = 0u8;
|
||||
for &ix in f.iter().take_while(|&&ix| ix != 0) {
|
||||
let at = usize::try_from(ix.unsigned_abs()).ok().and_then(|n| n.checked_sub(1));
|
||||
let p = *at.and_then(|n| self.vertices.get(n))?;
|
||||
if ix < 0 {
|
||||
hidden |= 1 << corners.len();
|
||||
}
|
||||
corners.push(p);
|
||||
}
|
||||
match corners.as_slice() {
|
||||
// A triangle's closing edge is the 3DFACE's fourth (the third is degenerate).
|
||||
[a, b, c] => Some(Face3d { corners: [*a, *b, *c, *c], hidden_edges: hidden | if hidden & 4 != 0 { 8 } else { 0 } }),
|
||||
[a, b, c, d] => Some(Face3d { corners: [*a, *b, *c, *d], hidden_edges: hidden }),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RayLine {
|
||||
pub base: Vec3,
|
||||
|
||||
@@ -52,6 +52,11 @@ pub struct Dimension {
|
||||
/// Anonymous block with the rendered geometry (from files); regenerated when edited.
|
||||
#[serde(default)]
|
||||
pub block: Option<String>,
|
||||
/// Insertion point (DXF group 12): a dimension moved by an older AutoCAD keeps its offset
|
||||
/// here with the `*D` block's contents left where they were, and is drawn translated by it.
|
||||
/// Zero once the geometry is regenerated from the definition points.
|
||||
#[serde(default)]
|
||||
pub insertion_point: Vec3,
|
||||
/// Per-dimension style overrides (DIMOVERRIDE): DimStyle field names (camelCase) → values,
|
||||
/// merged over the named style.
|
||||
#[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+82
-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,13 +205,17 @@ 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
|
||||
}
|
||||
};
|
||||
// The block draws translated by the insertion point (group 12), so its bounds
|
||||
// move with it (#2634); the cache above keeps the block's own bounds.
|
||||
let off = dm.insertion_point.xy();
|
||||
let inner = if off == Vec2::ZERO { inner } else { Bounds2 { min: inner.min + off, max: inner.max + off } };
|
||||
b = b.union(&inner);
|
||||
}
|
||||
b
|
||||
@@ -193,7 +230,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 +346,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)));
|
||||
|
||||
+165
-3
@@ -6,7 +6,7 @@
|
||||
//! named object dictionary, a table record, a block, an entity); references to objects that are
|
||||
//! gone are dropped. Commands don't edit it.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use crate::{Handle, RawTag};
|
||||
|
||||
@@ -89,8 +89,170 @@ impl Foreign {
|
||||
&& self.data_store.is_empty()
|
||||
}
|
||||
|
||||
/// The entities the data store has records for (their 320 handles).
|
||||
/// The entities with a geometry record in the data store: an `ACDSRECORD` naming the entity
|
||||
/// (320) that holds its `ASM_Data`. A 320 without one doesn't keep an entity's geometry.
|
||||
pub fn data_store_entities(&self) -> std::collections::BTreeSet<Handle> {
|
||||
self.data_store.iter().filter(|t| t.code == 320).filter_map(|t| Handle::parse_hex(t.value.trim())).collect()
|
||||
let mut out = std::collections::BTreeSet::new();
|
||||
for rec in data_store_records(&self.data_store) {
|
||||
let is_record = rec.first().is_some_and(|t| t.value.trim().eq_ignore_ascii_case("ACDSRECORD"));
|
||||
if is_record && rec.iter().any(|t| t.code == 2 && t.value.trim().eq_ignore_ascii_case("ASM_Data")) {
|
||||
out.extend(data_store_owner(rec).and_then(|t| Handle::parse_hex(t.value.trim())));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a solid modeler entity (3DSOLID, REGION, BODY) holds its ACIS geometry as SAT text
|
||||
/// (groups 1 and 3), which DXF 2013 and later no longer has: those files keep it in the data
|
||||
/// store section, so an entity with SAT text and no record there can't be written to one (#2334).
|
||||
pub fn sat_text_acis(u: &crate::Unknown, handle: Handle, stored: &std::collections::BTreeSet<Handle>) -> bool {
|
||||
["3DSOLID", "REGION", "BODY"].iter().any(|t| u.dxf_type.eq_ignore_ascii_case(t)) && !stored.contains(&handle) && has_sat_text(&u.tags)
|
||||
}
|
||||
|
||||
/// The entities a DXF of 2013 or later leaves out because they hold SAT-text ACIS data
|
||||
/// ([`sat_text_acis`]), counted by DXF type, across model space, layouts and block definitions.
|
||||
pub fn sat_text_dropped(d: &crate::Drawing) -> BTreeMap<String, usize> {
|
||||
let stored = d.foreign.data_store_entities();
|
||||
let mut out = BTreeMap::new();
|
||||
// The writer saves xrefs as references: their blocks (and `xref|` ones) hold nothing in the file.
|
||||
let own_blocks = d.blocks.values().filter(|b| b.xref_path.is_none() && !xref_dependent(&b.name));
|
||||
let stores = std::iter::once(&d.model).chain(d.layouts.iter().map(|l| &l.entities)).chain(own_blocks.map(|b| &b.entities));
|
||||
for e in stores.flat_map(|s| s.iter()) {
|
||||
if let crate::EntityKind::Unknown(u) = &e.kind
|
||||
&& sat_text_acis(u, e.handle, &stored)
|
||||
{
|
||||
let n: &mut usize = out.entry(u.dxf_type.to_ascii_uppercase()).or_default();
|
||||
*n = n.saturating_add(1);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether a symbol name belongs to an attached xref (`xref|name`): the DXF writer leaves such
|
||||
/// symbols out, and the contents of xref blocks too ([`Block::xref_path`](crate::Block::xref_path)).
|
||||
pub fn xref_dependent(name: &str) -> bool {
|
||||
name.contains('|')
|
||||
}
|
||||
|
||||
/// The records of a data store section's groups: each starts at a `0` group and runs to the next
|
||||
/// (the section's own groups before the first record are not part of one).
|
||||
pub fn data_store_records(tags: &[RawTag]) -> Vec<&[RawTag]> {
|
||||
let mut out = Vec::new();
|
||||
let mut start = tags.iter().position(|t| t.code == 0).unwrap_or(tags.len());
|
||||
while start < tags.len() {
|
||||
let end = tags.iter().skip(start + 1).position(|t| t.code == 0).map_or(tags.len(), |k| start + 1 + k);
|
||||
out.push(tags.get(start..end).unwrap_or(&[]));
|
||||
start = end;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether an entity's groups hold SAT text: groups 1 and 3 inside its `AcDbModelerGeometry`
|
||||
/// subclass. A file of a version before the subclass markers has no `100` groups: all of its
|
||||
/// groups (before the extended data) are the entity's.
|
||||
pub fn has_sat_text(tags: &[RawTag]) -> bool {
|
||||
let own = tags.iter().take_while(|r| r.code < 1000);
|
||||
let marked = tags.iter().take_while(|r| r.code < 1000).any(|r| r.code == 100);
|
||||
let mut in_modeler = !marked;
|
||||
for r in own {
|
||||
if r.code == 100 {
|
||||
in_modeler = r.value.trim().eq_ignore_ascii_case("AcDbModelerGeometry");
|
||||
} else if in_modeler && (r.code == 1 || r.code == 3) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// The group naming the entity a data store record belongs to: its first `320`. A record is kept
|
||||
/// or dropped by this handle alone, in the writer and wherever the store is read.
|
||||
pub fn data_store_owner(record: &[RawTag]) -> Option<&RawTag> {
|
||||
record.iter().find(|t| t.code == 320)
|
||||
}
|
||||
|
||||
impl Foreign {
|
||||
/// The kept objects by handle (the first of any that share one).
|
||||
fn object_index(&self) -> BTreeMap<Handle, &ForeignObject> {
|
||||
let mut m = BTreeMap::new();
|
||||
for o in &self.objects {
|
||||
m.entry(o.handle).or_insert(o);
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
/// The kept object `root` and the kept objects it owns, directly or not (dictionary entries
|
||||
/// and child pointers, groups 350 and 360), `root` first.
|
||||
pub fn tree(&self, root: Handle) -> Vec<Handle> {
|
||||
// Indexed once: a file can keep many objects, and a lookup per pointer would grow with
|
||||
// their product.
|
||||
let by_handle = self.object_index();
|
||||
let mut out = vec![root];
|
||||
let mut seen = BTreeSet::from([root]);
|
||||
let mut i = 0;
|
||||
while let Some(&h) = out.get(i) {
|
||||
i += 1;
|
||||
let Some(o) = by_handle.get(&h) else { continue };
|
||||
for c in o.tags.iter().filter(|t| matches!(t.code, 350 | 360)).filter_map(|t| Handle::parse_hex(t.value.trim())) {
|
||||
if out.len() < 4096 && by_handle.contains_key(&c) && seen.insert(c) {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Copy the objects of `tree` (see [`Foreign::tree`]) of `src` into this one under the handles
|
||||
/// `fresh` (the same order; two more follow for a field list), `owner` taking the place of the
|
||||
/// root's owner. References to objects outside the tree become null. Fields keep a field
|
||||
/// list: this one gets a copy of the source's if it has none.
|
||||
pub fn adopt_tree(&mut self, src: &Foreign, tree: &[Handle], fresh: &[Handle], owner: Handle) {
|
||||
if fresh.len() < tree.len() + 2 {
|
||||
return;
|
||||
}
|
||||
let map: BTreeMap<Handle, Handle> = tree.iter().copied().zip(fresh.iter().copied()).collect();
|
||||
let by_handle = src.object_index();
|
||||
let mut kinds = Vec::new();
|
||||
for (n, h) in tree.iter().enumerate() {
|
||||
let Some(o) = by_handle.get(h) else { continue };
|
||||
let mut c = (*o).clone();
|
||||
let Some(&new) = fresh.get(n) else { continue };
|
||||
c.handle = new;
|
||||
for t in &mut c.tags {
|
||||
if t.code == 5 {
|
||||
t.value = new.hex();
|
||||
} else if matches!(t.code, 320..=369 | 390..=399) {
|
||||
let to = Handle::parse_hex(t.value.trim()).and_then(|h| map.get(&h).copied()).or((n == 0 && t.code == 330).then_some(owner));
|
||||
t.value = to.map_or_else(|| "0".to_string(), Handle::hex);
|
||||
}
|
||||
}
|
||||
kinds.push(c.kind.clone());
|
||||
self.objects.push(c);
|
||||
}
|
||||
if let Some(list) = src.objects.iter().find(|o| o.kind == "FIELDLIST").filter(|_| !self.objects.iter().any(|o| o.kind == "FIELDLIST")) {
|
||||
let (Some(&h), Some(&nod)) = (fresh.get(tree.len()), fresh.get(tree.len() + 1)) else { return };
|
||||
let mut c = list.clone();
|
||||
c.handle = h;
|
||||
for t in &mut c.tags {
|
||||
match t.code {
|
||||
5 => t.value = h.hex(),
|
||||
330 => t.value = "0".to_string(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(t) = c.tags.iter_mut().find(|t| t.code == 330) {
|
||||
t.value = nod.hex();
|
||||
}
|
||||
self.targets.insert(nod, ("NOD".to_string(), String::new()));
|
||||
self.dictionary.push(("ACAD_FIELDLIST".to_string(), h));
|
||||
kinds.push(c.kind.clone());
|
||||
self.objects.push(c);
|
||||
}
|
||||
for k in kinds {
|
||||
let named = |c: &Vec<RawTag>| c.iter().any(|t| t.code == 1 && t.value.eq_ignore_ascii_case(&k));
|
||||
if !self.classes.iter().any(named) {
|
||||
self.classes.extend(src.classes.iter().filter(|c| named(c)).cloned());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+15
-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};
|
||||
@@ -132,6 +132,10 @@ pub struct Drawing {
|
||||
/// Records of the file this drawing was read from that the reader could not place (a damaged
|
||||
/// file), counted by the section they came from. Never written to a file.
|
||||
pub skipped_records: BTreeMap<String, usize>,
|
||||
/// A save option, set on the copy SAVEAS `portableFonts` writes: the file gets Windows fonts
|
||||
/// for the Linux fonts of text styles, and each style keeps its own in CADCraft xdata, so the
|
||||
/// drawing reopens with it. Never set on an open drawing.
|
||||
pub portable_fonts: bool,
|
||||
}
|
||||
|
||||
impl Default for Drawing {
|
||||
@@ -191,6 +195,7 @@ impl Drawing {
|
||||
read_notes: Vec::new(),
|
||||
refedit: None,
|
||||
skipped_records: BTreeMap::new(),
|
||||
portable_fonts: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -369,13 +374,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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,6 +189,19 @@ impl EntityStore {
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
/// The objects directly under and above `h` in the draw order (None at the bottom or the
|
||||
/// top), when `h` is here.
|
||||
pub fn neighbours(&self, h: Handle) -> Option<(Option<Handle>, Option<Handle>)> {
|
||||
let pos = self.chunk_pos(self.chunk_id(h)?)?;
|
||||
let items = &self.chunks.get(pos)?.items;
|
||||
let i = items.iter().position(|e| e.handle == h)?;
|
||||
let below = match i.checked_sub(1) {
|
||||
Some(j) => items.get(j),
|
||||
None => pos.checked_sub(1).and_then(|p| self.chunks.get(p)).and_then(|c| c.items.last()),
|
||||
};
|
||||
let above = items.get(i + 1).or_else(|| self.chunks.get(pos + 1).and_then(|c| c.items.first()));
|
||||
Some((below.map(|e| e.handle), above.map(|e| e.handle)))
|
||||
}
|
||||
/// Move `h` directly above (`above`) or under `anchor` in the draw order. False (and nothing
|
||||
/// moves) when either is absent or they are the same object.
|
||||
pub fn move_next_to(&mut self, h: Handle, anchor: Handle, above: bool) -> bool {
|
||||
@@ -364,4 +377,26 @@ mod tests {
|
||||
assert_eq!(s.get(Handle(h)).map(|e| e.handle.0), Some(h));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn neighbours_across_chunks() {
|
||||
let mut s = EntityStore::new();
|
||||
for h in 1..=3000 {
|
||||
s.push(ent(h));
|
||||
}
|
||||
assert_eq!(s.neighbours(Handle(1)), Some((None, Some(Handle(2)))));
|
||||
assert_eq!(s.neighbours(Handle(1500)), Some((Some(Handle(1499)), Some(Handle(1501)))));
|
||||
// The first and last objects of a chunk look into the chunks next to it.
|
||||
let k = CHUNK_MAX as u64;
|
||||
assert_eq!(s.neighbours(Handle(k)), Some((Some(Handle(k - 1)), Some(Handle(k + 1)))));
|
||||
assert_eq!(s.neighbours(Handle(k + 1)), Some((Some(Handle(k)), Some(Handle(k + 2)))));
|
||||
assert_eq!(s.neighbours(Handle(3000)), Some((Some(Handle(2999)), None)));
|
||||
assert_eq!(s.neighbours(Handle(9999)), None);
|
||||
assert!(s.move_next_to(Handle(3000), Handle(1), false));
|
||||
assert_eq!(s.neighbours(Handle(1)), Some((Some(Handle(3000)), Some(Handle(2)))));
|
||||
assert_eq!(s.neighbours(Handle(3000)), Some((None, Some(Handle(1)))));
|
||||
let mut one = EntityStore::new();
|
||||
one.push(ent(7));
|
||||
assert_eq!(one.neighbours(Handle(7)), Some((None, None)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -799,6 +799,10 @@ pub struct PageSetup {
|
||||
/// The layout's paper units are millimetres (plot paper units 1 in DXF) whatever the
|
||||
/// drawing's MEASUREMENT says; otherwise paper units follow MEASUREMENT (#1058).
|
||||
pub paper_mm: bool,
|
||||
/// The paper image origin (DXF groups 148/149, in plot paper units) as the file had it, so a
|
||||
/// save keeps where the sheet sits; `None` for a page CADCraft made, written as 0,0 (#2109).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub image_origin: Option<Vec2>,
|
||||
}
|
||||
|
||||
impl Default for PageSetup {
|
||||
@@ -820,6 +824,7 @@ impl Default for PageSetup {
|
||||
show_plot_styles: false,
|
||||
plot_transparency: false,
|
||||
paper_mm: false,
|
||||
image_origin: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -835,11 +840,15 @@ pub struct Layout {
|
||||
/// Saved paper-space view (centre, height).
|
||||
#[serde(default)]
|
||||
pub view: Option<(Vec2, f64)>,
|
||||
/// The layout's limits (LAYOUT groups 10/11, lower left and upper right) as the file had
|
||||
/// them; `None` for a layout CADCraft made, written as 0,0 to 12,9 (#2109).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub limits: Option<(Vec2, Vec2)>,
|
||||
}
|
||||
|
||||
impl Layout {
|
||||
pub fn new(name: &str, tab_order: u32) -> Self {
|
||||
Layout { name: name.into(), tab_order, page: PageSetup::default(), entities: EntityStore::new(), view: None }
|
||||
Layout { name: name.into(), tab_order, page: PageSetup::default(), entities: EntityStore::new(), view: None, limits: None }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+149
-21
@@ -44,15 +44,123 @@ 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));
|
||||
}
|
||||
|
||||
/// DXF group 12: a dimension moved by an older AutoCAD keeps its `*D` block where it was and
|
||||
/// the move in the insertion point, and the block draws translated by it. Extents follow the
|
||||
/// drawing, not the stale block contents (#2634).
|
||||
#[test]
|
||||
fn dimension_insertion_point_moves_its_block_in_extents() {
|
||||
let moved = |ins: Vec3| {
|
||||
let mut d = Drawing::new_metric();
|
||||
let mut blk = Block::new("*D1");
|
||||
blk.entities.push(on_layer("0", line((850.0, 920.0), (860.0, 920.0)), 2));
|
||||
d.blocks.insert("*D1".into(), std::sync::Arc::new(blk));
|
||||
let dm = Dimension {
|
||||
kind: DimKind::Aligned,
|
||||
defpt: Vec3::new(5.0, 1.0, 0.0),
|
||||
text_mid: Vec3::ZERO,
|
||||
p13: Vec3::ZERO,
|
||||
p14: Vec3::new(10.0, 0.0, 0.0),
|
||||
p15: Vec3::ZERO,
|
||||
p16: Vec3::ZERO,
|
||||
text: String::new(),
|
||||
style: "Standard".into(),
|
||||
measurement: 0.0,
|
||||
text_rotation: 0.0,
|
||||
user_text_pos: false,
|
||||
block: Some("*D1".into()),
|
||||
insertion_point: ins,
|
||||
overrides: Default::default(),
|
||||
assoc: Vec::new(),
|
||||
marks: Default::default(),
|
||||
};
|
||||
d.add(&Space::Model, Common::default(), EntityKind::Dimension(dm)).unwrap();
|
||||
d
|
||||
};
|
||||
// Without the offset the block's far-away contents reach the extents.
|
||||
assert!(moved(Vec3::ZERO).extents(&Space::Model).max.x > 800.0);
|
||||
// With it, the block draws at the definition points and the extents stay at the drawing.
|
||||
assert!(moved(Vec3::new(-850.0, -920.0, 0.0)).extents(&Space::Model).max.x < 20.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 +423,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 +516,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]
|
||||
@@ -548,6 +675,7 @@ fn invisible_dimblock_entity_is_ignored_in_dimension_extents() {
|
||||
overrides: Default::default(),
|
||||
assoc: vec![],
|
||||
marks: Default::default(),
|
||||
insertion_point: Vec3::ZERO,
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
@@ -558,10 +686,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);
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
//! A 3D face draws only its visible edges: `hidden_edges` bit i hides the side from corner i to
|
||||
//! the next, as DXF group 70 defines it, and a triangle's zero-length side isn't drawn.
|
||||
|
||||
use cadcraft_doc::{EntityKind, Face3d, Prim};
|
||||
use cadcraft_geom::Vec3;
|
||||
|
||||
fn sides(f: Face3d) -> usize {
|
||||
EntityKind::Face3d(f).prims().iter().filter(|p| matches!(p, Prim::Seg(_))).count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_edges_are_not_drawn() {
|
||||
let quad = [Vec3::new(0.0, 0.0, 0.0), Vec3::new(1.0, 0.0, 0.0), Vec3::new(1.0, 1.0, 0.0), Vec3::new(0.0, 1.0, 0.0)];
|
||||
assert_eq!(sides(Face3d { corners: quad, hidden_edges: 0 }), 4);
|
||||
assert_eq!(sides(Face3d { corners: quad, hidden_edges: 1 | 4 }), 2);
|
||||
assert_eq!(sides(Face3d { corners: quad, hidden_edges: 15 }), 0);
|
||||
let triangle = [quad[0], quad[1], quad[2], quad[2]];
|
||||
assert_eq!(sides(Face3d { corners: triangle, hidden_edges: 0 }), 3);
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
mod face3d_hidden_edges;
|
||||
mod lin_pat_parse;
|
||||
mod sub_segment_alias;
|
||||
|
||||
@@ -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"
|
||||
acadrust = "0.6.6"
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
+404
-34
@@ -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,17 +104,17 @@ 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"));
|
||||
}
|
||||
let mut out = CappedWriter { buf: Vec::new(), limit: limits.max_dxf_bytes, exceeded: false };
|
||||
let written = std::panic::catch_unwind(AssertUnwindSafe(|| acadrust::DxfWriter::new(&doc).write_to_writer(&mut out)))
|
||||
.map_err(|_| "the DWG→DXF conversion failed on this file".to_string())?;
|
||||
let written = std::panic::catch_unwind(AssertUnwindSafe(|| {
|
||||
acadrust::DxfWriter::new(&doc).write_to_writer(&mut out)?;
|
||||
mtext_contents(&doc, &mut out)
|
||||
}))
|
||||
.map_err(|_| "the DWG→DXF conversion failed on this file".to_string())?;
|
||||
if out.exceeded {
|
||||
return Err(format!(
|
||||
"DWG: converting this {} file produced more than {} of drawing data (the limit); \
|
||||
@@ -127,6 +127,94 @@ mod native {
|
||||
Ok((add_header_vars(out.buf, &doc.header), notes))
|
||||
}
|
||||
|
||||
/// acadrust 0.6.6's ASCII writer treats a caret followed by a literal space as already
|
||||
/// escaped, and turns line feeds into paragraph codes. Serialize affected MTEXT contents
|
||||
/// from the native values instead, using its lossless string writer. All other groups,
|
||||
/// including embedded-object data, remain byte-for-byte as the DXF writer emitted them.
|
||||
fn mtext_contents(doc: &acadrust::CadDocument, out: &mut CappedWriter) -> acadrust::Result<()> {
|
||||
use acadrust::io::dxf::{DxfStreamWriter, DxfTextWriter};
|
||||
let contents: std::collections::HashMap<_, _> = doc
|
||||
.entities()
|
||||
.filter_map(|e| match e {
|
||||
acadrust::EntityType::MText(t) if t.value.contains(['^', '\t', '\n']) => Some((t.common.handle.value(), t.value.as_str())),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
if contents.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let dxf = std::mem::take(&mut out.buf);
|
||||
let mut lines = dxf.split_inclusive(|b| *b == b'\n');
|
||||
let (mut mtext, mut text, mut written) = (false, None, false);
|
||||
while let Some(code) = lines.next() {
|
||||
let value = lines.next().ok_or_else(|| std::io::Error::other("incomplete DWG bridge DXF pair"))?;
|
||||
match code.trim_ascii() {
|
||||
b"0" => {
|
||||
mtext = value.trim_ascii() == b"MTEXT";
|
||||
text = None;
|
||||
written = false;
|
||||
}
|
||||
b"5" if mtext => {
|
||||
text = std::str::from_utf8(value.trim_ascii()).ok().and_then(|s| u64::from_str_radix(s, 16).ok()).and_then(|h| contents.get(&h));
|
||||
}
|
||||
b"101" => mtext = false,
|
||||
b"1" | b"3" if mtext && text.is_some() => {
|
||||
if let Some(&text) = text
|
||||
&& !written
|
||||
{
|
||||
let mut writer = DxfTextWriter::new(&mut *out);
|
||||
if doc.version < acadrust::DxfVersion::AC1021
|
||||
&& let Some(encoding) = acadrust::io::dxf::code_page::encoding_from_code_page(&doc.header.code_page)
|
||||
{
|
||||
writer.set_encoding(encoding);
|
||||
}
|
||||
// Each character can expand to two caret-escape characters. Keep
|
||||
// those pairs together and each group within 250 characters.
|
||||
let mut rest = text;
|
||||
while let Some((end, _)) = rest.char_indices().nth(125) {
|
||||
let (chunk, tail) = rest.split_at(end);
|
||||
writer.write_xrecord_string(3, chunk)?;
|
||||
rest = tail;
|
||||
}
|
||||
writer.write_xrecord_string(1, rest)?;
|
||||
written = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
out.write_all(code)?;
|
||||
out.write_all(value)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[test]
|
||||
fn mtext_contents_keeps_other_groups_and_the_output_limit() -> acadrust::Result<()> {
|
||||
for version in [acadrust::DxfVersion::AC1015, acadrust::DxfVersion::AC1032] {
|
||||
let mut doc = acadrust::CadDocument::new();
|
||||
doc.version = version;
|
||||
doc.header.code_page = "ANSI_1252".into();
|
||||
let mut t = acadrust::MText::with_value("é ^ C", acadrust::Vector3::ZERO);
|
||||
t.common.handle = acadrust::Handle::new(0x10);
|
||||
doc.add_entity(acadrust::EntityType::MText(Box::new(t)))?;
|
||||
// Only the MTEXT's own 1/3 groups change. The embedded object's groups and the
|
||||
// following TEXT record must survive byte-for-byte, including their spaces.
|
||||
let prefix = b" 0\r\nMTEXT\r\n 5\r\n10\r\n 8\r\n0\r\n";
|
||||
let suffix = b"101\r\nEmbedded Object\r\n 1\r\n^ embedded\r\n 3\r\n^ tail\r\n 0\r\nTEXT\r\n 1\r\n^ plain\r\n";
|
||||
let raw = [prefix.as_slice(), b" 3\r\nold\r\n 1\r\ntext\r\n", suffix.as_slice()].concat();
|
||||
let mut out = CappedWriter { buf: raw.clone(), limit: 4096, exceeded: false };
|
||||
mtext_contents(&doc, &mut out)?;
|
||||
let value: &[u8] = if version == acadrust::DxfVersion::AC1015 { b"\xe9 ^ C" } else { "é ^ C".as_bytes() };
|
||||
assert_eq!(out.buf, [prefix.as_slice(), b" 1\r\n", value, b"\r\n", suffix.as_slice()].concat());
|
||||
let mut limited = CappedWriter { buf: raw, limit: 1, exceeded: false };
|
||||
assert!(mtext_contents(&doc, &mut limited).is_err());
|
||||
assert!(limited.exceeded);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Most record types named in the skipped-records note.
|
||||
const NOTE_TYPES: usize = 6;
|
||||
|
||||
@@ -237,6 +325,72 @@ 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;
|
||||
|
||||
/// A table cell's own text height only when the file says it overrides the table style's.
|
||||
/// acadrust's DXF writer writes a cell style's height (group 140) whenever the cell has a
|
||||
/// style record, even a default 0.18 with no override flag, and CADCraft reads a 140 as an
|
||||
/// override (#2501). The override is the cell style's TEXT_HEIGHT property flag or the
|
||||
/// content's format override bit 128; without either the height is cleared (0 is no
|
||||
/// override), so the table style's height applies.
|
||||
fn table_cell_heights(doc: &mut acadrust::CadDocument) {
|
||||
use acadrust::entities::table::CellStylePropertyFlags;
|
||||
const CONTENT_TEXT_HEIGHT: i32 = 128;
|
||||
for e in doc.entities_mut() {
|
||||
let acadrust::entities::EntityType::Table(t) = e else { continue };
|
||||
for cell in t.rows.iter_mut().flat_map(|r| r.cells.iter_mut()) {
|
||||
// The first content's own height (group 144) when its format override sets bit 128.
|
||||
// CADCraft keeps one height for the cell's text, so a later content's override
|
||||
// must not stand in for the first content's.
|
||||
let content_height = cell
|
||||
.contents
|
||||
.first()
|
||||
.filter(|c| c.format_override_flags & CONTENT_TEXT_HEIGHT != 0)
|
||||
.map(|c| c.text_height)
|
||||
.filter(|h| h.is_finite() && *h > 0.0);
|
||||
if let Some(h) = content_height {
|
||||
// CADCraft reads only the style's height (group 140), so carry it there.
|
||||
cell.style.get_or_insert_with(acadrust::entities::table::CellStyle::new).text_height = h;
|
||||
continue;
|
||||
}
|
||||
// A style overrides through its property flags or, in newer files, its override
|
||||
// flags; anything else is the writer's default and is cleared.
|
||||
if let Some(style) = cell.style.as_mut()
|
||||
&& !style.property_flags.contains(CellStylePropertyFlags::TEXT_HEIGHT)
|
||||
&& style.override_flags & CellStylePropertyFlags::TEXT_HEIGHT.bits() as i32 == 0
|
||||
{
|
||||
style.text_height = 0.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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_cell_heights(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 +406,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 +422,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
|
||||
@@ -293,7 +474,7 @@ mod native {
|
||||
/// The DWG dimension flags byte. Its bit 1 is DXF group 70's bit 32 (ODA DWG specification,
|
||||
/// common dimension data), which acadrust keeps no copy of from DXF: it is set here as
|
||||
/// CADCraft's DXF writer always sets it (#1094). Bit 0, the opposite of group 70's bit 128,
|
||||
/// acadrust 0.6.5 reads and writes itself.
|
||||
/// acadrust 0.6.6 reads and writes itself.
|
||||
fn dimension_flags(doc: &mut acadrust::CadDocument) {
|
||||
for e in doc.entities_mut() {
|
||||
let acadrust::entities::EntityType::Dimension(d) = e else { continue };
|
||||
@@ -512,6 +693,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 +728,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 +837,168 @@ 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 height marked only in the style's override flags (as newer files store it) is kept,
|
||||
/// and the same height with no flag at all is cleared (review on #2588). Tested on the
|
||||
/// read document because acadrust's DWG writer doesn't write the override flags.
|
||||
#[test]
|
||||
fn a_style_override_flag_keeps_the_cell_height() {
|
||||
use acadrust::entities::table::{CellStyle, CellStylePropertyFlags};
|
||||
let height = |override_flags: i32| {
|
||||
let mut doc = doc_with_table(1, 1, 0);
|
||||
for e in doc.entities_mut() {
|
||||
if let EntityType::Table(t) = e {
|
||||
for cell in t.rows.iter_mut().flat_map(|r| r.cells.iter_mut()) {
|
||||
let mut s = CellStyle::new();
|
||||
s.text_height = 0.5;
|
||||
s.override_flags = override_flags;
|
||||
cell.style = Some(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
table_cell_heights(&mut doc);
|
||||
doc.entities().find_map(|e| match e {
|
||||
EntityType::Table(t) => t.rows.first()?.cells.first()?.style.as_ref().map(|s| s.text_height),
|
||||
_ => None,
|
||||
})
|
||||
};
|
||||
assert_eq!(height(CellStylePropertyFlags::TEXT_HEIGHT.bits() as i32), Some(0.5));
|
||||
assert_eq!(height(0), Some(0.0));
|
||||
}
|
||||
|
||||
/// 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 +1008,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 +1028,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 +1038,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 +1055,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 +1102,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 +1119,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::*;
|
||||
@@ -911,4 +1234,51 @@ mod tests {
|
||||
assert!(ls.contains(&"LSDEFINITION") && !ls.contains(&"LSINTERNALCOMPONENT"), "{ls:?}");
|
||||
assert_eq!(notes, ["DWG: 3 DGN line style components that no line style definition uses were left out"]);
|
||||
}
|
||||
|
||||
/// AutoCAD's DWG scale action (BLOCKSCALEACTION) ends with a byte after its three
|
||||
/// connections that DXF doesn't carry. acadrust 0.6.4 neither read nor wrote it, so AutoCAD
|
||||
/// read past the object and refused every DWG CADCraft wrote with a dynamic block that scales
|
||||
/// ("Error Decrypting Data"); 0.6.5 added it and 0.6.6, which this uses, still keeps it (0.6.6 also
|
||||
/// fixes multi-layout DWGs, #1463). The byte reaches the DWG, and a scale action
|
||||
/// from DXF is written whole, with the byte AutoCAD writes (0).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn scale_actions_keep_the_byte_autocad_reads_after_their_connections() {
|
||||
use acadrust::objects::*;
|
||||
let scale_action = |doc: &acadrust::CadDocument| {
|
||||
doc.objects.values().find_map(|o| match o {
|
||||
ObjectType::DynamicBlock(DynamicBlockObject { data: DynamicBlockData::ScaleAction(s), .. }) => Some(s.clone()),
|
||||
_ => None,
|
||||
})
|
||||
};
|
||||
let mut doc = acadrust::CadDocument::new();
|
||||
let h = doc.allocate_handle();
|
||||
let conn = |code, name: &str| BlockConnection { code, name: name.into() };
|
||||
let mut action = DynamicBlockObject::new("BLOCKSCALEACTION", "AcDbBlockScaleAction");
|
||||
action.handle = h;
|
||||
action.owner = doc.header.named_objects_dict_handle;
|
||||
let mut a = BlockBasePointAction::default();
|
||||
a.action.action.element.name = "Scale".into();
|
||||
a.action.connections = vec![conn(2, "UpdatedBaseX"), conn(2, "UpdatedBaseY")];
|
||||
a.action.dependent = true;
|
||||
a.connections = vec![conn(2, "Scale"), conn(2, "XScale"), conn(2, "YScale")];
|
||||
a.dwg_tail_byte = 7;
|
||||
action.data = DynamicBlockData::ScaleAction(a.clone());
|
||||
doc.objects.insert(h, ObjectType::DynamicBlock(action));
|
||||
|
||||
let dwg = acadrust::DwgWriter::write_to_vec(&doc).unwrap();
|
||||
let back = acadrust::DwgReader::from_stream(std::io::Cursor::new(dwg)).read().unwrap();
|
||||
assert_eq!(scale_action(&back).map(|s| s.dwg_tail_byte), Some(7), "the byte isn't written");
|
||||
|
||||
let mut dxf = Vec::new();
|
||||
acadrust::DxfWriter::new(&doc).write_to_writer(&mut dxf).unwrap();
|
||||
for version in ["AC1018", "AC1032"] {
|
||||
let dwg = dxf_to_dwg_as(&dxf, version).unwrap();
|
||||
let back = acadrust::DwgReader::from_stream(std::io::Cursor::new(dwg)).read().unwrap();
|
||||
let read = scale_action(&back).unwrap_or_else(|| panic!("{version}: the scale action wasn't written"));
|
||||
assert_eq!(read.connections, a.connections, "{version}");
|
||||
assert_eq!(read.action.connections, a.action.connections, "{version}");
|
||||
assert_eq!(read.dwg_tail_byte, 0, "{version}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,50 @@ 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))
|
||||
}
|
||||
|
||||
/// The topmost folder missing above `path`: what [`write_atomic_making_folders`] will create.
|
||||
/// `None` when the file's folder exists, or when `path` itself exists (a dangling symlink's target
|
||||
/// folder is not ours to make).
|
||||
pub fn missing_folder(path: &Path) -> Option<PathBuf> {
|
||||
if std::fs::symlink_metadata(path).is_ok() {
|
||||
return None;
|
||||
}
|
||||
path.ancestors().skip(1).filter(|a| !a.as_os_str().is_empty()).take_while(|a| std::fs::symlink_metadata(a).is_err()).last().map(Path::to_path_buf)
|
||||
}
|
||||
|
||||
/// [`write_atomic`] to a path the user named, first creating the folders missing above it: a
|
||||
/// mistyped folder costs less than a drawing that could not be written. The caller reports
|
||||
/// [`missing_folder`], asked before this call, so the new folder is never a surprise.
|
||||
pub fn write_atomic_making_folders(path: &Path, bytes: &[u8]) -> io::Result<()> {
|
||||
if let Some(folder) = missing_folder(path) {
|
||||
let dir = path.parent().unwrap_or(&folder);
|
||||
std::fs::create_dir_all(dir).map_err(|e| io::Error::new(e.kind(), format!("the folder {} could not be created: {e}", dir.display())))?;
|
||||
}
|
||||
write_atomic(path, 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 +86,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
|
||||
@@ -101,6 +180,30 @@ fn create_exclusive(path: &Path, permissions: Option<&Permissions>) -> io::Resul
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn creates_missing_parent_directories() {
|
||||
let dir = std::env::temp_dir().join(format!("cadcraft-aw-mkdir-{}-{}", std::process::id(), SEQ.fetch_add(1, Ordering::Relaxed)));
|
||||
let dest = dir.join("new_folder").join("deeper").join("out.bin");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
assert_eq!(missing_folder(&dest), Some(dir.join("new_folder")));
|
||||
assert!(write_atomic(&dest, b"created").is_err(), "the plain writer never makes folders");
|
||||
write_atomic_making_folders(&dest, b"created").unwrap();
|
||||
assert_eq!(std::fs::read(&dest).unwrap(), b"created");
|
||||
assert_eq!(missing_folder(&dest), None);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folder_that_cannot_be_made_is_named_in_the_error() {
|
||||
let dir = std::env::temp_dir().join(format!("cadcraft-aw-nomk-{}-{}", std::process::id(), SEQ.fetch_add(1, Ordering::Relaxed)));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let blocker = dir.join("a_file");
|
||||
std::fs::write(&blocker, b"x").unwrap();
|
||||
let e = write_atomic_making_folders(&blocker.join("sub").join("out.bin"), b"new").unwrap_err();
|
||||
assert!(e.to_string().contains("could not be created"), "{e}");
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replaces_contents_atomically() {
|
||||
let dir = std::env::temp_dir().join(format!("cadcraft-aw-{}-{}", std::process::id(), SEQ.fetch_add(1, Ordering::Relaxed)));
|
||||
@@ -116,6 +219,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 +262,54 @@ 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!(write_atomic_making_folders(&link, b"new").is_err());
|
||||
assert!(!dir.join("missing").exists(), "a link's unavailable target folder must not be created");
|
||||
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) {
|
||||
|
||||
+143
-21
@@ -4,15 +4,18 @@
|
||||
//! autosave is written as DXF to `<SAVEFILEPATH>/<drawing>_<instance>_<uid>.sv$`. The file goes
|
||||
//! away when the drawing is saved or closed and when the app exits normally, so a file left behind
|
||||
//! means the app (or the machine) went down; the next start offers it for recovery. A running app
|
||||
//! keeps a lock file (`<instance>.lock`) fresh, so another copy running at the same time doesn't
|
||||
//! offer its files. SAVEFILEPATH may be a folder other programs use too, so only names built from
|
||||
//! an instance id ([`is_instance_id`]) are ever read as ours or deleted.
|
||||
//! holds an operating-system lock on its lock file (`<instance>.lock`) and keeps the file fresh, so
|
||||
//! another copy running at the same time doesn't offer its files. The system drops the lock the
|
||||
//! moment a process ends, however it ended, so a relaunch after a crash can offer recovery at once.
|
||||
//! Where locks aren't available, and for lock files of older versions, a file not refreshed for
|
||||
//! [`STALE_LOCK_SECS`] counts as stopped. SAVEFILEPATH may be a folder other programs use too, so
|
||||
//! only names built from an instance id ([`is_instance_id`]) are ever read as ours or deleted.
|
||||
//!
|
||||
//! [`Autosaver::plan`] decides what to write and delete (no I/O, so it is testable); on the desktop
|
||||
//! [`Autosaver::tick`] carries it out, writing on a background thread. SAVETIME and SAVEFILEPATH
|
||||
//! are profile settings: process-wide, not saved in the drawing.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::sync::{Arc, Mutex, Weak};
|
||||
|
||||
@@ -29,11 +32,27 @@ pub const DEFAULT_SAVETIME: u32 = 10;
|
||||
pub const MAX_SAVETIME: u32 = 600;
|
||||
/// A lock file not refreshed for this long belongs to an app that is no longer running.
|
||||
pub const STALE_LOCK_SECS: u64 = 5 * 60;
|
||||
/// How a lock file ends when its app holds the system lock on it, so a free lock proves the app
|
||||
/// stopped. Older versions write only the process id and never hold one.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const LOCK_HELD: &str = " held";
|
||||
/// How often a running app refreshes its lock file.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
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 +78,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
|
||||
@@ -125,13 +156,19 @@ pub struct Autosaver {
|
||||
docs: HashMap<u64, Tracked>,
|
||||
/// Drawings whose autosave is being written right now.
|
||||
busy: HashSet<u64>,
|
||||
/// Writes in progress; each tells whether its file was written.
|
||||
/// Why the last autosave of each drawing failed, until one succeeds or the drawing no longer
|
||||
/// needs it. Ordered, so [`Autosaver::failure`] doesn't change between frames.
|
||||
failed: BTreeMap<u64, String>,
|
||||
/// Writes in progress; each ends with why its file wasn't written, if it wasn't.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
running: Vec<(u64, std::thread::JoinHandle<bool>)>,
|
||||
running: Vec<(u64, std::thread::JoinHandle<std::result::Result<(), String>>)>,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
start: std::time::Instant,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
last_beat: Option<f64>,
|
||||
/// The lock file this app holds the system lock on, open for as long as the lock is wanted.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
lock: Option<(std::path::PathBuf, std::fs::File)>,
|
||||
}
|
||||
|
||||
impl Autosaver {
|
||||
@@ -140,12 +177,15 @@ impl Autosaver {
|
||||
instance: instance.into(),
|
||||
docs: HashMap::new(),
|
||||
busy: HashSet::new(),
|
||||
failed: BTreeMap::new(),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
running: Vec::new(),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
start: std::time::Instant::now(),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
last_beat: None,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
lock: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,6 +203,11 @@ impl Autosaver {
|
||||
self.docs.values().filter_map(|t| t.file.clone()).collect()
|
||||
}
|
||||
|
||||
/// Why a drawing currently has no up-to-date recovery copy, for the host to show the user.
|
||||
pub fn failure(&self) -> Option<&str> {
|
||||
self.failed.values().next().map(String::as_str)
|
||||
}
|
||||
|
||||
/// What to do at `now` (seconds, any fixed origin) with SAVETIME `minutes` and autosave folder
|
||||
/// `dir`: write drawings changed `minutes` ago or earlier and not autosaved since, delete the
|
||||
/// files of drawings that were saved (no unsaved changes left) or closed.
|
||||
@@ -196,6 +241,7 @@ impl Autosaver {
|
||||
jobs.push(Job::Delete { path });
|
||||
}
|
||||
*t = Tracked::default();
|
||||
self.failed.remove(&st.uid);
|
||||
continue;
|
||||
}
|
||||
let same = |w: &Option<Weak<Drawing>>, d: &Arc<Drawing>| w.as_ref().is_some_and(|w| std::ptr::eq(w.as_ptr(), Arc::as_ptr(d)));
|
||||
@@ -224,6 +270,8 @@ impl Autosaver {
|
||||
t.since = None;
|
||||
jobs.push(Job::Write { uid: st.uid, path, doc });
|
||||
}
|
||||
let docs = &self.docs;
|
||||
self.failed.retain(|uid, _| docs.contains_key(uid));
|
||||
jobs
|
||||
}
|
||||
|
||||
@@ -253,12 +301,14 @@ pub use native::*;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod native {
|
||||
use std::fs::{File, OpenOptions, TryLockError};
|
||||
use std::io::{Read, Write};
|
||||
use std::path::Path;
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use super::{
|
||||
Autosaver, Drawing, HEARTBEAT_SECS, HashMap, Job, Recoverable, STALE_LOCK_SECS, Session, is_instance_id, parse_file_name, parse_lock_name,
|
||||
savefilepath, savetime,
|
||||
Autosaver, Drawing, HEARTBEAT_SECS, HashMap, Job, LOCK_HELD, Recoverable, STALE_LOCK_SECS, Session, is_instance_id, parse_file_name,
|
||||
parse_lock_name, savefilepath, savetime,
|
||||
};
|
||||
|
||||
/// A new instance id: the process id and the start time.
|
||||
@@ -283,6 +333,28 @@ mod native {
|
||||
Some(SystemTime::now().duration_since(m).unwrap_or_default())
|
||||
}
|
||||
|
||||
/// Whether the app instance named by a lock file in `dir` may still be running: its system
|
||||
/// lock answers at once, and the file's age decides when it can't (no lock support, a lock
|
||||
/// file from an older version).
|
||||
fn owner_alive(dir: &Path, instance: &str) -> bool {
|
||||
let by_age = || instance_alive(lock_age(dir, instance));
|
||||
let path = lock_path(dir, instance);
|
||||
// Only a plain file is opened: opening a FIFO planted in a shared folder would block.
|
||||
if !std::fs::symlink_metadata(&path).is_ok_and(|m| m.is_file()) {
|
||||
return by_age();
|
||||
}
|
||||
let Ok(file) = File::open(&path) else { return by_age() };
|
||||
match file.try_lock() {
|
||||
Err(TryLockError::WouldBlock) => true,
|
||||
Err(TryLockError::Error(_)) => by_age(),
|
||||
Ok(()) => {
|
||||
let mut text = String::new();
|
||||
let held = file.take(64).read_to_string(&mut text).is_ok() && text.ends_with(LOCK_HELD);
|
||||
!held && by_age()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The autosave files in `dir` left behind by apps that are no longer running (never those of
|
||||
/// `own`, this app's instance), oldest first.
|
||||
pub fn recoverable(dir: &Path, own: &str) -> Vec<Recoverable> {
|
||||
@@ -292,7 +364,7 @@ mod native {
|
||||
for e in entries.flatten().take(10_000) {
|
||||
let name = e.file_name().to_string_lossy().to_string();
|
||||
let Some((title, instance, _)) = parse_file_name(&name) else { continue };
|
||||
if instance == own || *alive.entry(instance.to_string()).or_insert_with(|| instance_alive(lock_age(dir, instance))) {
|
||||
if instance == own || *alive.entry(instance.to_string()).or_insert_with(|| owner_alive(dir, instance)) {
|
||||
continue;
|
||||
}
|
||||
let Ok(meta) = e.metadata() else { continue };
|
||||
@@ -334,7 +406,7 @@ mod native {
|
||||
for n in &names {
|
||||
let Some(instance) = parse_lock_name(n) else { continue };
|
||||
let has_files = names.iter().any(|f| parse_file_name(f).is_some_and(|(_, i, _)| i == instance));
|
||||
if !has_files && !instance_alive(lock_age(dir, instance)) {
|
||||
if !has_files && !owner_alive(dir, instance) {
|
||||
let _ = std::fs::remove_file(dir.join(n));
|
||||
}
|
||||
}
|
||||
@@ -392,24 +464,36 @@ 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.
|
||||
/// so it is tried again SAVETIME later even if it doesn't change, and [`Autosaver::failure`]
|
||||
/// says why until a write succeeds.
|
||||
pub fn reap(&mut self) {
|
||||
let (done, running) = std::mem::take(&mut self.running).into_iter().partition::<Vec<_>, _>(|(_, h)| h.is_finished());
|
||||
self.running = running;
|
||||
for (uid, h) in done {
|
||||
self.busy.remove(&uid);
|
||||
if !h.join().unwrap_or(false) {
|
||||
self.unwritten(uid);
|
||||
match h.join().unwrap_or_else(|_| Err("the autosave writer stopped unexpectedly".to_string())) {
|
||||
Ok(()) => {
|
||||
self.failed.remove(&uid);
|
||||
}
|
||||
Err(e) => self.unwritten(uid, e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn unwritten(&mut self, uid: u64) {
|
||||
fn unwritten(&mut self, uid: u64, why: String) {
|
||||
if let Some(t) = self.docs.get_mut(&uid) {
|
||||
t.written = None;
|
||||
}
|
||||
self.failed.insert(uid, why);
|
||||
}
|
||||
|
||||
/// Carry out jobs from [`Autosaver::plan`]: writes on a background thread, deletes now.
|
||||
@@ -420,16 +504,17 @@ mod native {
|
||||
Job::Write { uid, path, doc } => {
|
||||
let Some(write) = crate::cmd::file::io().map(|h| h.write) else {
|
||||
log::warn!("autosave: file formats are not available in this build");
|
||||
self.unwritten(uid, "file formats are not available in this build".to_string());
|
||||
continue;
|
||||
};
|
||||
let spawned = std::thread::Builder::new().name("autosave".into()).spawn(move || match write_file(write, &doc, &path) {
|
||||
Ok(()) => {
|
||||
log::info!("autosaved {path}");
|
||||
true
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("autosave {path}: {e}");
|
||||
false
|
||||
Err(format!("{path}: {e}"))
|
||||
}
|
||||
});
|
||||
match spawned {
|
||||
@@ -439,7 +524,7 @@ mod native {
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("autosave: {e}");
|
||||
self.unwritten(uid);
|
||||
self.unwritten(uid, e.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -454,12 +539,31 @@ mod native {
|
||||
}
|
||||
self.last_beat = Some(now);
|
||||
let dir = Path::new(dir);
|
||||
let r = create_folder(dir).and_then(|()| std::fs::write(lock_path(dir, &self.instance), std::process::id().to_string()));
|
||||
if let Err(e) = r {
|
||||
if let Err(e) = create_folder(dir).and_then(|()| self.refresh_lock(&lock_path(dir, &self.instance))) {
|
||||
log::warn!("autosave: lock file in {}: {e}", dir.display());
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh the lock file at `path`, taking the system lock on it first if this app doesn't
|
||||
/// hold it there yet. Without one the file still works by its age.
|
||||
fn refresh_lock(&mut self, path: &Path) -> std::io::Result<()> {
|
||||
if let Some((held, file)) = &self.lock
|
||||
&& held == path
|
||||
{
|
||||
return file.set_modified(SystemTime::now());
|
||||
}
|
||||
self.lock = None;
|
||||
let id = std::process::id();
|
||||
let mut file = OpenOptions::new().write(true).create(true).truncate(false).open(path)?;
|
||||
if file.try_lock().is_err() {
|
||||
return std::fs::write(path, id.to_string());
|
||||
}
|
||||
file.set_len(0)?;
|
||||
file.write_all(format!("{id}{LOCK_HELD}").as_bytes())?;
|
||||
self.lock = Some((path.to_path_buf(), file));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The app is closing normally: finish writes in progress, then delete this app's autosave
|
||||
/// files and its lock file.
|
||||
pub fn shutdown(&mut self) {
|
||||
@@ -471,6 +575,7 @@ mod native {
|
||||
discard(&path);
|
||||
}
|
||||
self.docs.clear();
|
||||
self.lock = None;
|
||||
let dir = savefilepath();
|
||||
if !dir.is_empty() && is_instance_id(&self.instance) {
|
||||
let _ = std::fs::remove_file(lock_path(Path::new(&dir), &self.instance));
|
||||
@@ -478,3 +583,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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,11 +268,12 @@ impl DocState {
|
||||
}
|
||||
|
||||
/// Start editing in the Block Editor `be` (the drawing is already the editor's): a fresh
|
||||
/// undo history, nothing selected, model space.
|
||||
/// undo history, nothing selected, nothing for OOPS, model space.
|
||||
fn enter_block_editor(&mut self, be: BlockEdit) {
|
||||
self.undo.clear();
|
||||
self.redo.clear();
|
||||
self.undo_ctl = Default::default();
|
||||
self.erased = Default::default();
|
||||
self.selection.clear();
|
||||
self.previous_selection.clear();
|
||||
self.space = cadcraft_doc::Space::Model;
|
||||
@@ -338,6 +339,7 @@ impl DocState {
|
||||
self.undo = host.undo;
|
||||
self.redo = host.redo;
|
||||
self.undo_ctl = host.undo_ctl;
|
||||
self.erased = host.erased;
|
||||
// The drawing comes back with nothing selected; Previous is what was selected before.
|
||||
self.selection.clear();
|
||||
self.previous_selection = if host.selection.is_empty() { host.previous_selection } else { host.selection };
|
||||
|
||||
@@ -25,6 +25,7 @@ mod lisp;
|
||||
mod mleader_content;
|
||||
mod mleader_edit;
|
||||
mod modify;
|
||||
pub(crate) use modify::Erased;
|
||||
mod modify2;
|
||||
mod mtext_opts;
|
||||
mod overkill;
|
||||
@@ -33,6 +34,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;
|
||||
|
||||
@@ -16,6 +16,7 @@ use crate::{Accept, EngineError, Input, Interactive, LeaderOrder, MLeaderOptions
|
||||
mod angular;
|
||||
mod common;
|
||||
mod edit;
|
||||
mod leader;
|
||||
mod linear;
|
||||
mod mleader;
|
||||
mod radial;
|
||||
@@ -88,7 +89,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.alias(&["mld"])
|
||||
.params("{points: [[arrow], ..., [landing]] ([location] with leaderType none), text?, leaderType?: straight|spline|none, block?: name, blockAttach?: center|insertion}")
|
||||
.interactive(|s| Ok(Box::new(MLeaderM::new(s)))),
|
||||
CommandSpec::new("leader", "Leader", run_leader).alias(&["lead"]).params("{points: [[x,y]...], text?}"),
|
||||
CommandSpec::new("leader", "Leader", run_leader)
|
||||
.alias(&["lead"])
|
||||
.params("{points: [[x,y]...], text?}")
|
||||
.interactive(|_| Ok(Box::new(leader::LeaderM::default()))),
|
||||
CommandSpec::new("dimstyle.update", "Update", run_update)
|
||||
.menu(&["Dimension", "Update"])
|
||||
.params("{handles?}")
|
||||
|
||||
@@ -22,6 +22,7 @@ pub(super) fn dim(s: &Session, kind: DimKind, defpt: Vec2, p13: Vec2, p14: Vec2,
|
||||
overrides: s.doc().map(|d| d.dim_overrides()).unwrap_or_default(),
|
||||
assoc: Vec::new(),
|
||||
marks: Default::default(),
|
||||
insertion_point: Vec3::ZERO,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -48,6 +49,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 +108,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`.
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
//! LEADER at the command line, as AutoCAD 2024 runs it (accoreconsole, synthetic drawings): the
|
||||
//! points, Format and Undo, then annotation text, no annotation, or a block.
|
||||
//!
|
||||
//! The leader takes a hook line, a horizontal last segment DIMASZ long, when its last segment is
|
||||
//! straight and more than 15° off horizontal and text follows it; the text sits DIMGAP past the
|
||||
//! leader's end, above it (DIMTAD on) or centred on it (DIMTAD 0), on the side the leader runs to.
|
||||
//! All sizes are scaled by DIMSCALE. The text and the leader are separate objects here: the
|
||||
//! drawing model has no link from a leader to its annotation.
|
||||
|
||||
use super::common::style;
|
||||
use super::{Accept, EntityKind, Handle, Input, Interactive, MText, Prompt, Result, Session, Step, Vec2, v3};
|
||||
use crate::cmd::curves::KW;
|
||||
|
||||
/// Where LEADER is in its prompts.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
enum Ask {
|
||||
#[default]
|
||||
Points,
|
||||
Format,
|
||||
FirstLine,
|
||||
NextLine,
|
||||
Option,
|
||||
/// Copy: the text or multiline text to copy.
|
||||
Copy,
|
||||
BlockName,
|
||||
BlockPoint,
|
||||
BlockX,
|
||||
BlockY,
|
||||
BlockRotation,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct LeaderM {
|
||||
ask: Ask,
|
||||
pts: Vec<Vec2>,
|
||||
spline: bool,
|
||||
no_arrow: bool,
|
||||
lines: Vec<String>,
|
||||
block: String,
|
||||
block_at: Vec2,
|
||||
block_scale: Vec2,
|
||||
}
|
||||
|
||||
/// What follows the leader.
|
||||
enum Annotation {
|
||||
Text(Vec<String>),
|
||||
/// A copy of a text or multiline text: its contents, height, width and style.
|
||||
Copy(MText),
|
||||
None,
|
||||
Block {
|
||||
name: String,
|
||||
at: Vec2,
|
||||
scale: Vec2,
|
||||
rotation: f64,
|
||||
},
|
||||
}
|
||||
|
||||
impl Interactive for LeaderM {
|
||||
fn name(&self) -> &'static str {
|
||||
"LEADER"
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
let last = self.pts.last().copied();
|
||||
match self.ask {
|
||||
Ask::Points => match self.pts.len() {
|
||||
0 => Prompt::new("Specify leader start point", Accept::POINT),
|
||||
1 => Prompt::new("Specify next point", Accept::POINT).base_opt(last),
|
||||
_ => Prompt::new("Specify next point", Accept::POINT).kw(&["Annotation", "Format", "Undo"]).default("Annotation").base_opt(last),
|
||||
},
|
||||
Ask::Format => Prompt::new("Enter leader format option", KW).kw(&["Spline", "STraight", "Arrow", "None"]).default("Exit"),
|
||||
// "or <options>": the default is the options, not a keyword list.
|
||||
Ask::FirstLine => Prompt::new("Enter first line of annotation text or", Accept::TEXT).default("options"),
|
||||
Ask::NextLine => Prompt::new("Enter next line of annotation text", Accept::TEXT),
|
||||
Ask::Copy => Prompt::new("Select an object to copy", Accept::SELECT),
|
||||
Ask::Option => Prompt::new("Enter an annotation option", KW).kw(&["Tolerance", "Copy", "Block", "None", "Mtext"]).default("Mtext"),
|
||||
Ask::BlockName => Prompt::new("Enter block name", Accept::TEXT).kw(&["?"]),
|
||||
Ask::BlockPoint => Prompt::new("Specify insertion point", Accept::POINT).kw(&["Basepoint", "Scale", "X", "Y", "Z", "Rotate"]),
|
||||
Ask::BlockX => Prompt::new("Enter X scale factor, specify opposite corner", Accept::NUMBER).kw(&["Corner", "XYZ"]).default("1"),
|
||||
Ask::BlockY => Prompt::new("Enter Y scale factor", Accept::NUMBER).default("use X scale factor"),
|
||||
Ask::BlockRotation => Prompt::new("Specify rotation angle", Accept::NUMBER).default("0"),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
match self.ask {
|
||||
Ask::Points => match i {
|
||||
Input::Point(p) => self.pts.push(p),
|
||||
// Enter after one point: no leader.
|
||||
Input::Enter if self.pts.len() < 2 => return Ok(Step::Done),
|
||||
Input::Enter => self.ask = Ask::FirstLine,
|
||||
Input::Keyword(k) => match k.as_str() {
|
||||
"Annotation" => self.ask = Ask::FirstLine,
|
||||
"Format" => self.ask = Ask::Format,
|
||||
_ => {
|
||||
self.pts.pop();
|
||||
}
|
||||
},
|
||||
_ => {}
|
||||
},
|
||||
Ask::Format => {
|
||||
// Enter, or Exit typed, changes nothing; either way back to the points.
|
||||
if let Input::Keyword(k) = &i {
|
||||
match k.as_str() {
|
||||
"Spline" => self.spline = true,
|
||||
"STraight" => self.spline = false,
|
||||
"Arrow" => self.no_arrow = false,
|
||||
_ => self.no_arrow = true,
|
||||
}
|
||||
}
|
||||
self.ask = Ask::Points;
|
||||
}
|
||||
Ask::FirstLine | Ask::NextLine => match i {
|
||||
Input::Text(t) if !t.is_empty() => {
|
||||
self.lines.push(t);
|
||||
self.ask = Ask::NextLine;
|
||||
}
|
||||
_ if self.ask == Ask::NextLine => {
|
||||
let lines = std::mem::take(&mut self.lines);
|
||||
return self.finish(s, Annotation::Text(lines));
|
||||
}
|
||||
_ => self.ask = Ask::Option,
|
||||
},
|
||||
Ask::Option => match &i {
|
||||
Input::Keyword(k) if k == "None" => return self.finish(s, Annotation::None),
|
||||
Input::Keyword(k) if k == "Block" => self.ask = Ask::BlockName,
|
||||
Input::Keyword(k) if k == "Copy" => self.ask = Ask::Copy,
|
||||
Input::Text(_) => s.echo("Invalid option keyword."),
|
||||
// Mtext (the default) and Tolerance open an editor or a dialog in AutoCAD; at the
|
||||
// command line, as in accoreconsole, they come back to the first line.
|
||||
_ => self.ask = Ask::FirstLine,
|
||||
},
|
||||
Ask::Copy => {
|
||||
let picked = match &i {
|
||||
Input::Pick(hs) => hs.first().copied(),
|
||||
Input::Point(p) => crate::select::pick(s.doc()?, &s.state()?.edit_space(), *p, s.settings.pickbox.max(1.0) * s.pixel_size()),
|
||||
_ => None,
|
||||
};
|
||||
let text = picked.and_then(|h| s.doc().ok()?.entity(h).cloned()).and_then(|e| match &e.kind {
|
||||
EntityKind::Text(t) => Some(MText {
|
||||
insert: t.insert,
|
||||
height: t.height,
|
||||
width: 0.0,
|
||||
attach: 4,
|
||||
rotation: 0.0,
|
||||
style: t.style.clone(),
|
||||
contents: t.value.clone(),
|
||||
line_spacing: 1.0,
|
||||
line_spacing_exact: false,
|
||||
columns: None,
|
||||
}),
|
||||
EntityKind::MText(m) => Some(m.clone()),
|
||||
_ => None,
|
||||
});
|
||||
match text {
|
||||
Some(m) => return self.finish(s, Annotation::Copy(m)),
|
||||
None if picked.is_some() => s.echo("The object is not text."),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
Ask::BlockName => {
|
||||
let name = match i {
|
||||
Input::Text(t) => t.trim().to_string(),
|
||||
Input::Keyword(k) => k,
|
||||
_ => return Ok(Step::Continue),
|
||||
};
|
||||
if name == "?" {
|
||||
let names: Vec<String> = s.doc()?.blocks.values().filter(|b| !b.anonymous).map(|b| b.name.clone()).collect();
|
||||
s.echo(format!("Defined blocks: {}", if names.is_empty() { "(none)".into() } else { names.join(", ") }));
|
||||
} else if s.doc()?.block(&name).is_none() {
|
||||
s.echo(format!("Block \"{name}\" not found."));
|
||||
} else {
|
||||
self.block = name;
|
||||
self.ask = Ask::BlockPoint;
|
||||
}
|
||||
}
|
||||
Ask::BlockPoint => match i {
|
||||
Input::Point(p) => {
|
||||
self.block_at = p;
|
||||
self.ask = Ask::BlockX;
|
||||
}
|
||||
Input::Keyword(_) => s.echo("This option is not available here yet."),
|
||||
_ => {}
|
||||
},
|
||||
Ask::BlockX => match scale(&i) {
|
||||
Some(x) => {
|
||||
self.block_scale = Vec2::new(x, x);
|
||||
self.ask = Ask::BlockY;
|
||||
}
|
||||
None => s.echo("Requires a nonzero number."),
|
||||
},
|
||||
Ask::BlockY => {
|
||||
match scale(&i) {
|
||||
Some(y) if !matches!(i, Input::Enter) => self.block_scale.y = y,
|
||||
Some(_) => {}
|
||||
None => {
|
||||
s.echo("Requires a nonzero number.");
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
}
|
||||
self.ask = Ask::BlockRotation;
|
||||
}
|
||||
Ask::BlockRotation => {
|
||||
let rotation = match &i {
|
||||
Input::Enter => 0.0,
|
||||
Input::Text(t) => match s.angle_settings().direction(t) {
|
||||
Some(a) => a,
|
||||
None => {
|
||||
s.echo("Requires a valid angle.");
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
},
|
||||
_ => return Ok(Step::Continue),
|
||||
};
|
||||
let a = Annotation::Block { name: self.block.clone(), at: self.block_at, scale: self.block_scale, rotation };
|
||||
return self.finish(s, a);
|
||||
}
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
}
|
||||
|
||||
/// A scale factor typed, or 1 for Enter; `None` for zero or anything that isn't a number.
|
||||
fn scale(i: &Input) -> Option<f64> {
|
||||
match i {
|
||||
Input::Enter => Some(1.0),
|
||||
Input::Text(t) => t.trim().parse::<f64>().ok().filter(|v| v.is_finite() && *v != 0.0),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
impl LeaderM {
|
||||
fn finish(&mut self, s: &mut Session, ann: Annotation) -> Result<Step> {
|
||||
let pts = std::mem::take(&mut self.pts);
|
||||
add_leader(s, &pts, self.spline, !self.no_arrow, ann)?;
|
||||
Ok(Step::Done)
|
||||
}
|
||||
}
|
||||
|
||||
/// The dimension settings LEADER draws with, from the current dimension style and DIMSCALE.
|
||||
struct Sizes {
|
||||
arrow: f64,
|
||||
gap: f64,
|
||||
text: f64,
|
||||
above: bool,
|
||||
text_style: String,
|
||||
}
|
||||
|
||||
fn sizes(s: &Session) -> Sizes {
|
||||
// The current style with the drawing's DIM* overrides (what SETVAR DIMTXT… sets).
|
||||
let st = s.doc().ok().and_then(|d| d.dim_style(&style(s)).map(|st| st.with_overrides(&d.dim_overrides()))).unwrap_or_default();
|
||||
let k = if st.scale > 0.0 { st.scale } else { 1.0 };
|
||||
let text_style = if st.text_style.is_empty() { "Standard".to_string() } else { st.text_style.clone() };
|
||||
Sizes { arrow: st.arrow_size * k, gap: st.text_gap.abs() * k, text: st.text_height * k, above: st.text_above != 0, text_style }
|
||||
}
|
||||
|
||||
/// LEADER's JSON form: a straight leader with an arrow through `pts`, and `text` (lines split at
|
||||
/// `\n`) placed as the command line places it. The handles of the leader and its text.
|
||||
pub(super) fn json_leader(s: &mut Session, pts: &[Vec2], text: Option<&str>) -> Result<Vec<Handle>> {
|
||||
let ann = match text {
|
||||
Some(t) if !t.is_empty() => Annotation::Text(t.split('\n').map(str::to_string).collect()),
|
||||
_ => Annotation::None,
|
||||
};
|
||||
add_leader(s, pts, false, true, ann)
|
||||
}
|
||||
|
||||
/// Draw the leader through `pts` and its annotation, as AutoCAD 2024 places them; their handles.
|
||||
fn add_leader(s: &mut Session, pts: &[Vec2], spline: bool, arrow: bool, ann: Annotation) -> Result<Vec<Handle>> {
|
||||
let (Some(&end), Some(&before)) = (pts.last(), pts.len().checked_sub(2).and_then(|i| pts.get(i))) else { return Ok(Vec::new()) };
|
||||
let z = sizes(s);
|
||||
// The side the leader runs to: right unless its last segment runs left.
|
||||
let dir = if end.x < before.x { -1.0 } else { 1.0 };
|
||||
let mut vertices = pts.to_vec();
|
||||
if let Annotation::Text(_) | Annotation::Copy(_) = ann {
|
||||
let d = end - before;
|
||||
// A copied text is centred on its line, and with DIMTAD on that line is above the leader:
|
||||
// the leader ends half the text height lower.
|
||||
if let (Annotation::Copy(_), true, Some(last)) = (&ann, z.above, vertices.last_mut()) {
|
||||
last.y -= z.text / 2.0;
|
||||
}
|
||||
let end = vertices.last().copied().unwrap_or(end);
|
||||
// More than 15° off horizontal: a hook line DIMASZ long into the end.
|
||||
if !spline && d.y.abs() > d.x.abs() * 15f64.to_radians().tan() {
|
||||
let at = vertices.len() - 1;
|
||||
vertices.insert(at, Vec2::new(end.x - dir * z.arrow, end.y));
|
||||
}
|
||||
}
|
||||
let leader = s.add_entity(EntityKind::Leader(cadcraft_doc::Leader {
|
||||
vertices: vertices.iter().map(|p| v3(*p)).collect(),
|
||||
arrow,
|
||||
spline,
|
||||
style: style(s),
|
||||
}))?;
|
||||
let annotation = match ann {
|
||||
Annotation::Text(lines) => {
|
||||
let (attach, lift) = match (z.above, dir > 0.0) {
|
||||
(true, true) => (7, z.gap),
|
||||
(true, false) => (9, z.gap),
|
||||
(false, true) => (4, 0.0),
|
||||
(false, false) => (6, 0.0),
|
||||
};
|
||||
Some(s.add_entity(EntityKind::MText(MText {
|
||||
insert: v3(end + Vec2::new(dir * z.gap, lift)),
|
||||
height: z.text,
|
||||
width: 0.0,
|
||||
attach,
|
||||
rotation: 0.0,
|
||||
style: z.text_style,
|
||||
contents: lines.join("\\P"),
|
||||
line_spacing: 1.0,
|
||||
line_spacing_exact: false,
|
||||
columns: None,
|
||||
}))?)
|
||||
}
|
||||
Annotation::Copy(m) => {
|
||||
let lift = if z.above { z.gap } else { 0.0 };
|
||||
let attach = if dir > 0.0 { 4 } else { 6 };
|
||||
Some(s.add_entity(EntityKind::MText(MText { insert: v3(end + Vec2::new(dir * z.gap, lift)), attach, rotation: 0.0, ..m }))?)
|
||||
}
|
||||
Annotation::Block { name, at, scale, rotation } => {
|
||||
match crate::cmd::blocks::insert_with(s, &name, at, scale, rotation, None, &serde_json::Map::new()) {
|
||||
Ok(h) => Some(h),
|
||||
Err(e) => {
|
||||
s.doc_mut()?.remove_entity(leader);
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Annotation::None => None,
|
||||
};
|
||||
Ok(std::iter::once(leader).chain(annotation).collect())
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
use super::common::style;
|
||||
use super::{
|
||||
Accept, EngineError, EntityKind, Input, Interactive, LeaderOrder, MLeader, MLeaderOptions, MText, Prompt, Result, Session, Step, Value, Vec2,
|
||||
bad, json, points_param, str_param, v3,
|
||||
@@ -57,33 +56,8 @@ pub(super) fn run_leader(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if pts.len() < 2 {
|
||||
return Err(bad("leader", "need 2+ points"));
|
||||
}
|
||||
let st = style(s);
|
||||
let h = s.add_entity(EntityKind::Leader(cadcraft_doc::Leader {
|
||||
vertices: pts.iter().map(|q| v3(*q)).collect(),
|
||||
arrow: true,
|
||||
spline: false,
|
||||
style: st,
|
||||
}))?;
|
||||
let mut out = vec![h.hex()];
|
||||
if let (Some(t), Some(last)) = (str_param(p, "text"), pts.last()) {
|
||||
let th = s.doc()?.dim_style(&style(s)).map(|d| d.text_height).unwrap_or(0.18);
|
||||
out.push(
|
||||
s.add_entity(EntityKind::MText(MText {
|
||||
insert: v3(*last + Vec2::new(th * 0.5, th * 0.5)),
|
||||
height: th,
|
||||
width: 0.0,
|
||||
attach: 4,
|
||||
rotation: 0.0,
|
||||
style: "Standard".into(),
|
||||
contents: t.into(),
|
||||
line_spacing: 1.0,
|
||||
line_spacing_exact: false,
|
||||
columns: None,
|
||||
}))?
|
||||
.hex(),
|
||||
);
|
||||
}
|
||||
Ok(json!({ "handles": out }))
|
||||
let hs = super::leader::json_leader(s, &pts, str_param(p, "text"))?;
|
||||
Ok(json!({ "handles": hs.iter().map(|h| h.hex()).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
/// The MLEADER Options prompt being answered.
|
||||
|
||||
@@ -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,16 +368,20 @@ 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);
|
||||
let fill = f64_or(p, "angle", 360.0);
|
||||
if !(fill.abs() >= 1e-9 && fill.abs() <= 360.0 + 1e-9) {
|
||||
return Err(other("Requires a fill angle between -360 and 360 degrees (not 0)."));
|
||||
}
|
||||
let kind = ArrayKind::Polar(PolarArray {
|
||||
center: c - base,
|
||||
items: count32(n),
|
||||
fill: f64_or(p, "angle", 360.0),
|
||||
fill: fill.clamp(-360.0, 360.0),
|
||||
rotate: bool_or(p, "rotate", true),
|
||||
rows: count32(rows),
|
||||
row_spacing,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+168
-121
@@ -7,7 +7,7 @@ pub(in crate::cmd) mod purge;
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_doc::{Attrib, Block, Entity, EntityKind, HAlign, Handle, Insert, Text, VAlign};
|
||||
use cadcraft_geom::{Mat3, Vec2, Vec3};
|
||||
use cadcraft_geom::{Vec2, Vec3};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::helpers::v3;
|
||||
@@ -17,12 +17,14 @@ use super::{CommandSpec, bad, bool_or, f64_or, find_command, ok, point_param, po
|
||||
use super::has_doc;
|
||||
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
mod attdef;
|
||||
mod attedit;
|
||||
mod burst;
|
||||
mod description;
|
||||
mod dyngrip;
|
||||
mod extract;
|
||||
pub(crate) mod refedit;
|
||||
mod wblock;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
@@ -45,10 +47,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.menu(&["Draw", "Block", "Define Attributes..."])
|
||||
.alias(&["att", "-attdef"])
|
||||
.params("{tag, prompt?, default?, at, height?, invisible?}")
|
||||
.interactive(|_| Ok(Box::new(AttdefM::default()))),
|
||||
.interactive(|_| Ok(Box::new(attdef::AttdefM::default()))),
|
||||
attedit::attedit_spec(),
|
||||
attedit::dash_attedit_spec(),
|
||||
CommandSpec::new("wblock", "Write Block", run_wblock).alias(&["w"]).params("{path, name? | handles?, base?}"),
|
||||
wblock::wblock_spec(),
|
||||
wblock::dash_wblock_spec(),
|
||||
CommandSpec::new("base", "Base", run_base).menu(&["Draw", "Block", "Base"]).params("{at: [x,y]}"),
|
||||
CommandSpec::new("blocks.dynamic", "Dynamic Block Data", run_dynamic)
|
||||
.params("{name (a block, or \"*Model_Space\" for a drawing that is a dynamic block's own)} → {parameters, actions, other} | null (not a dynamic block)")
|
||||
@@ -184,9 +187,16 @@ pub(crate) fn make_block(s: &mut Session, name: &str, base: Vec2, hs: &[Handle],
|
||||
b.entities.push(e);
|
||||
}
|
||||
doc.blocks.insert(name.to_string(), Arc::new(b));
|
||||
if keep != Keep::Retain {
|
||||
for h in hs {
|
||||
doc.remove_entity(*h);
|
||||
match keep {
|
||||
Keep::Retain => {}
|
||||
Keep::Convert => {
|
||||
for h in hs {
|
||||
doc.remove_entity(*h);
|
||||
}
|
||||
}
|
||||
// As after ERASE, OOPS brings them back.
|
||||
Keep::Delete => {
|
||||
super::modify::erase_kept(s, hs)?;
|
||||
}
|
||||
}
|
||||
s.set_selection(Vec::new());
|
||||
@@ -202,13 +212,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 +233,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 +243,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);
|
||||
@@ -347,53 +371,6 @@ fn run_attdef(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "handle": s.add_entity(k)?.hex() }))
|
||||
}
|
||||
|
||||
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();
|
||||
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.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).
|
||||
let base = point_param(p, "base");
|
||||
d.header.set("INSBASE", cadcraft_doc::HVal::Point(Vec3::ZERO));
|
||||
let m = Mat3::translate(-base.unwrap_or(Vec2::ZERO));
|
||||
let ents: Vec<Entity> = if let Some(name) = str_param(p, "name") {
|
||||
let b = src.block(name).ok_or_else(|| bad("wblock", format!("no block `{name}`")))?;
|
||||
let m = Mat3::translate(-base.unwrap_or(b.base.xy()));
|
||||
b.entities
|
||||
.iter()
|
||||
.map(|e| {
|
||||
let mut e = (**e).clone();
|
||||
e.kind.transform(&m);
|
||||
e
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
targets(s, p)?
|
||||
.iter()
|
||||
.filter_map(|h| src.entity(*h))
|
||||
.map(|e| {
|
||||
let mut e = (**e).clone();
|
||||
e.kind.transform(&m);
|
||||
e
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
for mut e in ents {
|
||||
e.handle = d.new_handle();
|
||||
d.model.push(e);
|
||||
}
|
||||
let hooks = super::file::io().ok_or_else(|| bad("wblock", "file formats are not available in this build"))?;
|
||||
let bytes = (hooks.write)(&d, &path).map_err(|e| bad("wblock", e))?;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
std::fs::write(&path, &bytes).map_err(|e| bad("wblock", format!("{path}: {e}")))?;
|
||||
Ok(json!({ "path": path, "entities": d.model.len(), "bytes": bytes.len() }))
|
||||
}
|
||||
|
||||
fn run_base(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let at = point_req("base", p, "at")?;
|
||||
s.doc_mut()?.header.set("INSBASE", cadcraft_doc::HVal::Point(v3(at)));
|
||||
@@ -566,17 +543,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 +570,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 +601,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 +625,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 +668,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 +715,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 +779,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 +808,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 +842,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 +859,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 +871,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 +895,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 +903,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 +925,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,53 +934,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))]
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct AttdefM {
|
||||
tag: Option<String>,
|
||||
prompt: Option<String>,
|
||||
default: Option<String>,
|
||||
}
|
||||
|
||||
impl Interactive for AttdefM {
|
||||
fn name(&self) -> &'static str {
|
||||
"ATTDEF"
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
match (&self.tag, &self.prompt, &self.default) {
|
||||
(None, ..) => Prompt::new("Enter attribute tag name", Accept::TEXT),
|
||||
(Some(_), None, _) => Prompt::new("Enter attribute prompt", Accept::TEXT),
|
||||
(Some(_), Some(_), None) => Prompt::new("Enter default attribute value", Accept::TEXT),
|
||||
_ => Prompt::new("Specify start point of text", Accept::POINT),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
let text = match &i {
|
||||
Input::Text(t) => Some(t.clone()),
|
||||
Input::Enter => Some(String::new()),
|
||||
_ => None,
|
||||
};
|
||||
match (&self.tag, &self.prompt, &self.default) {
|
||||
(None, ..) => {
|
||||
if let Some(t) = text.filter(|t| !t.trim().is_empty() && !t.contains(' ')) {
|
||||
self.tag = Some(t.to_ascii_uppercase());
|
||||
}
|
||||
}
|
||||
(Some(_), None, _) => self.prompt = text,
|
||||
(Some(_), Some(_), None) => self.default = text,
|
||||
(Some(tag), Some(pr), Some(def)) => {
|
||||
if let Input::Point(p) = i {
|
||||
let h = s.doc()?.header.f64("TEXTSIZE", 0.2);
|
||||
let k = attdef_kind(s, tag, pr, def, p, h, false);
|
||||
s.add_entity(k)?;
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
vec![EntityKind::Insert(insert_entity(b, at, self.scale3(scale), rot, self.base))]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
//! -ATTDEF at the command line, as AutoCAD 2024 runs it (accoreconsole): the attribute modes
|
||||
//! (kept in AFLAGS), the tag, prompt and default value (a constant attribute's value instead),
|
||||
//! then the text's point, justification, style, height and rotation.
|
||||
|
||||
use cadcraft_doc::{AttribModes, HAlign, VAlign};
|
||||
|
||||
use super::{Accept, Attrib, EntityKind, Input, Interactive, Prompt, Result, Session, Step, Text, Vec2, number, v3};
|
||||
use crate::cmd::curves::KW;
|
||||
use crate::cmd::draw::justify;
|
||||
|
||||
/// AFLAGS bits, as AutoCAD numbers the modes.
|
||||
const INVISIBLE: i64 = 1;
|
||||
const CONSTANT: i64 = 2;
|
||||
const VERIFY: i64 = 4;
|
||||
const PRESET: i64 = 8;
|
||||
const LOCK: i64 = 16;
|
||||
const MULTILINE: i64 = 32;
|
||||
|
||||
const MODES: [&str; 7] = ["Invisible", "Constant", "Verify", "Preset", "Lock position", "Annotative", "Multiple lines"];
|
||||
const JUSTIFY: [&str; 15] = ["Left", "Center", "Right", "Align", "Middle", "Fit", "TL", "TC", "TR", "ML", "MC", "MR", "BL", "BC", "BR"];
|
||||
|
||||
/// Where -ATTDEF is in its prompts.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
enum Ask {
|
||||
#[default]
|
||||
Modes,
|
||||
Tag,
|
||||
Prompt,
|
||||
Value,
|
||||
Point,
|
||||
Justify,
|
||||
Style,
|
||||
Height,
|
||||
Rotation,
|
||||
}
|
||||
|
||||
pub(super) struct AttdefM {
|
||||
ask: Ask,
|
||||
flags: i64,
|
||||
tag: String,
|
||||
prompt: String,
|
||||
value: String,
|
||||
justify: (HAlign, VAlign),
|
||||
style: String,
|
||||
at: Vec2,
|
||||
height: f64,
|
||||
}
|
||||
|
||||
impl Default for AttdefM {
|
||||
fn default() -> Self {
|
||||
AttdefM {
|
||||
ask: Ask::Modes,
|
||||
flags: LOCK,
|
||||
tag: String::new(),
|
||||
prompt: String::new(),
|
||||
value: String::new(),
|
||||
justify: (HAlign::Left, VAlign::Baseline),
|
||||
style: "Standard".into(),
|
||||
at: Vec2::ZERO,
|
||||
height: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AttdefM {
|
||||
fn echo_modes(&self, s: &mut Session) {
|
||||
let yn = |bit: i64| if self.flags & bit != 0 { "Y" } else { "N" };
|
||||
s.echo("Current attribute modes:");
|
||||
s.echo(format!(
|
||||
"Invisible={} Constant={} Verify={} Preset={} Lock position={} Annotative=N Multiple line={}",
|
||||
yn(INVISIBLE),
|
||||
yn(CONSTANT),
|
||||
yn(VERIFY),
|
||||
yn(PRESET),
|
||||
yn(LOCK),
|
||||
yn(MULTILINE)
|
||||
));
|
||||
}
|
||||
|
||||
fn echo_style(&self, s: &mut Session) -> Result<()> {
|
||||
let th = s.doc()?.header.f64("TEXTSIZE", 0.2);
|
||||
s.echo(format!("Current text style: \"{}\" Text height: {th:.4} Justify: {}", self.style, justify_name(self.justify)));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn point_prompt(&self) -> &'static str {
|
||||
match self.justify {
|
||||
(HAlign::Center, VAlign::Baseline) => "Specify center point of text",
|
||||
(HAlign::Right, VAlign::Baseline) => "Specify right endpoint of text baseline",
|
||||
(HAlign::Middle, _) | (HAlign::Center, VAlign::Middle) => "Specify middle point of text",
|
||||
(HAlign::Left, VAlign::Top) => "Specify top-left point of text",
|
||||
(HAlign::Center, VAlign::Top) => "Specify top-center point of text",
|
||||
(HAlign::Right, VAlign::Top) => "Specify top-right point of text",
|
||||
(HAlign::Left, VAlign::Middle) => "Specify middle-left point of text",
|
||||
(HAlign::Right, VAlign::Middle) => "Specify middle-right point of text",
|
||||
(HAlign::Left, VAlign::Bottom) => "Specify bottom-left point of text",
|
||||
(HAlign::Center, VAlign::Bottom) => "Specify bottom-center point of text",
|
||||
(HAlign::Right, VAlign::Bottom) => "Specify bottom-right point of text",
|
||||
_ => "Specify start point of text",
|
||||
}
|
||||
}
|
||||
|
||||
fn finish(&mut self, s: &mut Session, rotation: f64) -> Result<Step> {
|
||||
let (h, v) = self.justify;
|
||||
let left = h == HAlign::Left && v == VAlign::Baseline;
|
||||
s.add_entity(EntityKind::AttDef(Attrib {
|
||||
tag: self.tag.to_uppercase(),
|
||||
text: Text {
|
||||
insert: v3(self.at),
|
||||
align_pt: if left { None } else { Some(v3(self.at)) },
|
||||
height: self.height,
|
||||
value: std::mem::take(&mut self.value),
|
||||
rotation,
|
||||
width_factor: 1.0,
|
||||
oblique: 0.0,
|
||||
style: self.style.clone(),
|
||||
halign: h,
|
||||
valign: v,
|
||||
},
|
||||
invisible: self.flags & INVISIBLE != 0,
|
||||
constant: self.flags & CONSTANT != 0,
|
||||
prompt: std::mem::take(&mut self.prompt),
|
||||
props: Default::default(),
|
||||
multiline: None,
|
||||
modes: AttribModes { verify: self.flags & VERIFY != 0, preset: self.flags & PRESET != 0, lock_position: self.flags & LOCK != 0 },
|
||||
}))?;
|
||||
Ok(Step::Done)
|
||||
}
|
||||
}
|
||||
|
||||
/// The justification's name in "Current text style: … Justify: …".
|
||||
fn justify_name(j: (HAlign, VAlign)) -> &'static str {
|
||||
JUSTIFY.into_iter().find(|k| justify(k) == Some(j)).unwrap_or("Left")
|
||||
}
|
||||
|
||||
impl Interactive for AttdefM {
|
||||
fn name(&self) -> &'static str {
|
||||
"ATTDEF"
|
||||
}
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
let d = s.doc()?;
|
||||
self.flags = d.header.i64("AFLAGS", LOCK);
|
||||
self.style = d.header.str("TEXTSTYLE", "Standard");
|
||||
self.echo_modes(s);
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
fn prompt(&self, s: &Session) -> Prompt {
|
||||
match self.ask {
|
||||
Ask::Modes => Prompt::new("Enter an option to change", KW).kw(&MODES).default("done"),
|
||||
Ask::Tag => Prompt::new("Enter attribute tag name", Accept::TEXT),
|
||||
Ask::Prompt => Prompt::new("Enter attribute prompt", Accept::TEXT),
|
||||
Ask::Value if self.flags & CONSTANT != 0 => Prompt::new("Enter attribute value", Accept::TEXT),
|
||||
Ask::Value => Prompt::new("Enter default attribute value", Accept::TEXT),
|
||||
// Once a justification is chosen AutoCAD asks for its point alone.
|
||||
Ask::Point if self.justify == (HAlign::Left, VAlign::Baseline) => {
|
||||
Prompt::new(self.point_prompt(), Accept::POINT).kw(&["Justify", "Style"])
|
||||
}
|
||||
Ask::Point => Prompt::new(self.point_prompt(), Accept::POINT),
|
||||
Ask::Justify => Prompt::new("Enter an option", Accept::TEXT).kw(&JUSTIFY),
|
||||
Ask::Style => Prompt::new("Enter style name", Accept::TEXT).kw(&["?"]).default(self.style.clone()),
|
||||
Ask::Height => {
|
||||
let th = s.doc().map(|d| d.header.f64("TEXTSIZE", 0.2)).unwrap_or(0.2);
|
||||
Prompt::new("Specify height", Accept::POINT_OR_NUMBER).default(format!("{th:.4}")).base(self.at)
|
||||
}
|
||||
Ask::Rotation => Prompt::new("Specify rotation angle of text", Accept::POINT_OR_NUMBER).default("0").base(self.at),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
match self.ask {
|
||||
Ask::Modes => match &i {
|
||||
Input::Keyword(k) => {
|
||||
let bit = match k.as_str() {
|
||||
"Invisible" => INVISIBLE,
|
||||
"Constant" => CONSTANT,
|
||||
"Verify" => VERIFY,
|
||||
"Preset" => PRESET,
|
||||
"Lock position" => LOCK,
|
||||
_ => {
|
||||
s.echo(format!("{k} attributes are not available yet."));
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
};
|
||||
self.flags ^= bit;
|
||||
s.doc_mut()?.header.set_i64("AFLAGS", self.flags);
|
||||
self.echo_modes(s);
|
||||
}
|
||||
Input::Enter => self.ask = Ask::Tag,
|
||||
Input::Text(_) => s.echo("Invalid option keyword."),
|
||||
_ => {}
|
||||
},
|
||||
Ask::Tag => match &i {
|
||||
// AutoCAD takes spaces in a tag here, and upper-cases it.
|
||||
Input::Text(t) if !t.trim().is_empty() => {
|
||||
self.tag = t.trim().to_string();
|
||||
self.ask = if self.flags & CONSTANT != 0 { Ask::Value } else { Ask::Prompt };
|
||||
}
|
||||
Input::Text(_) | Input::Enter => s.echo("Tag cannot be null"),
|
||||
_ => {}
|
||||
},
|
||||
Ask::Prompt | Ask::Value => {
|
||||
let t = match i {
|
||||
Input::Text(t) => t,
|
||||
Input::Enter => String::new(),
|
||||
_ => return Ok(Step::Continue),
|
||||
};
|
||||
if self.ask == Ask::Prompt {
|
||||
self.prompt = t;
|
||||
self.ask = Ask::Value;
|
||||
} else {
|
||||
self.value = t;
|
||||
self.echo_style(s)?;
|
||||
self.ask = Ask::Point;
|
||||
}
|
||||
}
|
||||
Ask::Point => match i {
|
||||
Input::Point(p) => {
|
||||
self.at = p;
|
||||
self.ask = Ask::Height;
|
||||
}
|
||||
Input::Keyword(k) if k == "Justify" => self.ask = Ask::Justify,
|
||||
Input::Keyword(_) => self.ask = Ask::Style,
|
||||
_ => {}
|
||||
},
|
||||
Ask::Justify => {
|
||||
// A justification goes on to its point; anything else is asked again.
|
||||
if let Input::Keyword(k) | Input::Text(k) = &i {
|
||||
match justify(k) {
|
||||
// Aligned and fitted text take two points: not here yet.
|
||||
Some((HAlign::Aligned | HAlign::Fit, _)) => s.echo("Align and Fit are not available here yet."),
|
||||
Some(j) => {
|
||||
self.justify = j;
|
||||
self.ask = Ask::Point;
|
||||
}
|
||||
None => s.echo("Invalid option keyword."),
|
||||
}
|
||||
}
|
||||
}
|
||||
Ask::Style => {
|
||||
let name = match i {
|
||||
Input::Enter => self.style.clone(),
|
||||
Input::Text(t) | Input::Keyword(t) => t.trim().to_string(),
|
||||
_ => return Ok(Step::Continue),
|
||||
};
|
||||
if name == "?" {
|
||||
let names: Vec<String> = s.doc()?.text_styles.iter().map(|st| st.name.clone()).collect();
|
||||
s.echo(format!("Text styles: {}", names.join(", ")));
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
match s.doc()?.text_style(&name).map(|st| st.name.clone()) {
|
||||
Some(n) => {
|
||||
self.style = n;
|
||||
self.echo_style(s)?;
|
||||
self.ask = Ask::Point;
|
||||
}
|
||||
None => s.echo(format!("Cannot find text style \"{name}\".")),
|
||||
}
|
||||
}
|
||||
Ask::Height => {
|
||||
let th = s.doc()?.header.f64("TEXTSIZE", 0.2);
|
||||
let h = match &i {
|
||||
Input::Enter => th,
|
||||
Input::Point(p) => self.at.dist(*p),
|
||||
// A distance, as TEXT takes its height (1'6" in feet and inches, too).
|
||||
Input::Text(t) => number(t).unwrap_or(0.0),
|
||||
_ => return Ok(Step::Continue),
|
||||
};
|
||||
if !(h.is_finite() && h > 0.0) {
|
||||
s.echo("Requires a positive height.");
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
self.height = h;
|
||||
s.doc_mut()?.header.set_f64("TEXTSIZE", h);
|
||||
self.ask = Ask::Rotation;
|
||||
}
|
||||
Ask::Rotation => {
|
||||
let r = match &i {
|
||||
Input::Enter => 0.0,
|
||||
Input::Point(p) => self.at.angle_to(*p),
|
||||
Input::Text(t) => match s.angle_settings().direction(t) {
|
||||
Some(a) => a,
|
||||
None => {
|
||||
s.echo("Requires an angle.");
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
},
|
||||
_ => return Ok(Step::Continue),
|
||||
};
|
||||
return self.finish(s, r);
|
||||
}
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
}
|
||||
@@ -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())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ pub(super) fn file_ref(s: &Session, t: &str) -> Option<FileRef> {
|
||||
}
|
||||
|
||||
/// The file name without folders and extension (either separator, so Windows paths work anywhere).
|
||||
fn stem(path: &str) -> String {
|
||||
pub(super) fn stem(path: &str) -> String {
|
||||
let file = path.rsplit(['/', '\\']).next().unwrap_or(path);
|
||||
match file.rsplit_once('.') {
|
||||
Some((stem, _)) if !stem.is_empty() => stem.to_string(),
|
||||
@@ -109,7 +109,7 @@ fn define_from(s: &mut Session, src: &Drawing, name: &str) -> Result<String> {
|
||||
nb.name = to.clone();
|
||||
nb.entities = Default::default();
|
||||
for e in b.entities.iter() {
|
||||
let e = adopt(&mut d, e, &renames);
|
||||
let e = adopt(&mut d, src, e, &renames);
|
||||
nb.entities.push(e);
|
||||
}
|
||||
d.blocks.insert(to.clone(), Arc::new(nb));
|
||||
@@ -118,7 +118,7 @@ fn define_from(s: &mut Session, src: &Drawing, name: &str) -> Result<String> {
|
||||
b.base = src.header.point("INSBASE").filter(|p| p.x.is_finite() && p.y.is_finite() && p.z.is_finite()).unwrap_or_default();
|
||||
let mut ents = Vec::new();
|
||||
for e in src.model.iter() {
|
||||
let e = adopt(&mut d, e, &renames);
|
||||
let e = adopt(&mut d, src, e, &renames);
|
||||
d.ensure_layer(&e.common.layer);
|
||||
ents.push(e);
|
||||
}
|
||||
@@ -183,9 +183,20 @@ fn block_names(d: &Drawing, src: &Drawing, defining: &str) -> BTreeMap<String, S
|
||||
|
||||
/// A copy of a source entity for this drawing: a fresh handle, block references renamed, and
|
||||
/// links to other source objects (associative dimension points) dropped.
|
||||
fn adopt(d: &mut Drawing, e: &Entity, renames: &BTreeMap<String, String>) -> Entity {
|
||||
fn adopt(d: &mut Drawing, src: &Drawing, e: &Entity, renames: &BTreeMap<String, String>) -> Entity {
|
||||
let mut e = e.clone();
|
||||
let old = e.handle;
|
||||
e.handle = d.new_handle();
|
||||
// An attribute definition keeps its field: its extension dictionary and the objects in it.
|
||||
if matches!(e.kind, EntityKind::AttDef(_))
|
||||
&& let Some(root) = src.foreign.entities.get(&old).and_then(|x| x.xdict)
|
||||
{
|
||||
let tree = src.foreign.tree(root);
|
||||
let fresh: Vec<Handle> = (0..tree.len() + 2).map(|_| d.new_handle()).collect();
|
||||
let foreign = Arc::make_mut(&mut d.foreign);
|
||||
foreign.adopt_tree(&src.foreign, &tree, &fresh, e.handle);
|
||||
foreign.entities.entry(e.handle).or_default().xdict = fresh.first().copied();
|
||||
}
|
||||
match &mut e.kind {
|
||||
EntityKind::Insert(i) => {
|
||||
if let Some(n) = renames.get(&i.block) {
|
||||
|
||||
@@ -197,7 +197,21 @@ pub(in crate::cmd) fn candidates(d: &Drawing, kind: Kind) -> Vec<String> {
|
||||
.collect()
|
||||
}
|
||||
Kind::Linetypes => {
|
||||
let used = lower(&mut entities(d).map(|e| e.common.linetype.clone()).chain(d.layers.iter().map(|l| l.linetype.clone())));
|
||||
// Objects and layers use linetypes, and so do dimension styles and a dimension's own
|
||||
// overrides (DIMLTYPE, DIMLTEX1, DIMLTEX2), and multiline styles' elements.
|
||||
let styles = d.dim_styles.iter().flat_map(|s| [s.dim_linetype.clone(), s.ext_linetype1.clone(), s.ext_linetype2.clone()]);
|
||||
let overrides = entities(d).filter_map(|e| if let EntityKind::Dimension(dm) = &e.kind { Some(dm) } else { None }).flat_map(|dm| {
|
||||
["dimLinetype", "extLinetype1", "extLinetype2"].into_iter().filter_map(|k| dm.overrides.get(k)?.as_str().map(str::to_string))
|
||||
});
|
||||
let mlines = d.mline_styles.iter().flat_map(|s| s.elements.iter().map(|el| el.linetype.clone()));
|
||||
let used = lower(
|
||||
&mut entities(d)
|
||||
.map(|e| e.common.linetype.clone())
|
||||
.chain(d.layers.iter().map(|l| l.linetype.clone()))
|
||||
.chain(styles)
|
||||
.chain(overrides)
|
||||
.chain(mlines),
|
||||
);
|
||||
let cur = d.header.str("CELTYPE", "ByLayer");
|
||||
d.linetypes
|
||||
.iter()
|
||||
@@ -218,6 +232,7 @@ pub(in crate::cmd) fn candidates(d: &Drawing, kind: Kind) -> Vec<String> {
|
||||
let used = lower(&mut entities(d).filter_map(|e| match &e.kind {
|
||||
EntityKind::Dimension(dm) => Some(dm.style.clone()),
|
||||
EntityKind::Tolerance(t) => Some(t.style.clone()),
|
||||
EntityKind::Leader(l) => Some(l.style.clone()),
|
||||
_ => None,
|
||||
}));
|
||||
let cur = d.header.str("DIMSTYLE", "Standard");
|
||||
@@ -299,6 +314,41 @@ fn retain<T>(v: &mut Vec<T>, name: impl Fn(&T) -> &String, n: &str) -> bool {
|
||||
v.len() != before
|
||||
}
|
||||
|
||||
/// Most passes [`purge_unused`] makes: each can free what the last one's objects used (a nested
|
||||
/// block, its layer, that layer's linetype).
|
||||
const MAX_PASSES: usize = 64;
|
||||
|
||||
/// Remove every named object drawing `d` doesn't use, as PURGE All would, nested ones included;
|
||||
/// the defaults and the current ones stay. For a drawing being written (WBLOCK's), not the one
|
||||
/// being edited: nothing goes through the session or its undo.
|
||||
pub(in crate::cmd) fn purge_unused(d: &mut Drawing) {
|
||||
for _ in 0..MAX_PASSES {
|
||||
let mut removed = false;
|
||||
for kind in ALL {
|
||||
for name in candidates(d, *kind) {
|
||||
removed |= match kind {
|
||||
Kind::Blocks => {
|
||||
let key = d.blocks.keys().find(|k| k.eq_ignore_ascii_case(&name)).cloned();
|
||||
key.is_some_and(|k| d.blocks.remove(&k).is_some())
|
||||
}
|
||||
Kind::Layers => retain(&mut d.layers, |l| &l.name, &name),
|
||||
Kind::TextStyles => retain(&mut d.text_styles, |t| &t.name, &name),
|
||||
Kind::Linetypes => retain(&mut d.linetypes, |t| &t.name, &name),
|
||||
Kind::Dimstyles => retain(&mut d.dim_styles, |t| &t.name, &name),
|
||||
Kind::MleaderStyles => retain(&mut d.mleader_styles, |t| &t.name, &name),
|
||||
Kind::MlineStyles => retain(&mut d.mline_styles, |t| &t.name, &name),
|
||||
Kind::TableStyles => retain(&mut d.table_styles, |t| &t.name, &name),
|
||||
Kind::Groups => retain(&mut d.groups, |g| &g.name, &name),
|
||||
Kind::ZeroLength | Kind::EmptyText | Kind::Absent | Kind::All => false,
|
||||
};
|
||||
}
|
||||
}
|
||||
if !removed {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Model-space and layout objects with no length, or text with no characters.
|
||||
fn degenerate(d: &Drawing, kind: Kind) -> Vec<Handle> {
|
||||
let zero = |e: &EntityKind| match e {
|
||||
|
||||
@@ -281,6 +281,8 @@ fn close(s: &mut Session, save: bool) -> Result<Value> {
|
||||
}
|
||||
nd.end_refedit_discard(&r);
|
||||
*s.doc_mut()? = nd;
|
||||
// The objects the edit managed were its own: OOPS doesn't bring them back after it.
|
||||
s.state_mut()?.erased.forget(|h| r.owns(h));
|
||||
s.set_selection(Vec::new());
|
||||
Ok(json!({ "block": r.block, "saved": save, "objects": objects }))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
//! WBLOCK (its JSON form) and -WBLOCK at the command line: objects, a block or the whole drawing
|
||||
//! written to a drawing file.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use cadcraft_doc::{Drawing, Entity, Handle};
|
||||
use cadcraft_geom::{Mat3, Vec2, Vec3};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::super::curves::KW;
|
||||
use super::super::file::{drops_unknown, new_folder, not_saved, not_saved_message, report_new_folder, report_not_saved};
|
||||
use super::super::machines::{SelOutcome, SelectPhase};
|
||||
use super::super::modify::erase_kept;
|
||||
use super::super::{CommandSpec, bad, point_param, str_param, targets};
|
||||
use super::insert_file::stem;
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
pub(super) fn wblock_spec() -> CommandSpec {
|
||||
CommandSpec::new("wblock", "Write Block", run_wblock).alias(&["w"]).params("{path, name? | handles?, base?}")
|
||||
}
|
||||
|
||||
/// -WBLOCK erases the objects it writes, whatever its mOde option says: AutoCAD 2024 does, and
|
||||
/// keeps "Object conversion=Delete" as the setting. OOPS brings them back, as after ERASE.
|
||||
pub(super) fn dash_wblock_spec() -> CommandSpec {
|
||||
CommandSpec::new("-wblock", "Write Block", run_dash_wblock)
|
||||
.params("{path, name? | handles?, base?} (the objects written are erased)")
|
||||
.interactive(|_| Ok(Box::new(DashWblockM::default())))
|
||||
}
|
||||
|
||||
/// The web build has no file system: -WBLOCK would erase objects it can't save.
|
||||
const NO_FILES: &str = "files can't be written in the web build";
|
||||
|
||||
/// What WBLOCK writes.
|
||||
enum Source<'a> {
|
||||
/// These objects, `base` becoming the written drawing's origin.
|
||||
Objects(&'a [Handle], Vec2),
|
||||
/// A block's objects, from its base point or `base` (in block coordinates).
|
||||
Block(&'a str, Option<Vec2>),
|
||||
/// The whole drawing.
|
||||
Whole,
|
||||
}
|
||||
|
||||
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 base = point_param(p, "base");
|
||||
let folder = new_folder(&path);
|
||||
let (entities, bytes, lost) = match str_param(p, "name") {
|
||||
Some(name) => write(s, "wblock", &path, Source::Block(name, base))?,
|
||||
None => write(s, "wblock", &path, Source::Objects(&targets(s, p)?, base.unwrap_or(Vec2::ZERO)))?,
|
||||
};
|
||||
let mut r = json!({ "path": path, "entities": entities, "bytes": bytes });
|
||||
report_new_folder(s, &mut r, folder);
|
||||
report_not_saved(s, &mut r, &lost);
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
fn run_dash_wblock(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
return Err(bad("-wblock", NO_FILES));
|
||||
}
|
||||
let path = str_param(p, "path").ok_or_else(|| bad("-wblock", "`path` is required"))?.to_string();
|
||||
let base = point_param(p, "base");
|
||||
let folder = new_folder(&path);
|
||||
let (entities, bytes, lost) = match str_param(p, "name") {
|
||||
Some(name) => write(s, "-wblock", &path, Source::Block(name, base))?,
|
||||
None => {
|
||||
let hs = targets(s, p)?;
|
||||
// As at the command line, nothing selected writes no file.
|
||||
if !hs.iter().any(|h| s.doc().is_ok_and(|d| d.entity(*h).is_some())) {
|
||||
return Err(bad("-wblock", "no objects selected"));
|
||||
}
|
||||
let written = write(s, "-wblock", &path, Source::Objects(&hs, base.unwrap_or(Vec2::ZERO)))?;
|
||||
erase(s, &hs)?;
|
||||
written
|
||||
}
|
||||
};
|
||||
let mut r = json!({ "path": path, "entities": entities, "bytes": bytes });
|
||||
report_new_folder(s, &mut r, folder);
|
||||
report_not_saved(s, &mut r, &lost);
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// Write `what` to `path` through the io hooks; the objects written, the file's size and what a
|
||||
/// DWG file left out, for the caller to report as a save does. 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).
|
||||
fn write(s: &Session, cmd: &str, path: &str, what: Source) -> Result<(usize, usize, BTreeMap<String, usize>)> {
|
||||
let src = s.doc()?;
|
||||
// The base point becomes the written drawing's origin: by default 0,0 for objects and the
|
||||
// block's base point for a block.
|
||||
let d = match what {
|
||||
Source::Whole => src.clone(),
|
||||
Source::Block(name, base) => {
|
||||
let b = src.block(name).ok_or_else(|| bad(cmd, format!("no block `{name}`")))?;
|
||||
let m = Mat3::translate(-base.unwrap_or(b.base.xy()));
|
||||
with_objects(src, b.entities.iter().map(|e| moved(e, &m)))
|
||||
}
|
||||
Source::Objects(hs, base) => {
|
||||
let m = Mat3::translate(-base);
|
||||
with_objects(src, hs.iter().filter_map(|h| src.entity(*h)).map(|e| moved(e, &m)))
|
||||
}
|
||||
};
|
||||
let hooks = super::super::file::io().ok_or_else(|| bad(cmd, "file formats are not available in this build"))?;
|
||||
let bytes = (hooks.write)(&d, path).map_err(|e| bad(cmd, e))?;
|
||||
// Through a temporary file, as SAVE writes, so a failed write keeps the file it replaces.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
crate::atomic_write::write_atomic_making_folders(std::path::Path::new(path), &bytes).map_err(|e| bad(cmd, format!("{path}: {e}")))?;
|
||||
let lost = if drops_unknown(path) { not_saved(&d) } else { BTreeMap::new() };
|
||||
Ok((d.model.len(), bytes.len(), lost))
|
||||
}
|
||||
|
||||
/// Say on the command line what the written DWG file left out.
|
||||
fn echo_not_saved(s: &mut Session, lost: &BTreeMap<String, usize>) {
|
||||
if let Some(msg) = not_saved_message(lost) {
|
||||
s.echo(msg);
|
||||
}
|
||||
}
|
||||
|
||||
/// A new drawing of `ents`, with `src`'s settings and the layers, linetypes, styles and blocks
|
||||
/// they use, as AutoCAD's WBLOCK writes them (#2423): what nothing in it uses is purged, and
|
||||
/// the defaults and the current ones stay. Its insertion base is 0,0.
|
||||
fn with_objects(src: &Drawing, ents: impl Iterator<Item = Entity>) -> Drawing {
|
||||
let mut d = Drawing::new_imperial();
|
||||
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();
|
||||
d.header.set("INSBASE", cadcraft_doc::HVal::Point(Vec3::ZERO));
|
||||
// The copied blocks keep their objects' handles, so the new ones start above them.
|
||||
let kept: Vec<Handle> = d.blocks.values().flat_map(|b| b.entities.iter().map(|e| e.handle)).collect();
|
||||
for h in kept {
|
||||
d.bump_handseed(h);
|
||||
}
|
||||
for mut e in ents {
|
||||
e.handle = d.new_handle();
|
||||
d.model.push(e);
|
||||
}
|
||||
super::purge::purge_unused(&mut d);
|
||||
d
|
||||
}
|
||||
|
||||
fn moved(e: &Entity, m: &Mat3) -> Entity {
|
||||
let mut e = e.clone();
|
||||
e.kind.transform(m);
|
||||
e
|
||||
}
|
||||
|
||||
fn erase(s: &mut Session, hs: &[Handle]) -> Result<()> {
|
||||
erase_kept(s, hs)?;
|
||||
s.set_selection(Vec::new());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The file -WBLOCK writes for a typed name: as named when it ends in .dwg or .dxf, else with
|
||||
/// .dwg added, as AutoCAD names a drawing.
|
||||
fn output_path(typed: &str) -> String {
|
||||
let t = typed.trim().trim_matches('"');
|
||||
let lower = t.to_ascii_lowercase();
|
||||
if lower.ends_with(".dwg") || lower.ends_with(".dxf") { t.to_string() } else { format!("{t}.dwg") }
|
||||
}
|
||||
|
||||
/// Whether a file is there to replace; the web has no file system.
|
||||
fn exists(path: &str) -> bool {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
std::path::Path::new(path).exists()
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let _ = path;
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Where -WBLOCK is in its prompts, as AutoCAD 2024 asks them.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
enum Ask {
|
||||
#[default]
|
||||
File,
|
||||
/// The file exists: "… already exists, do you want to replace it? [Yes/No] <N>".
|
||||
Replace,
|
||||
/// A DXF file's decimal places of accuracy.
|
||||
Precision,
|
||||
/// An existing block, `=` (the block named as the file), `*` (the whole drawing), or Enter
|
||||
/// for objects.
|
||||
Block,
|
||||
Base,
|
||||
/// mOde at the base point prompt: asked, but the objects are erased whatever it says.
|
||||
Mode,
|
||||
Objects,
|
||||
}
|
||||
|
||||
const SAME_NAME: &str = "= (block=output file)";
|
||||
const WHOLE: &str = "* (whole drawing)";
|
||||
|
||||
#[derive(Default)]
|
||||
struct DashWblockM {
|
||||
ask: Ask,
|
||||
path: String,
|
||||
base: Vec2,
|
||||
sel: SelectPhase,
|
||||
}
|
||||
|
||||
impl Interactive for DashWblockM {
|
||||
fn name(&self) -> &'static str {
|
||||
"WBLOCK"
|
||||
}
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
s.echo(format!("-WBLOCK: {NO_FILES}."));
|
||||
return Ok(Step::Cancel);
|
||||
}
|
||||
s.echo("Current settings: Object conversion=Delete");
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
match self.ask {
|
||||
Ask::File => Prompt::new("Enter name of output file", Accept::TEXT),
|
||||
Ask::Replace => Prompt::new(format!("{} already exists, do you want to replace it?", self.path), KW).kw(&["Yes", "No"]).default("N"),
|
||||
Ask::Precision => Prompt::new("Enter decimal places of accuracy (0 to 16)", Accept::NUMBER).kw(&["Binary", "Preview"]).default("16"),
|
||||
Ask::Block => Prompt::new("Enter name of existing block", Accept::TEXT).kw(&[SAME_NAME, WHOLE]).default("define new drawing"),
|
||||
Ask::Base => Prompt::new("Specify insertion base point", Accept::POINT).kw(&["mOde"]),
|
||||
Ask::Mode => {
|
||||
Prompt::new("Choose mode for selected object conversion", KW).kw(&["Convert to block", "Retain", "Delete"]).default("Delete")
|
||||
}
|
||||
Ask::Objects => self.sel.prompt(),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
match self.ask {
|
||||
Ask::File => {
|
||||
if let Input::Text(t) = &i
|
||||
&& !t.trim().trim_matches('"').is_empty()
|
||||
{
|
||||
self.path = output_path(t);
|
||||
self.ask = if exists(&self.path) { Ask::Replace } else { self.after_file(s) };
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Ask::Replace => match &i {
|
||||
// Yes goes on; No, the default, ends the command; anything else is asked again.
|
||||
Input::Keyword(k) if k == "Yes" => {
|
||||
self.ask = self.after_file(s);
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Input::Keyword(_) | Input::Enter => Ok(Step::Done),
|
||||
_ => {
|
||||
s.echo("Invalid option keyword.");
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
},
|
||||
Ask::Precision => {
|
||||
match &i {
|
||||
Input::Enter => self.ask = Ask::Block,
|
||||
// The file is written at full precision whatever is asked.
|
||||
Input::Text(t) if t.trim().parse::<u8>().is_ok_and(|n| n <= 16) => self.ask = Ask::Block,
|
||||
Input::Keyword(k) if k == "Binary" => s.echo("Binary DXF output is not available."),
|
||||
Input::Keyword(_) => {}
|
||||
_ => s.echo("Requires an integer between 0 and 16."),
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Ask::Block => {
|
||||
let name = match &i {
|
||||
Input::Enter => {
|
||||
self.ask = Ask::Base;
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
Input::Keyword(k) if k == WHOLE => return self.write(s, Source::Whole),
|
||||
Input::Keyword(k) if k == SAME_NAME => stem(&self.path),
|
||||
Input::Text(t) => t.trim().to_string(),
|
||||
// A point (from a LISP list) is a name too: AutoCAD answers Block "0,0" not found.
|
||||
Input::Point(p) => format!("{},{}", p.x, p.y),
|
||||
_ => return Ok(Step::Continue),
|
||||
};
|
||||
if s.doc()?.block(&name).is_none() {
|
||||
s.echo(format!("Block \"{name}\" not found."));
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
self.write(s, Source::Block(&name, None))
|
||||
}
|
||||
Ask::Base => {
|
||||
match i {
|
||||
Input::Keyword(_) => self.ask = Ask::Mode,
|
||||
Input::Enter | Input::Text(_) => s.echo("Point or option keyword required."),
|
||||
Input::Point(p) => {
|
||||
self.base = p;
|
||||
self.sel = SelectPhase::begin(s);
|
||||
if self.sel.done {
|
||||
let picked = self.sel.picked.clone();
|
||||
return self.write_objects(s, &picked);
|
||||
}
|
||||
self.ask = Ask::Objects;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Ask::Mode => {
|
||||
// Any answer, Enter included, goes back to the base point.
|
||||
self.ask = Ask::Base;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Ask::Objects => match self.sel.feed(s, &i)? {
|
||||
SelOutcome::More => Ok(Step::Continue),
|
||||
// Nothing selected: nothing is written.
|
||||
SelOutcome::Empty => Ok(Step::Done),
|
||||
SelOutcome::Done(hs) => self.write_objects(s, &hs),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DashWblockM {
|
||||
/// The prompt after the file name: a DXF file's precision, else the block.
|
||||
fn after_file(&self, s: &mut Session) -> Ask {
|
||||
if self.path.to_ascii_lowercase().ends_with(".dxf") {
|
||||
s.echo("Current DXF settings: Precision= 16 Format= ASCII Preview= No");
|
||||
Ask::Precision
|
||||
} else {
|
||||
Ask::Block
|
||||
}
|
||||
}
|
||||
|
||||
fn write(&self, s: &mut Session, what: Source) -> Result<Step> {
|
||||
match write(s, "-wblock", &self.path, what) {
|
||||
Ok((.., lost)) => echo_not_saved(s, &lost),
|
||||
Err(e) => s.echo(e.to_string()),
|
||||
}
|
||||
Ok(Step::Done)
|
||||
}
|
||||
|
||||
fn write_objects(&self, s: &mut Session, hs: &[Handle]) -> Result<Step> {
|
||||
if hs.is_empty() {
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
match write(s, "-wblock", &self.path, Source::Objects(hs, self.base)) {
|
||||
Ok((.., lost)) => {
|
||||
echo_not_saved(s, &lost);
|
||||
erase(s, hs)?;
|
||||
}
|
||||
Err(e) => s.echo(e.to_string()),
|
||||
}
|
||||
Ok(Step::Done)
|
||||
}
|
||||
}
|
||||
@@ -208,7 +208,7 @@ pub(crate) fn find(path: &str, host: Option<&Path>, chosen: bool) -> Result<Path
|
||||
}
|
||||
let (files, refused) = candidates(path, host, chosen);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let found = files.into_iter().find(|p| p.is_file());
|
||||
let found = files.iter().find(|p| p.is_file()).cloned().or_else(|| files.iter().find_map(|p| case_insensitive(p)));
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let found: Option<PathBuf> = {
|
||||
let _ = files;
|
||||
@@ -217,6 +217,43 @@ pub(crate) fn find(path: &str, host: Option<&Path>, chosen: bool) -> Result<Path
|
||||
found.ok_or(refused.map_or(Missing::NotFound, Missing::Refused))
|
||||
}
|
||||
|
||||
/// Upper bound on directory entries scanned per path component by [`case_insensitive`].
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const MAX_CASE_SCAN: usize = 10_000;
|
||||
|
||||
/// The existing file `p` names when its letters' case is ignored, on file systems that don't
|
||||
/// ignore it themselves. Drawings made on Windows often store a reference as `PLAN.DWG` for
|
||||
/// `Plan.dwg`; Windows finds it, and so should Linux. Each missing component is matched against
|
||||
/// its folder's entries; an exact match always wins, and an ambiguous one finds nothing.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn case_insensitive(p: &Path) -> Option<PathBuf> {
|
||||
use std::path::Component;
|
||||
let mut out = PathBuf::new();
|
||||
for c in p.components() {
|
||||
let Component::Normal(name) = c else {
|
||||
out.push(c.as_os_str());
|
||||
continue;
|
||||
};
|
||||
let exact = out.join(name);
|
||||
if exact.exists() {
|
||||
out = exact;
|
||||
continue;
|
||||
}
|
||||
let want = name.to_str()?.to_lowercase();
|
||||
let mut hits = std::fs::read_dir(if out.as_os_str().is_empty() { Path::new(".") } else { &out })
|
||||
.ok()?
|
||||
.take(MAX_CASE_SCAN)
|
||||
.flatten()
|
||||
.filter(|e| e.file_name().to_str().is_some_and(|n| n.to_lowercase() == want));
|
||||
let hit = hits.next()?;
|
||||
if hits.next().is_some() {
|
||||
return None;
|
||||
}
|
||||
out.push(hit.file_name());
|
||||
}
|
||||
out.is_file().then_some(out)
|
||||
}
|
||||
|
||||
/// Read a drawing file through the host's io hooks, counting its bytes against the load's total.
|
||||
fn read(file: &Path, cx: &mut Ctx) -> Result<Drawing, String> {
|
||||
let hooks = super::super::super::file::io().ok_or("file formats are not available in this build")?;
|
||||
|
||||
@@ -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,7 +31,9 @@ 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};
|
||||
pub(crate) use text::justify;
|
||||
use text::{MTextM, TextM, run_mtext, run_text};
|
||||
|
||||
/// Every draw command's spec: JSON parameters, menu path, aliases and the interactive prompt
|
||||
@@ -100,6 +103,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"])
|
||||
|
||||
@@ -153,19 +153,17 @@ impl Interactive for HelixM {
|
||||
}
|
||||
match (self.center, self.rb, self.rt) {
|
||||
(None, ..) => Prompt::new("Specify center point of base", Accept::POINT),
|
||||
(Some(c), None, _) => {
|
||||
Prompt::new(if self.diameter { "Specify base diameter" } else { "Specify base radius or" }, Accept::POINT_OR_NUMBER)
|
||||
.kw(if self.diameter { &[] } else { &["Diameter"] })
|
||||
.default("1.0000")
|
||||
.base(c)
|
||||
}
|
||||
(Some(c), None, _) => Prompt::new(if self.diameter { "Specify base diameter" } else { "Specify base radius" }, Accept::POINT_OR_NUMBER)
|
||||
.kw(if self.diameter { &[] } else { &["Diameter"] })
|
||||
.default("1.0000")
|
||||
.base(c),
|
||||
(Some(c), Some(rb), None) => {
|
||||
Prompt::new(if self.diameter { "Specify top diameter" } else { "Specify top radius or" }, Accept::POINT_OR_NUMBER)
|
||||
Prompt::new(if self.diameter { "Specify top diameter" } else { "Specify top radius" }, Accept::POINT_OR_NUMBER)
|
||||
.kw(if self.diameter { &[] } else { &["Diameter"] })
|
||||
.default(format!("{rb:.4}"))
|
||||
.base(c)
|
||||
}
|
||||
(Some(c), Some(_), Some(_)) => Prompt::new("Specify helix height or", Accept::POINT_OR_NUMBER)
|
||||
(Some(c), Some(_), Some(_)) => Prompt::new("Specify helix height", Accept::POINT_OR_NUMBER)
|
||||
.kw(&["Axis endpoint", "Turns", "turn Height", "tWist"])
|
||||
.default("1.0000")
|
||||
.base(c),
|
||||
|
||||
@@ -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:?}"),
|
||||
|
||||
@@ -119,7 +119,7 @@ impl Interactive for XlineM2 {
|
||||
Some(b) => Prompt::new("Specify through point", Accept::POINT).base(b),
|
||||
},
|
||||
XMode::Fixed(_) => Prompt::new("Specify through point", Accept::POINT),
|
||||
XMode::AngAsk => Prompt::new("Enter angle of xline (0) or", Accept::NUMBER).kw(&["Reference"]),
|
||||
XMode::AngAsk => Prompt::new("Enter angle of xline (0)", Accept::NUMBER).kw(&["Reference"]),
|
||||
XMode::RefSelect => Prompt::new("Select a line object", Accept::POINT),
|
||||
XMode::RefAngle(_) => Prompt::new("Enter angle of xline", Accept::NUMBER).default("0"),
|
||||
XMode::Bisect => match self.bis.len() {
|
||||
@@ -129,7 +129,7 @@ impl Interactive for XlineM2 {
|
||||
},
|
||||
XMode::OffDist => {
|
||||
let d = s.doc().map(|d| d.header.f64("OFFSETDIST", 1.0)).unwrap_or(1.0);
|
||||
Prompt::new("Specify offset distance or", Accept::NUMBER).kw(&["Through"]).default(if d < 0.0 {
|
||||
Prompt::new("Specify offset distance", Accept::NUMBER).kw(&["Through"]).default(if d < 0.0 {
|
||||
"Through".into()
|
||||
} else {
|
||||
format!("{d:.4}")
|
||||
|
||||
@@ -549,13 +549,7 @@ fn run_pasteorig(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
fn run_clear(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let mut hs = s.selection();
|
||||
hs.retain(|h| !super::curves::is_locked(s, *h));
|
||||
let d = s.doc_mut()?;
|
||||
let mut n = 0;
|
||||
for h in hs {
|
||||
if d.remove_entity(h).is_some() {
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
let n = super::modify::erase_kept(s, &hs)?;
|
||||
s.set_selection(Vec::new());
|
||||
Ok(json!({ "erased": n }))
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ fn write_file(cmd: &str, path: &str, bytes: &[u8]) -> Result<()> {
|
||||
{
|
||||
// Exclusively create a fresh temporary file and rename it into place so a planted
|
||||
// `<path>.cadcraft-tmp` symlink cannot redirect the export write (CWE-59).
|
||||
crate::atomic_write::write_atomic(std::path::Path::new(path), bytes).map_err(|e| bad(cmd, format!("{path}: {e}")))
|
||||
crate::atomic_write::write_atomic_making_folders(std::path::Path::new(path), bytes).map_err(|e| bad(cmd, format!("{path}: {e}")))
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
@@ -117,14 +117,19 @@ fn run_export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !EXPORT_FORMATS.contains(&format.as_str()) {
|
||||
return Err(bad("export", "`format` must be dxf, dwg, svg, png or pdf (or give a `path` with one of those extensions)"));
|
||||
}
|
||||
let bytes = (hooks.write)(s.doc()?, &format!("export.{format}")).map_err(|e| bad("export", e))?;
|
||||
match path {
|
||||
let file = format!("export.{format}");
|
||||
let bytes = (hooks.write)(s.doc()?, &file).map_err(|e| bad("export", e))?;
|
||||
let folder = path.and_then(super::file::new_folder);
|
||||
let mut r = match path {
|
||||
Some(path) => {
|
||||
write_file("export", path, &bytes)?;
|
||||
Ok(json!({ "path": path, "format": format, "bytes": bytes.len(), "message": format!("Exported to {path}") }))
|
||||
json!({ "path": path, "format": format, "bytes": bytes.len(), "message": format!("Exported to {path}") })
|
||||
}
|
||||
None => Ok(json!({ "data": base64_encode(&bytes), "format": format, "bytes": bytes.len() })),
|
||||
}
|
||||
None => json!({ "data": base64_encode(&bytes), "format": format, "bytes": bytes.len() }),
|
||||
};
|
||||
super::file::report_new_folder(s, &mut r, folder);
|
||||
super::file::report_loss(s, &mut r, &file);
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
// ---------- IMPORT ----------
|
||||
@@ -330,17 +335,23 @@ fn run_preview(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(l) = p.get("landscape").and_then(Value::as_bool) {
|
||||
page.landscape = l;
|
||||
}
|
||||
// A layout's saved page setup (plot window, scale to fit, scale, centring) applies unless the call says otherwise (#2493).
|
||||
// An explicit `fit: false` without a `scale` asks for the plain 1:1 plot.
|
||||
let plain = p.get("fit").and_then(Value::as_bool) == Some(false) && p.get("scale").is_none();
|
||||
let saved = if space.is_paper() && !plain { cadcraft_render::paper::saved_plot(&page) } else { Default::default() };
|
||||
let sheet = Sheet::from_page(&page, paper::page_unit_mm(d, &page));
|
||||
let lineweights = p.get("lineweights").and_then(Value::as_bool).unwrap_or(if space.is_paper() { page.lineweights } else { true });
|
||||
let transparency = super::plotters::plot_transparency(s, p).unwrap_or(page.plot_transparency);
|
||||
let fit = p.get("fit").and_then(Value::as_bool).unwrap_or(matches!(space, Space::Model));
|
||||
let scale = p.get("scale").and_then(Value::as_f64).filter(|v| v.is_finite() && *v > 0.0);
|
||||
let given_scale = p.get("scale").and_then(Value::as_f64).filter(|v| v.is_finite() && *v > 0.0);
|
||||
// A saved scale to fit applies only when the call gives neither `fit` nor `scale`, as in the PDF.
|
||||
let fit = p.get("fit").and_then(Value::as_bool).unwrap_or(matches!(space, Space::Model) || (saved.fit && given_scale.is_none()));
|
||||
let scale = given_scale.or(saved.scale);
|
||||
let width = p.get("width").and_then(Value::as_u64).unwrap_or(PREVIEW_PX).clamp(16, MAX_PREVIEW_PX);
|
||||
let height = ((width as f64) * sheet.size.y / sheet.size.x).round();
|
||||
let height = if height.is_finite() { (height as u64).clamp(16, MAX_PREVIEW_PX) } else { width };
|
||||
let px_per_paper_unit = width as f64 / sheet.size.x;
|
||||
// Curves are flattened to about half a pixel.
|
||||
let est = plot_scale(&d.extents(&space), &sheet, fit, scale);
|
||||
let est = plot_scale(&saved.window.unwrap_or_else(|| d.extents(&space)), &sheet, fit, scale);
|
||||
let tol = 0.5 / (px_per_paper_unit * est);
|
||||
let ropts = cadcraft_render::Options {
|
||||
tolerance: if tol.is_finite() && tol > 0.0 { tol } else { 0.01 },
|
||||
@@ -352,9 +363,13 @@ fn run_preview(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
..Default::default()
|
||||
};
|
||||
let list = cadcraft_render::build_plot(d, &space, &ropts);
|
||||
let b = list.bounds;
|
||||
let b = saved.window.unwrap_or(list.bounds);
|
||||
// Paper point q shows drawing point (q - to) / k + from.
|
||||
let (from, k, to) = if fit || space == Space::Model {
|
||||
let (from, k, to) = if saved.window.is_some() {
|
||||
// A saved window, fitted or at a saved scale: its centre (or corner) on the printable area's.
|
||||
let c = saved.center;
|
||||
(if c { b.center() } else { b.min }, plot_scale(&b, &sheet, fit, scale), if c { sheet.printable.center() } else { sheet.printable.min })
|
||||
} else if fit || space == Space::Model {
|
||||
(if b.is_empty() { Vec2::ZERO } else { b.center() }, plot_scale(&b, &sheet, fit, scale), sheet.printable.center())
|
||||
} else {
|
||||
(Vec2::ZERO, plot_scale(&b, &sheet, fit, scale), Vec2::ZERO)
|
||||
@@ -372,7 +387,20 @@ fn run_preview(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
px_per_mm: if lineweights && px_per_mm.is_finite() { px_per_mm } else { 0.0 },
|
||||
antialias: true,
|
||||
};
|
||||
let png = cadcraft_render::raster::render_png(&list, &view, &ro).ok_or_else(|| bad("preview", "rendering the preview failed"))?;
|
||||
let mut pm = cadcraft_render::raster::render(&list, &view, &ro).ok_or_else(|| bad("preview", "rendering the preview failed"))?;
|
||||
// A placed plot (fitted, or a saved window) shows only inside the printable area, as the PDF and PNG plots clip it.
|
||||
if fit || saved.window.is_some() || space == Space::Model {
|
||||
// The view may scale and centre the sheet (the height cap), so map the printable corners
|
||||
// (paper point q shows drawing point (q - to) / k + from) through it.
|
||||
let screen = |q: Vec2| {
|
||||
let (x, y) = view.to_screen((q - to) / k + from);
|
||||
(f64::from(x), f64::from(y))
|
||||
};
|
||||
let (min, max) = (sheet.printable.min, sheet.printable.max);
|
||||
let (a, b) = (screen(Vec2::new(min.x, max.y)), screen(Vec2::new(max.x, min.y)));
|
||||
cadcraft_render::raster::blank_outside_pixels(&mut pm, a, b);
|
||||
}
|
||||
let png = pm.encode_png().map_err(|_| bad("preview", "rendering the preview failed"))?;
|
||||
let layout = space.tab_name();
|
||||
Ok(json!({ "data": base64_encode(&png), "width": w, "height": h, "layout": layout, "paper": page.paper, "landscape": page.landscape }))
|
||||
}
|
||||
@@ -738,6 +766,140 @@ mod tests {
|
||||
assert_ne!(png(&mut s, json!({ "layout": "Layout1", "width": 200, "fit": false, "scale": 0.5 })), default, "scale 0.5 is not 1:1");
|
||||
}
|
||||
|
||||
/// A layout's saved page setup (plot window, scale to fit) shapes its preview by default, as
|
||||
/// it shapes the PDF; `fit: false` still gives the plain 1:1 sheet (#2493).
|
||||
#[test]
|
||||
fn preview_uses_the_layouts_saved_page_setup_by_default() {
|
||||
let mut s = Session::new();
|
||||
s.execute("layout.set", &json!({ "name": "Layout1" })).unwrap();
|
||||
s.execute("line", &json!({ "points": [[20.0, 5.0], [836.0, 589.0]] })).unwrap();
|
||||
s.execute("pagesetup", &json!({ "layout": "Layout1", "paper": "A4", "landscape": true, "margins": [0, 0, 0, 0] })).unwrap();
|
||||
let png = |s: &mut Session, p: Value| s.execute("preview", &p).unwrap()["data"].as_str().unwrap_or_default().to_string();
|
||||
let unsaved = png(&mut s, json!({ "layout": "Layout1", "width": 200 }));
|
||||
let page = &mut s.doc_mut().unwrap().layouts.iter_mut().find(|l| l.name == "Layout1").unwrap().page;
|
||||
page.plot_area = "window".into();
|
||||
page.plot_window = [20.0, 5.0, 836.0, 589.0];
|
||||
page.scale_to_fit = true;
|
||||
let saved = png(&mut s, json!({ "layout": "Layout1", "width": 200 }));
|
||||
assert_ne!(saved, unsaved, "the saved window and scale to fit change the preview");
|
||||
assert_eq!(png(&mut s, json!({ "layout": "Layout1", "width": 200, "fit": false })), unsaved, "fit: false is the plain 1:1 sheet");
|
||||
}
|
||||
|
||||
/// With a saved plot window the preview follows the page setup's centring (fitted or not) and an
|
||||
/// explicit `scale` beats a saved scale to fit, as in the PDF.
|
||||
/// A placed saved window shows only inside the printable area, as the PDF and PNG plots clip it:
|
||||
/// geometry in the margin leaves the preview unchanged.
|
||||
#[test]
|
||||
fn preview_clips_a_saved_window_to_the_printable_area() {
|
||||
let build = |margin_line: bool| {
|
||||
let mut s = Session::new();
|
||||
s.execute("layout.set", &json!({ "name": "Layout1" })).unwrap();
|
||||
s.execute("line", &json!({ "points": [[0.0, 30.0], [100.0, 30.0]] })).unwrap();
|
||||
if margin_line {
|
||||
s.execute("line", &json!({ "points": [[-3.0, 0.0], [-3.0, 60.0]] })).unwrap();
|
||||
}
|
||||
s.execute("pagesetup", &json!({ "layout": "Layout1", "paper": "A4", "landscape": true, "margins": [10, 10, 10, 10] })).unwrap();
|
||||
let page = &mut s.doc_mut().unwrap().layouts.iter_mut().find(|l| l.name == "Layout1").unwrap().page;
|
||||
page.plot_area = "window".into();
|
||||
page.plot_window = [0.0, 0.0, 100.0, 60.0];
|
||||
page.scale_to_fit = true;
|
||||
page.center = true;
|
||||
s.execute("preview", &json!({ "layout": "Layout1", "width": 600 })).unwrap()["data"].as_str().unwrap_or_default().to_string()
|
||||
};
|
||||
assert_eq!(build(true), build(false), "a line in the margin is clipped away");
|
||||
}
|
||||
|
||||
/// On a tall sheet the height cap makes the view scale and centre the sheet in the image; the
|
||||
/// printable clip must follow it, so ink in the margin is still blanked.
|
||||
#[test]
|
||||
fn preview_clip_follows_the_view_when_the_height_cap_applies() {
|
||||
let build = |margin_line: bool| {
|
||||
let mut s = Session::new();
|
||||
s.execute("layout.set", &json!({ "name": "Layout1" })).unwrap();
|
||||
s.execute("line", &json!({ "points": [[0.0, 50.0], [100.0, 50.0]] })).unwrap();
|
||||
if margin_line {
|
||||
s.execute("line", &json!({ "points": [[-2.6, 0.0], [-2.6, 100.0]] })).unwrap();
|
||||
}
|
||||
s.execute("pagesetup", &json!({ "layout": "Layout1", "margins": [10, 10, 10, 10] })).unwrap();
|
||||
let page = &mut s.doc_mut().unwrap().layouts.iter_mut().find(|l| l.name == "Layout1").unwrap().page;
|
||||
page.width_mm = 210.0;
|
||||
page.height_mm = 1200.0;
|
||||
page.landscape = false;
|
||||
page.plot_area = "window".into();
|
||||
page.plot_window = [0.0, 0.0, 100.0, 100.0];
|
||||
page.scale_to_fit = true;
|
||||
page.center = true;
|
||||
s.execute("preview", &json!({ "layout": "Layout1", "width": 1000 })).unwrap()["data"].as_str().unwrap_or_default().to_string()
|
||||
};
|
||||
assert_eq!(build(true), build(false), "a line in the margin is clipped away on a tall sheet");
|
||||
}
|
||||
|
||||
/// Hostile saved setups (a degenerate or far-away window, a scale of 0 or not finite) fall back
|
||||
/// to the plain preview and never panic, and a layout with no saved setup previews exactly as
|
||||
/// the plain 1:1 sheet does.
|
||||
#[test]
|
||||
fn preview_survives_hostile_saved_setups_and_default_is_unchanged() {
|
||||
let mut s = Session::new();
|
||||
s.execute("layout.set", &json!({ "name": "Layout1" })).unwrap();
|
||||
s.execute("line", &json!({ "points": [[20.0, 5.0], [200.0, 100.0]] })).unwrap();
|
||||
s.execute("pagesetup", &json!({ "layout": "Layout1", "paper": "A4", "landscape": true })).unwrap();
|
||||
let png = |s: &mut Session, p: Value| s.execute("preview", &p).unwrap()["data"].as_str().unwrap_or_default().to_string();
|
||||
let call = json!({ "layout": "Layout1", "width": 200 });
|
||||
let plain = png(&mut s, json!({ "layout": "Layout1", "width": 200, "fit": false }));
|
||||
assert_eq!(png(&mut s, call.clone()), plain, "no saved setup: the default preview is the plain sheet");
|
||||
let set = |s: &mut Session, window: [f64; 4], scale: f64, fit: bool| {
|
||||
let page = &mut s.doc_mut().unwrap().layouts.iter_mut().find(|l| l.name == "Layout1").unwrap().page;
|
||||
page.plot_area = "window".into();
|
||||
page.plot_window = window;
|
||||
page.scale = scale;
|
||||
page.scale_to_fit = fit;
|
||||
};
|
||||
let good = [0.0, 0.0, 100.0, 60.0];
|
||||
let (nan, inf) = (f64::NAN, f64::INFINITY);
|
||||
for window in [[5.0, 5.0, 5.0, 40.0], [5.0, 5.0, 40.0, 5.0], [nan, 0.0, 10.0, 10.0], [0.0, 0.0, inf, 10.0]] {
|
||||
set(&mut s, window, 1.0, true);
|
||||
assert_eq!(png(&mut s, call.clone()), plain, "{window:?}: a degenerate window changes nothing");
|
||||
}
|
||||
for scale in [0.0, -2.0, nan, inf] {
|
||||
set(&mut s, good, scale, false);
|
||||
let _ = png(&mut s, call.clone());
|
||||
set(&mut s, [5.0, 5.0, 5.0, 40.0], scale, false);
|
||||
assert_eq!(png(&mut s, call.clone()), plain, "{scale}: an unusable saved scale is ignored");
|
||||
}
|
||||
set(&mut s, [1e9, 1e9, 1e9 + 10.0, 1e9 + 10.0], 1.0, true);
|
||||
let _ = png(&mut s, call.clone());
|
||||
set(&mut s, [-1e300, -1e300, 1e300, 1e300], 1.0, true);
|
||||
let _ = png(&mut s, call.clone());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_honours_saved_centering_and_an_explicit_scale() {
|
||||
let mut s = Session::new();
|
||||
s.execute("layout.set", &json!({ "name": "Layout1" })).unwrap();
|
||||
s.execute("line", &json!({ "points": [[20.0, 5.0], [836.0, 589.0]] })).unwrap();
|
||||
s.execute("pagesetup", &json!({ "layout": "Layout1", "paper": "A4", "landscape": true, "margins": [0, 0, 0, 0] })).unwrap();
|
||||
let page = &mut s.doc_mut().unwrap().layouts.iter_mut().find(|l| l.name == "Layout1").unwrap().page;
|
||||
page.plot_area = "window".into();
|
||||
page.plot_window = [20.0, 5.0, 400.0, 200.0];
|
||||
page.scale_to_fit = true;
|
||||
let png = |s: &mut Session, p: Value| s.execute("preview", &p).unwrap()["data"].as_str().unwrap_or_default().to_string();
|
||||
let set_center = |s: &mut Session, c: bool| s.doc_mut().unwrap().layouts.iter_mut().find(|l| l.name == "Layout1").unwrap().page.center = c;
|
||||
set_center(&mut s, true);
|
||||
let centred = png(&mut s, json!({ "layout": "Layout1", "width": 200 }));
|
||||
set_center(&mut s, false);
|
||||
// The window is fitted by width, so only the other axis shows where it sits.
|
||||
let corner = png(&mut s, json!({ "layout": "Layout1", "width": 200 }));
|
||||
assert_ne!(corner, centred, "a saved centre: false puts the fitted window at the printable corner");
|
||||
let page = &mut s.doc_mut().unwrap().layouts.iter_mut().find(|l| l.name == "Layout1").unwrap().page;
|
||||
page.center = true;
|
||||
let by_scale = png(&mut s, json!({ "layout": "Layout1", "width": 200, "scale": 0.5 }));
|
||||
assert_ne!(by_scale, centred, "an explicit scale is not the fitted plot");
|
||||
assert_eq!(
|
||||
by_scale,
|
||||
png(&mut s, json!({ "layout": "Layout1", "width": 200, "fit": false, "scale": 0.5 })),
|
||||
"scale decides, not the saved fit"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn pdfbook_collects_frames_into_one_pdf_per_row() {
|
||||
our_hooks();
|
||||
@@ -754,19 +916,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());
|
||||
}
|
||||
|
||||
+367
-30
@@ -85,12 +85,13 @@ pub fn deliver() -> Option<DeliverHook> {
|
||||
DELIVER.get().copied()
|
||||
}
|
||||
|
||||
/// How a drawing file is written: its version (`None`: the drawing's own, `$ACADVER`) and
|
||||
/// binary instead of ASCII DXF.
|
||||
/// How a drawing file is written: its version (`None`: the drawing's own, `$ACADVER`), binary
|
||||
/// instead of ASCII DXF, and Windows fonts for Linux ones ([`cadcraft_doc::Drawing::portable_fonts`]).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct SaveAs {
|
||||
pub version: Option<Version>,
|
||||
pub binary: bool,
|
||||
pub portable_fonts: bool,
|
||||
}
|
||||
|
||||
/// Write a drawing with [`SaveAs`] options (installed by hosts whose writer supports them).
|
||||
@@ -131,7 +132,12 @@ fn save_options(cmd: &str, p: &Value) -> Result<SaveAs> {
|
||||
Some(Value::Bool(b)) => *b,
|
||||
Some(_) => return Err(bad(cmd, "`binary` must be true or false")),
|
||||
};
|
||||
Ok(SaveAs { version, binary })
|
||||
let portable_fonts = match p.get("portableFonts") {
|
||||
None | Some(Value::Null) => false,
|
||||
Some(Value::Bool(b)) => *b,
|
||||
Some(_) => return Err(bad(cmd, "`portableFonts` must be true or false")),
|
||||
};
|
||||
Ok(SaveAs { version, binary, portable_fonts })
|
||||
}
|
||||
|
||||
/// Whether `path` names a DXF file (or has no extension, which saves as DXF).
|
||||
@@ -148,7 +154,9 @@ fn write_bytes(s: &Session, cmd: &str, path: &str, opts: &SaveAs) -> Result<Vec<
|
||||
}
|
||||
// A drawing file is the drawing outside the Block Editor; an image is what is shown.
|
||||
let d: &cadcraft_doc::Drawing = if is_drawing_file(path) { s.state()?.drawing() } else { s.doc()? };
|
||||
let r = if *opts == SaveAs::default() {
|
||||
let copy = (opts.portable_fonts && is_drawing_file(path)).then(|| Drawing { portable_fonts: true, ..d.clone() });
|
||||
let d = copy.as_ref().unwrap_or(d);
|
||||
let r = if opts.version.is_none() && !opts.binary {
|
||||
(hooks.write)(d, path)
|
||||
} else {
|
||||
let w = write_as().ok_or_else(|| bad(cmd, "saving at a chosen version or as binary DXF is not available in this build"))?;
|
||||
@@ -178,11 +186,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("saveas", "Save As...", run_saveas_reporting)
|
||||
.menu(&["File", "Save As..."])
|
||||
.key("Cmd+Shift+S")
|
||||
.params("{path, version?: 2000|2004|2007|2010|2013|2018, binary?: bool}")
|
||||
.params(
|
||||
"{path, version?: 2000|2004|2007|2010|2013|2018, binary?: bool, portableFonts?: bool (Windows fonts for Linux ones, in this file)}",
|
||||
)
|
||||
.noundo(),
|
||||
CommandSpec::new("document.switch", "Switch Drawing", run_switch).params("{index}").enabled(always).noundo(),
|
||||
CommandSpec::new("document.bytes", "Drawing as bytes", run_bytes)
|
||||
.params("{format?: dxf, version?: 2000…2018, binary?: bool} → {data: base64}")
|
||||
.params("{format?: dxf, version?: 2000…2018, binary?: bool, portableFonts?: bool} → {data: base64}")
|
||||
.noundo(),
|
||||
CommandSpec::new("script", "Run Script...", run_script)
|
||||
.menu(&["Tools", "Run Script..."])
|
||||
@@ -395,7 +405,7 @@ fn run_closeall(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
pub(crate) fn save_to(s: &mut Session, path: &str) -> Result<usize> {
|
||||
let opts = SaveAs { version: None, binary: s.state()?.binary_dxf && is_dxf(path) };
|
||||
let opts = SaveAs { binary: s.state()?.binary_dxf && is_dxf(path), ..SaveAs::default() };
|
||||
save_as(s, "save", path, &opts)
|
||||
}
|
||||
|
||||
@@ -420,14 +430,24 @@ 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"))]
|
||||
{
|
||||
// A file in a folder still to be made has nothing to back up.
|
||||
if is_drawing_file(path) && crate::autosave::isavebak() && crate::atomic_write::missing_folder(std::path::Path::new(path)).is_none() {
|
||||
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}")))
|
||||
crate::atomic_write::write_atomic_making_folders(std::path::Path::new(path), bytes).map_err(|e| bad("save", format!("{path}: {e}")))
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
@@ -437,6 +457,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
|
||||
@@ -483,6 +688,7 @@ fn run_saveas(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if is_drawing_file(&path) {
|
||||
r["version"] = json!(opts.version.unwrap_or_else(|| drawing_version(s, &path)).year());
|
||||
r["binary"] = json!(opts.binary);
|
||||
r["portableFonts"] = json!(opts.portable_fonts);
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
@@ -490,8 +696,11 @@ fn run_saveas(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// The version a drawing is saved in at `path` when none is asked for: its `$ACADVER` (2018 by
|
||||
/// default), and for DWG at least 2004 (the io layer's rule: R2000 DWG has no true colours).
|
||||
fn drawing_version(s: &Session, path: &str) -> Version {
|
||||
let v =
|
||||
s.doc().ok().and_then(|d| d.header.get("ACADVER").and_then(cadcraft_doc::HVal::as_str).and_then(Version::for_acadver)).unwrap_or_default();
|
||||
let v = s
|
||||
.state()
|
||||
.ok()
|
||||
.and_then(|st| st.drawing().header.get("ACADVER").and_then(cadcraft_doc::HVal::as_str).and_then(Version::for_acadver))
|
||||
.unwrap_or_default();
|
||||
if is_dxf(path) { v } else { v.max(Version::R2004) }
|
||||
}
|
||||
|
||||
@@ -520,6 +729,8 @@ fn run_bytes(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut r = json!({ "data": base64_encode(&bytes), "bytes": bytes.len() });
|
||||
if drops_unknown(format!("drawing.{fmt}")) {
|
||||
report_not_saved(s, &mut r, &lost);
|
||||
} else if version >= Version::R2013 && is_dxf_file(format!("drawing.{fmt}")) {
|
||||
report_sat_text_dropped(s, &mut r);
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
@@ -537,15 +748,28 @@ pub fn not_saved(d: &Drawing) -> std::collections::BTreeMap<String, usize> {
|
||||
/// store section, which is written back with the drawing (`Foreign::data_store`).
|
||||
const MODELER_TYPES: [&str; 3] = ["3DSOLID", "REGION", "BODY"];
|
||||
|
||||
/// Entity types CADCraft doesn't model that the DWG bridge writes all the same: acadrust reads
|
||||
/// them from our DXF into entities of its own and writes them to DWG of every version we save,
|
||||
/// an OLE object with its frame and its embedded object's bytes unchanged. Matched exactly: the
|
||||
/// bridge gets the type as stored, and acadrust knows only this spelling of it.
|
||||
const DWG_WRITES: [&str; 1] = ["OLE2FRAME"];
|
||||
|
||||
/// [`not_saved`] for a file saved as `version`. From 2013 on, a 3D solid, region or body the data
|
||||
/// store has a record for keeps its geometry and is not counted.
|
||||
/// store has a record for keeps its geometry and is not counted; neither are [`DWG_WRITES`].
|
||||
pub fn not_saved_as(d: &Drawing, version: Version) -> std::collections::BTreeMap<String, usize> {
|
||||
let mut out: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
|
||||
let stored = if version >= Version::R2013 { d.foreign.data_store_entities() } else { Default::default() };
|
||||
let stores = std::iter::once(&d.model).chain(d.layouts.iter().map(|l| &l.entities)).chain(d.blocks.values().map(|b| &b.entities));
|
||||
for e in stores.flat_map(|s| s.iter()) {
|
||||
if let cadcraft_doc::EntityKind::Unknown(u) = &e.kind {
|
||||
if MODELER_TYPES.iter().any(|t| u.dxf_type.eq_ignore_ascii_case(t)) && stored.contains(&e.handle) {
|
||||
// A DWG before 2013 writes a solid's SAT text as it is; from 2013 the geometry has to be
|
||||
// in the data store, and SAT text alone is lost (#2334).
|
||||
if MODELER_TYPES.iter().any(|t| u.dxf_type.eq_ignore_ascii_case(t))
|
||||
&& (stored.contains(&e.handle) || (version < Version::R2013 && cadcraft_doc::foreign::has_sat_text(&u.tags)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if DWG_WRITES.contains(&u.dxf_type.as_str()) {
|
||||
continue;
|
||||
}
|
||||
let n = out.entry(u.dxf_type.clone()).or_insert(0);
|
||||
@@ -561,7 +785,37 @@ pub fn not_saved_message(lost: &std::collections::BTreeMap<String, usize>) -> Op
|
||||
return None;
|
||||
}
|
||||
let list: Vec<String> = lost.iter().map(|(t, n)| format!("{n} {t}")).collect();
|
||||
Some(format!("Not saved in the DWG file (its writer can't write these object types yet; DXF keeps them): {}", list.join(", ")))
|
||||
Some(format!(
|
||||
"Not saved in the DWG file (its writer can't write these object types yet; DXF keeps them, solids only when saved as 2010 or earlier): {}",
|
||||
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> {
|
||||
let version = drawing_version(s, path);
|
||||
if is_dxf_file(path) && version >= Version::R2013 {
|
||||
// The drawing a file holds: the host while the Block Editor is open.
|
||||
return s.state().map(|st| cadcraft_doc::foreign::sat_text_dropped(st.drawing())).unwrap_or_default();
|
||||
}
|
||||
if !drops_unknown(path) {
|
||||
return Default::default();
|
||||
}
|
||||
s.doc().map(|d| not_saved_as(d, version)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Report what the file `path`, just written at the drawing's own version, left out.
|
||||
pub(super) fn report_loss(s: &mut Session, r: &mut Value, path: &str) {
|
||||
if is_dxf_file(path) {
|
||||
if drawing_version(s, path) >= Version::R2013 {
|
||||
report_sat_text_dropped(s, r);
|
||||
}
|
||||
} else {
|
||||
let lost = loss_on_save(s, path);
|
||||
report_not_saved(s, r, &lost);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether saving to `path` leaves out the entities [`not_saved`] counts: DWG does (the DWG
|
||||
@@ -575,8 +829,53 @@ fn is_drawing_file(path: impl AsRef<std::path::Path>) -> bool {
|
||||
Format::from_path(path).is_some_and(Format::is_drawing)
|
||||
}
|
||||
|
||||
/// Whether `path` is a DXF file.
|
||||
fn is_dxf_file(path: impl AsRef<std::path::Path>) -> bool {
|
||||
Format::from_path(path) == Some(Format::Dxf)
|
||||
}
|
||||
|
||||
/// Say on the command line which solid modeler entities a DXF of 2013 or later left out because
|
||||
/// they hold SAT-text ACIS data, which such files keep in a data store instead (#2334).
|
||||
fn report_sat_text_dropped(s: &mut Session, r: &mut Value) {
|
||||
// The drawing a file holds: the host while the Block Editor is open.
|
||||
let lost = s.state().map(|st| cadcraft_doc::foreign::sat_text_dropped(st.drawing())).unwrap_or_default();
|
||||
if lost.is_empty() {
|
||||
return;
|
||||
}
|
||||
let list: Vec<String> = lost.iter().map(|(t, n)| format!("{n} {t}")).collect();
|
||||
s.echo(format!(
|
||||
"Not saved in the DXF file (2013 and later keep ACIS geometry in a data store these entities have no record in; save as 2010 or earlier to keep them): {}",
|
||||
list.join(", ")
|
||||
));
|
||||
if let Some(o) = r.as_object_mut() {
|
||||
o.insert("notSaved".into(), json!(lost));
|
||||
}
|
||||
}
|
||||
|
||||
/// The folder a write to `path` has to create, asked before writing so it can be reported after.
|
||||
/// The web has no folders.
|
||||
pub(super) fn new_folder(path: &str) -> Option<String> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
return crate::atomic_write::missing_folder(std::path::Path::new(path)).map(|f| f.display().to_string());
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let _ = path;
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Say on the command line that the file went into a folder that didn't exist, so a mistyped path
|
||||
/// is noticed, and name the folder in the result's `createdFolder`.
|
||||
pub(super) fn report_new_folder(s: &mut Session, r: &mut Value, folder: Option<String>) {
|
||||
let Some(folder) = folder else { return };
|
||||
s.echo(format!("Created the folder {folder}, which did not exist. Check the path if you did not mean to."));
|
||||
if let Some(o) = r.as_object_mut() {
|
||||
o.insert("createdFolder".into(), json!(folder));
|
||||
}
|
||||
}
|
||||
|
||||
/// Say on the command line what the file couldn't keep, and list it in the result's `notSaved`.
|
||||
fn report_not_saved(s: &mut Session, r: &mut Value, lost: &std::collections::BTreeMap<String, usize>) {
|
||||
pub(super) fn report_not_saved(s: &mut Session, r: &mut Value, lost: &std::collections::BTreeMap<String, usize>) {
|
||||
let Some(msg) = not_saved_message(lost) else { return };
|
||||
s.echo(msg);
|
||||
if let Some(o) = r.as_object_mut() {
|
||||
@@ -585,24 +884,64 @@ fn report_not_saved(s: &mut Session, r: &mut Value, lost: &std::collections::BTr
|
||||
}
|
||||
|
||||
/// Save, then report the entities the file couldn't keep (nothing is dropped silently).
|
||||
fn save_reporting(s: &mut Session, p: &Value, run: fn(&mut Session, &Value) -> Result<Value>) -> Result<Value> {
|
||||
fn save_reporting(s: &mut Session, p: &Value, run: fn(&mut Session, &Value) -> Result<Value>, asked: Option<Version>) -> Result<Value> {
|
||||
// What the file will hold depends on the version it is saved as (the data store from 2013).
|
||||
// QSAVE takes no `version` and keeps the drawing's own; SAVEAS writes the one asked for.
|
||||
let path = str_param(p, "path").map(str::to_string).or_else(|| s.state().ok().and_then(|st| st.path.clone())).unwrap_or_default();
|
||||
let version = save_options("save", p).ok().and_then(|o| o.version).unwrap_or_else(|| drawing_version(s, &path));
|
||||
let version = asked.unwrap_or_else(|| drawing_version(s, &path));
|
||||
let lost = s.doc().map(|d| not_saved_as(d, version)).unwrap_or_default();
|
||||
let folder = new_folder(&path);
|
||||
let mut r = run(s, p)?;
|
||||
report_new_folder(s, &mut r, folder);
|
||||
if r.get("path").and_then(Value::as_str).is_some_and(drops_unknown) {
|
||||
report_not_saved(s, &mut r, &lost);
|
||||
} else if version >= Version::R2013 && r.get("path").and_then(Value::as_str).is_some_and(is_dxf_file) {
|
||||
report_sat_text_dropped(s, &mut r);
|
||||
}
|
||||
if r.get("path").and_then(Value::as_str).is_some_and(is_drawing_file) {
|
||||
report_fonts(s, &mut r);
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// Name the text styles whose font in the saved file AutoCAD on Windows doesn't have: in the
|
||||
/// result, and at the command line when the list changed since the last save. Styles from an xref
|
||||
/// (`XREF|name`) aren't saved: AutoCAD takes them from the xref.
|
||||
fn report_fonts(s: &mut Session, r: &mut Value) {
|
||||
use cadcraft_fonts::windows::{missing_on_windows, windows_font, written_font};
|
||||
let portable = r.get("portableFonts").and_then(Value::as_bool).unwrap_or(false);
|
||||
let Ok(d) = s.state().map(|st| st.drawing()) else { return };
|
||||
let fonts: Vec<(String, String)> = d
|
||||
.text_styles
|
||||
.iter()
|
||||
.filter(|t| !t.name.contains('|') && missing_on_windows(written_font(&t.font, portable)))
|
||||
.map(|t| (t.name.clone(), t.font.clone()))
|
||||
.collect();
|
||||
let list = fonts.iter().map(|(style, font)| format!("{style} ({font})")).collect::<Vec<_>>().join(", ");
|
||||
let changed = s.state_mut().map(|st| std::mem::replace(&mut st.fonts_noted, list.clone()) != list).unwrap_or(true);
|
||||
if fonts.is_empty() {
|
||||
return;
|
||||
}
|
||||
if changed {
|
||||
let hint = if fonts.iter().any(|(_, font)| windows_font(font).is_some()) {
|
||||
" SAVEAS with portableFonts writes Windows fonts for those that have one."
|
||||
} else {
|
||||
""
|
||||
};
|
||||
s.echo(format!("Fonts AutoCAD on Windows doesn't have: {list}.{hint}"));
|
||||
}
|
||||
if let Some(o) = r.as_object_mut() {
|
||||
o.insert("nonPortableFonts".into(), json!(fonts.iter().map(|(style, font)| json!({ "style": style, "font": font })).collect::<Vec<_>>()));
|
||||
}
|
||||
}
|
||||
|
||||
fn run_qsave_reporting(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
save_reporting(s, p, run_save)
|
||||
save_reporting(s, p, run_save, None)
|
||||
}
|
||||
|
||||
fn run_saveas_reporting(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
save_reporting(s, p, run_saveas)
|
||||
let asked = save_options("saveas", p)?.version;
|
||||
save_reporting(s, p, run_saveas, asked)
|
||||
}
|
||||
|
||||
const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
@@ -652,20 +991,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 +1018,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 +1028,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 ----------
|
||||
|
||||
@@ -217,6 +217,11 @@ fn run_dist(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let a = point3_req(s, "dist", p, "p1")?;
|
||||
let b = point3_req(s, "dist", p, "p2")?;
|
||||
let d = b - a;
|
||||
if !d.len().is_finite() {
|
||||
return Err(bad("dist", "the distance is too large"));
|
||||
}
|
||||
s.distance = d.len();
|
||||
s.last_point = a.xy();
|
||||
Ok(json!({
|
||||
"distance": d.len(),
|
||||
"angle": d.xy().angle().to_degrees(),
|
||||
@@ -285,6 +290,14 @@ fn entity_z(e: &Entity, p: Vec2) -> Option<f64> {
|
||||
EntityKind::MText(t) => Some(t.insert.z),
|
||||
EntityKind::Insert(i) => Some(i.insert.z),
|
||||
EntityKind::Face3d(f) => face_z(&f.corners, p).or_else(|| along(&f.corners, true)),
|
||||
// Drawn as its faces' visible edges: the Z on the nearest one.
|
||||
EntityKind::PolyfaceMesh(m) => m
|
||||
.faces3d()
|
||||
.iter()
|
||||
.flat_map(cadcraft_doc::Face3d::visible_edges)
|
||||
.map(|(a, b)| on_segment(a, b))
|
||||
.min_by(|a, b| a.1.total_cmp(&b.1))
|
||||
.map(|(z, _)| z),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -422,6 +435,8 @@ fn run_area(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(what) = too_large(a, per) {
|
||||
return Err(bad("area", format!("the {what} is too large")));
|
||||
}
|
||||
s.area = a;
|
||||
s.perimeter = per;
|
||||
Ok(json!({ "area": a, "perimeter": per, "message": area_text(s, a, per) }))
|
||||
}
|
||||
|
||||
@@ -508,6 +523,13 @@ pub(super) fn object_area(s: &Session, h: Handle) -> Result<(f64, f64)> {
|
||||
total
|
||||
}
|
||||
EntityKind::Region(r) => (r.area(), r.perimeter()),
|
||||
// Like an open polyline: the area closed by the chord, the perimeter its own length.
|
||||
EntityKind::Spline(sp) if sp.is_valid() => {
|
||||
let b = sp.bounds();
|
||||
let pts = sp.tessellate_spans((b.min.dist(b.max) * 1e-7).max(1e-12));
|
||||
// A length past f64's range is too large to report, as a polyline's is, not 0.
|
||||
(cadcraft_geom::shoelace(&pts).abs(), sp.checked_length().unwrap_or(f64::INFINITY))
|
||||
}
|
||||
_ => return Err(bad("area", "object has no area")),
|
||||
})
|
||||
}
|
||||
@@ -574,6 +596,8 @@ fn run_inspect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"view": { "center": [st.view().center.x, st.view().center.y], "height": st.view().height },
|
||||
// Records of the file the drawing came from that the reader could not place.
|
||||
"skipped": { "count": d.skipped_records.values().fold(0usize, |a, n| a.saturating_add(*n)), "sections": d.skipped_records },
|
||||
// What reading the file left out, in words (a damaged DWG's unreadable object records).
|
||||
"readNotes": d.read_notes,
|
||||
});
|
||||
if bool_or(p, "entities", true)
|
||||
&& let (Some(o), Some(store)) = (out.as_object_mut(), d.space(&space))
|
||||
@@ -840,11 +864,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 +874,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),
|
||||
_ => {}
|
||||
@@ -868,16 +888,25 @@ impl Interactive for DistM {
|
||||
self.first_source = s.picked_viewport;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(Some(a), Input::Point(b)) => {
|
||||
(Some(first), Input::Point(b)) => {
|
||||
let (a, b) = match (self.first_source, s.picked_viewport) {
|
||||
(Some(first), Some(second)) if first.viewport == second.viewport => (first.point, second.point),
|
||||
_ => (a, b),
|
||||
_ => (first, b),
|
||||
};
|
||||
let (za, zb) = if self.first_source.is_some() || s.picked_viewport.is_some() { (0.0, 0.0) } else { (z_at(s, a), z_at(s, b)) };
|
||||
let t = dist_text(s, Vec3::new(a.x, a.y, za), Vec3::new(b.x, b.y, zb));
|
||||
let (a, b) = (Vec3::new(a.x, a.y, za), Vec3::new(b.x, b.y, zb));
|
||||
if !(b - a).len().is_finite() {
|
||||
// The first point stays LASTPOINT, as after a measurement.
|
||||
s.last_point = first;
|
||||
return Err(bad("dist", "the distance is too large"));
|
||||
}
|
||||
let t = dist_text(s, a, b);
|
||||
for l in t.lines() {
|
||||
s.echo(l.to_string());
|
||||
}
|
||||
// As in AutoCAD, the second point doesn't become LASTPOINT: the first stays.
|
||||
s.distance = (b - a).len();
|
||||
s.last_point = first;
|
||||
Ok(Step::Done)
|
||||
}
|
||||
(Some(a), Input::Keyword(k)) if k == "Multiple points" => {
|
||||
@@ -905,7 +934,11 @@ impl DistM {
|
||||
.as_deref()
|
||||
.or(self.multi.as_deref())
|
||||
.map_or(0.0, |pts| if closed { arcs::polyline(pts, &self.bulges, true).len() } else { path_len(pts, &self.bulges) });
|
||||
if !d.is_finite() {
|
||||
return Err(bad("dist", "the distance is too large"));
|
||||
}
|
||||
s.echo(format!("Distance = {}", fmt_d(s, d)));
|
||||
s.distance = d;
|
||||
Ok(Step::Done)
|
||||
}
|
||||
}
|
||||
@@ -1119,10 +1152,14 @@ impl AreaM {
|
||||
return if self.mode == AreaMode::Single { Step::Done } else { Step::Continue };
|
||||
}
|
||||
s.echo(area_text(s, a, per));
|
||||
// AREA: this area, or in Add and Subtract mode the running total; PERIMETER: this area's.
|
||||
s.perimeter = per;
|
||||
if self.mode == AreaMode::Single {
|
||||
s.area = a;
|
||||
return Step::Done;
|
||||
}
|
||||
self.total = total;
|
||||
s.area = total;
|
||||
s.echo(format!("Total area = {}", fmt_d(s, self.total)));
|
||||
Step::Continue
|
||||
}
|
||||
|
||||
@@ -182,7 +182,7 @@ fn loaded_linetype(d: &cadcraft_doc::Drawing, cmd: &str, p: &Value) -> Result<Op
|
||||
}
|
||||
|
||||
fn valid_name(n: &str) -> bool {
|
||||
!n.trim().is_empty() && n.len() <= 255 && !n.chars().any(|c| "<>/\\\":;?*|,=`".contains(c))
|
||||
!n.trim().is_empty() && n.len() <= 255 && !n.chars().any(|c| c.is_control() || "<>/\\\":;?*|,=`".contains(c))
|
||||
}
|
||||
|
||||
fn run_new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -84,11 +84,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.menu(&["File", "Print..."])
|
||||
.key("Cmd+P")
|
||||
.alias(&["print"])
|
||||
.params("{path? (else returns base64 `data`), layout?: current|\"Model\", paper?, landscape?, fit?: bool, scale?: number | \"1:50\", center?: bool (Model, default true), offset?: [x,y] mm (Model, when center is false; each within ±100000), lineweights?: bool, plotTransparency?: bool (default: the page setup's; PLOTTRANSPARENCYOVERRIDE 0/2 overrides it), plotArea?: extents|display|limits|window (Model) | layout, window?: [[x1,y1],[x2,y2]]} (typed or from the menu: the Plot dialog)")
|
||||
.params("{path? (else returns base64 `data`), layout?: current|\"Model\", device?: \"CADCraft PDF\"|\"CADCraft PNG\"|\"CADCraft SVG\" | a plotter from PLOTTERMANAGER | \"None\" (default: a layout's page setup device; Model, or \"None\": no device, PDF; a device sets the output format and the papers), paper?: the device's paper (default: a layout keeps its page setup's; Model takes the device's default), landscape?, fit?: bool, scale?: number | \"1:50\", center?: bool (Model, default true), offset?: [x,y] mm (Model, when center is false; each within ±100000), lineweights?: bool, plotTransparency?: bool (default: the page setup's; PLOTTRANSPARENCYOVERRIDE 0/2 overrides it), plotArea?: extents|display|limits|window (Model) | layout, window?: [[x1,y1],[x2,y2]]} (typed or from the menu: the Plot dialog)")
|
||||
.noundo(),
|
||||
CommandSpec::new("exportpdf", "Export to PDF...", |s, p| run_plot(s, &super::plotters::plot_options(s, p, "exportpdf")?, "exportpdf"))
|
||||
.menu(&["File", "Export to PDF..."])
|
||||
.params("{path? (else returns base64 `data`), layout?, paper?, landscape?, fit?, scale?, center?: bool (Model, default true), offset?: [x,y] mm (Model, when center is false; each within ±100000), lineweights?, plotTransparency?, plotArea?, window?} (as PLOT)")
|
||||
.params("{path? (else returns base64 `data`), layout?, device? (its papers; the output stays PDF), paper?, landscape?, fit?, scale?, center?: bool (Model, default true), offset?: [x,y] mm (Model, when center is false; each within ±100000), lineweights?, plotTransparency?, plotArea?, window?} (as PLOT)")
|
||||
.noundo(),
|
||||
CommandSpec::new("mspace", "Model Space (in viewport)", run_mspace)
|
||||
.alias(&["ms"])
|
||||
@@ -313,7 +313,7 @@ fn run_copy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let h = d.new_handle();
|
||||
store.push(Entity { handle: h, ..Entity::clone(e) });
|
||||
}
|
||||
d.layouts.push(Layout { name: to.clone(), tab_order: order, page: src.page, entities: store, view: src.view });
|
||||
d.layouts.push(Layout { name: to.clone(), tab_order: order, page: src.page, entities: store, view: src.view, limits: src.limits });
|
||||
Ok(json!({ "name": to }))
|
||||
}
|
||||
|
||||
@@ -840,8 +840,11 @@ fn run_plot(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
|
||||
let path = _path;
|
||||
// Exclusively create a fresh temporary file and rename it into place so a planted
|
||||
// `<path>.cadcraft-tmp` symlink cannot redirect the plot write (CWE-59).
|
||||
crate::atomic_write::write_atomic(std::path::Path::new(path), &bytes).map_err(|e| bad(cmd, format!("{path}: {e}")))?;
|
||||
Ok(json!({ "path": path, "bytes": bytes.len(), "layout": layout, "pages": pages }))
|
||||
let folder = super::file::new_folder(path);
|
||||
crate::atomic_write::write_atomic_making_folders(std::path::Path::new(path), &bytes).map_err(|e| bad(cmd, format!("{path}: {e}")))?;
|
||||
let mut r = json!({ "path": path, "bytes": bytes.len(), "layout": layout, "pages": pages });
|
||||
super::file::report_new_folder(s, &mut r, folder);
|
||||
Ok(r)
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
|
||||
@@ -333,9 +333,8 @@ fn run_stylesmanager(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some(_path) => {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let tmp = format!("{_path}.cadcraft-tmp");
|
||||
std::fs::write(&tmp, text.as_bytes()).map_err(|e| bad(cmd, format!("{_path}: {e}")))?;
|
||||
std::fs::rename(&tmp, _path).map_err(|e| bad(cmd, format!("{_path}: {e}")))?;
|
||||
crate::atomic_write::write_atomic(std::path::Path::new(_path), text.as_bytes())
|
||||
.map_err(|e| bad(cmd, format!("{_path}: {e}")))?;
|
||||
Ok(json!({ "name": name, "path": _path, "bytes": text.len() }))
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
|
||||
@@ -104,8 +104,10 @@ pub(super) fn run_saveas(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let t = template_of(d, &name)?;
|
||||
let hooks = io().ok_or_else(|| bad("layout.saveas", "file formats are not available in this build"))?;
|
||||
let bytes = (hooks.write)(&t, &path).map_err(|e| bad("layout.saveas", e))?;
|
||||
let folder = super::super::file::new_folder(&path);
|
||||
store(&path, &bytes)?;
|
||||
let mut r = json!({ "name": name, "path": path, "bytes": bytes.len() });
|
||||
super::super::file::report_new_folder(s, &mut r, folder);
|
||||
let lost = not_saved(&t);
|
||||
if drops_unknown(&path)
|
||||
&& let Some(msg) = not_saved_message(&lost)
|
||||
@@ -124,7 +126,7 @@ fn store(path: &str, bytes: &[u8]) -> Result<()> {
|
||||
{
|
||||
// Exclusively create a fresh temporary file and rename it into place so a planted
|
||||
// `<path>.cadcraft-tmp` symlink cannot redirect the write (CWE-59).
|
||||
crate::atomic_write::write_atomic(std::path::Path::new(path), bytes).map_err(|e| bad("layout.saveas", format!("{path}: {e}")))
|
||||
crate::atomic_write::write_atomic_making_folders(std::path::Path::new(path), bytes).map_err(|e| bad("layout.saveas", format!("{path}: {e}")))
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
|
||||
@@ -293,3 +293,17 @@ fn mspace_edits_model_through_viewport() {
|
||||
s.execute("layout.set", &json!({"name": "Model"})).unwrap();
|
||||
assert!(s.state().unwrap().mspace.is_none());
|
||||
}
|
||||
|
||||
/// PLOT and EXPORTPDF help name the `device` parameter, and PLOT's help every built-in device (#2557).
|
||||
#[test]
|
||||
fn plot_help_names_the_device_and_the_built_in_devices() {
|
||||
for id in ["plot", "exportpdf"] {
|
||||
let help = crate::find_command(id).map(|c| c.params).unwrap_or_default();
|
||||
assert!(help.contains("device?"), "{id}: {help}");
|
||||
}
|
||||
let help = crate::find_command("plot").map(|c| c.params).unwrap_or_default();
|
||||
for name in ["CADCraft PDF", "CADCraft PNG", "CADCraft SVG"] {
|
||||
assert!(super::super::plotters::find_device(name).is_some(), "{name} is not a device");
|
||||
assert!(help.contains(name), "plot help lacks {name}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ mod break_join;
|
||||
mod explode;
|
||||
mod fillet;
|
||||
mod offset;
|
||||
mod oops;
|
||||
mod select_then;
|
||||
mod stretch;
|
||||
mod transform;
|
||||
@@ -30,6 +31,8 @@ use explode::run_explode;
|
||||
pub(crate) use explode::{explode_kind, explode_with};
|
||||
use fillet::{FilletM, run_chamfer, run_fillet};
|
||||
use offset::{OffsetM, run_offset};
|
||||
use oops::run_oops;
|
||||
pub(crate) use oops::{Erased, erase_kept};
|
||||
use select_then::{DisplaceM, Op, SelectThen};
|
||||
use stretch::{StretchM, run_stretch};
|
||||
pub(crate) use transform::transform_entities;
|
||||
@@ -43,6 +46,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.alias(&["e", "delete"])
|
||||
.params("{handles?: [hex]} (default: selection)")
|
||||
.interactive(|_| Ok(Box::new(SelectThen::new(Op::Erase)))),
|
||||
CommandSpec::new("oops", "Oops", run_oops).params("{} (brings back what the last ERASE, BLOCK or -WBLOCK removed)"),
|
||||
CommandSpec::new("move", "Move", run_move)
|
||||
.menu(&["Modify", "Move"])
|
||||
.alias(&["m"])
|
||||
@@ -61,7 +65,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]);
|
||||
|
||||
@@ -151,6 +151,12 @@ pub(crate) fn explode_kind(d: &cadcraft_doc::Drawing, e: &Entity) -> Option<Vec<
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
// A polyface mesh explodes into its 3D faces; one without any stays as it is.
|
||||
EntityKind::PolyfaceMesh(m) => {
|
||||
let faces = m.faces3d();
|
||||
(!faces.is_empty())
|
||||
.then(|| faces.into_iter().map(|f| Entity { handle: Handle(0), common: e.common.clone(), kind: EntityKind::Face3d(f) }).collect())
|
||||
}
|
||||
EntityKind::MLine(m) => Some(crate::cmd::draw2::mline::explode(e, m)),
|
||||
EntityKind::MText(m) => Some(explode_mtext(e, m)),
|
||||
// A region explodes into its boundary's lines and arcs.
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
//! OOPS: bring back the objects the last ERASE (or the Delete key) removed, or BLOCK deleting the
|
||||
//! objects it makes a block of, or -WBLOCK the objects it writes, with their handles and each where
|
||||
//! it was in the draw order, as AutoCAD 2024 does (accoreconsole, the drawing's `entnext` order read
|
||||
//! back afterwards).
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_doc::{Drawing, Entity, EntityKind, EntityStore, Handle, Space};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{Result, Session};
|
||||
|
||||
/// One erased object, with the objects that were directly under and above it.
|
||||
#[derive(Clone, Debug)]
|
||||
struct Taken {
|
||||
space: Space,
|
||||
entity: Arc<Entity>,
|
||||
below: Option<Handle>,
|
||||
above: Option<Handle>,
|
||||
}
|
||||
|
||||
/// What the drawing's last erase removed, for OOPS.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct Erased(Vec<Taken>);
|
||||
|
||||
impl Erased {
|
||||
/// Never bring back the objects `drop` picks.
|
||||
pub(crate) fn forget(&mut self, drop: impl Fn(Handle) -> bool) {
|
||||
self.0.retain(|t| !drop(t.entity.handle));
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove the objects `hs` and keep them for OOPS in place of what an earlier erase kept.
|
||||
/// Returns how many went; when none did, OOPS keeps what it had.
|
||||
pub(crate) fn erase_kept(s: &mut Session, hs: &[Handle]) -> Result<usize> {
|
||||
let d = s.doc_mut()?;
|
||||
// Every neighbour is read before anything goes: objects erased together may be neighbours.
|
||||
let at: Vec<(Handle, Space, Option<Handle>, Option<Handle>)> = hs
|
||||
.iter()
|
||||
.filter_map(|&h| {
|
||||
let space = d.space_of(h)?;
|
||||
let (below, above) = d.space(&space)?.neighbours(h)?;
|
||||
Some((h, space, below, above))
|
||||
})
|
||||
.collect();
|
||||
let mut taken = Vec::with_capacity(at.len());
|
||||
for (h, space, below, above) in at {
|
||||
if let Some(entity) = d.space_mut(&space).and_then(|st| st.remove(h)) {
|
||||
taken.push(Taken { space, entity, below, above });
|
||||
}
|
||||
}
|
||||
let n = taken.len();
|
||||
// During REFEDIT, removing an object outside the working set gets the command undone
|
||||
// (`refedit::after_command`), so what OOPS had stays.
|
||||
let refused = taken.iter().any(|t| crate::cmd::blocks::refedit::locked(d, t.entity.handle));
|
||||
if n > 0 && !refused {
|
||||
s.state_mut()?.erased = Erased(taken);
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
pub(super) fn run_oops(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let taken = s.state()?.erased.0.clone();
|
||||
let editing = s.state()?.block_edit.as_ref().map(|b| b.name.clone());
|
||||
let back: Vec<bool> = {
|
||||
let d = s.doc()?;
|
||||
let in_blocks = in_blocks(d, &taken, editing.as_deref());
|
||||
taken.iter().map(|t| !in_blocks.contains(&t.entity.handle) && can_return(d, t)).collect()
|
||||
};
|
||||
// Nothing to bring back leaves the drawing as it is: no undo step.
|
||||
if !back.contains(&true) {
|
||||
return Ok(json!({ "restored": 0 }));
|
||||
}
|
||||
let n = restore(s.doc_mut()?, &taken, &back);
|
||||
Ok(json!({ "restored": n }))
|
||||
}
|
||||
|
||||
/// The handles of `taken` that a block's object has now (an undo can take the drawing back to
|
||||
/// before an erased object was made, and a block's new object take its handle). The block open
|
||||
/// in the Block Editor (`editing`) doesn't count: its objects there keep the handles they have in
|
||||
/// it.
|
||||
fn in_blocks(d: &Drawing, taken: &[Taken], editing: Option<&str>) -> HashSet<Handle> {
|
||||
let hs: HashSet<Handle> = taken.iter().map(|t| t.entity.handle).collect();
|
||||
let mut out = HashSet::new();
|
||||
for b in d.blocks.values() {
|
||||
if editing.is_some_and(|n| b.name.eq_ignore_ascii_case(n)) {
|
||||
continue;
|
||||
}
|
||||
// Through whichever is smaller.
|
||||
if b.entities.len() < hs.len() {
|
||||
out.extend(b.entities.iter().map(|e| e.handle).filter(|h| hs.contains(h)));
|
||||
} else {
|
||||
out.extend(hs.iter().copied().filter(|h| b.entities.contains(*h)));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether `t` can come back: it is not in the drawing (OOPS again, or an undo brought it back),
|
||||
/// its handle is one the drawing has given out, a reference being edited in place (REFEDIT)
|
||||
/// doesn't keep it out, and its layout, layer and block are still there.
|
||||
fn can_return(d: &Drawing, t: &Taken) -> bool {
|
||||
let h = t.entity.handle;
|
||||
h.0 < d.handseed
|
||||
&& d.entity(h).is_none()
|
||||
&& !crate::cmd::blocks::refedit::locked(d, h)
|
||||
&& d.space(&t.space).is_some()
|
||||
&& d.layer(&t.entity.common.layer).is_some()
|
||||
&& match &t.entity.kind {
|
||||
EntityKind::Insert(i) => d.block(&i.block).is_some(),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Put back the objects `back` marks. Each run of objects that were neighbours comes back from
|
||||
/// its bottom one up, so each goes directly above the one that was under it.
|
||||
fn restore(d: &mut Drawing, taken: &[Taken], back: &[bool]) -> usize {
|
||||
let index: HashMap<Handle, usize> = taken.iter().enumerate().map(|(i, t)| (t.entity.handle, i)).collect();
|
||||
let next: HashMap<Handle, usize> = taken.iter().enumerate().filter_map(|(i, t)| Some((t.below.filter(|b| index.contains_key(b))?, i))).collect();
|
||||
let mut done = vec![false; taken.len()];
|
||||
let mut n = 0;
|
||||
for first in 0..taken.len() {
|
||||
if taken[first].below.is_some_and(|b| index.contains_key(&b)) {
|
||||
continue;
|
||||
}
|
||||
let mut at = Some(first);
|
||||
while let Some(i) = at {
|
||||
if std::mem::replace(&mut done[i], true) {
|
||||
break;
|
||||
}
|
||||
if back[i] && put_back(d, taken, &index, i) {
|
||||
n += 1;
|
||||
}
|
||||
at = next.get(&taken[i].entity.handle).copied();
|
||||
}
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
/// Put `taken[i]` back directly above the nearest object that was under it and is here; when
|
||||
/// those are gone, directly under the nearest one that was above it; else on top.
|
||||
fn put_back(d: &mut Drawing, taken: &[Taken], index: &HashMap<Handle, usize>, i: usize) -> bool {
|
||||
let t = &taken[i];
|
||||
let Some(store) = d.space_mut(&t.space) else { return false };
|
||||
let h = t.entity.handle;
|
||||
store.push(Entity::clone(&t.entity));
|
||||
match nearest(store, taken, index, t.below, |t| t.below) {
|
||||
Some(Some(b)) => {
|
||||
store.move_next_to(h, b, true);
|
||||
}
|
||||
Some(None) => {
|
||||
store.send_to_back(h);
|
||||
}
|
||||
None => {
|
||||
if let Some(Some(a)) = nearest(store, taken, index, t.above, |t| t.above) {
|
||||
store.move_next_to(h, a, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// From `h`, past objects erased with it that are not here, to the first object that is
|
||||
/// (`Some(Some)`), the end of the draw order (`Some(None)`), or an object that is gone (`None`).
|
||||
fn nearest(
|
||||
store: &EntityStore,
|
||||
taken: &[Taken],
|
||||
index: &HashMap<Handle, usize>,
|
||||
mut h: Option<Handle>,
|
||||
step: fn(&Taken) -> Option<Handle>,
|
||||
) -> Option<Option<Handle>> {
|
||||
for _ in 0..=taken.len() {
|
||||
let Some(x) = h else { return Some(None) };
|
||||
if store.contains(x) {
|
||||
return Some(Some(x));
|
||||
}
|
||||
h = step(taken.get(*index.get(&x)?)?);
|
||||
}
|
||||
None
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user